高性能网络框架汇总
高性能网络框架汇总
简介:从网络原理出发,引出四种Linux平台常用框架及以及windows异步并发架构。
引言
计算机网络在应用程序架构中扮演十分核心的功能,通过网络计算机与计算机之间才得以互相通信、数据交互,进而协作。在当前流行的大多应用,其底层都离不开TCP/IP进行可靠传输。Linux操作系统提供的网络IO接口为我们提供了最基础的两机器通信的桥梁。在这基础之上,提出了一些新的架构使得作为服务器的主机可以在短时间内处理大量并发请求,其中有基于select/poll/epoll的reactor事件驱动模型、优化IO的类proactor架构io_uring、同步编程实现异步性能的网络协程框架、绕过内核基于dpdk的用户态协议栈等常见框架。本文将从POSIX API的基本原理出发引出上述框架,分别在测试其在不同数据包长度的qps、并发连接数量、建链所耗时间等性能指标,综合对比特性分析各个框架在具体业务中的适配性。
网络原理
- POSIX API(POSIX API网络通信TCP协议接口详解 - +_+0526 - 博客园)
在计算机网络通信中,POSIX API指的是一系列在通信双方调用的接口,其功能就是用于连接的建立以及数据的收发。常见API如下:
在一次基于TCP/IP协议的完整通信中,各个接口都起到了不同的作用:
- 建立连接:
TCP连接的建立需要通信双方进行三次报文握手,其基本目标简单来说:通过前两次的握手来确认连接及收发的序号,在第三次握手向被请求端发送确认并且带去数据。被请求端先于请求端调用listen()处于LISTEN状态,等待请求端调用connect()进行三次报文握手。
- 发送数据:
传输数据在协议层面采用滑动窗口等机制确保数据正确收发(从TCP到可靠传输UDP - +_+0526 - 博客园)。
send()/recv()函数是数据收发过程中的核心函数,在用户态层面我们只需要将待发送数据交给send()、使用recv()去接收数据,但是真正的网络通信是由内核中的协议栈统一管理。用户态的数据只会被拷贝出/进内核,内核根据TCB去与指定ip与port的机器通信。成也萧何败也萧何,可以说这种明确的用户权限管理带来的是绝对的安全性,但同时也拖慢了整个流程的执行效率,操作系统需要频繁地在内核态和用户态之间进行切换,在高并发的场景下开销无法估量。我们后面提到的各种框架其根本目的就是从不同角度去解决这个问题,从而达到C100k、C1000k的并发数量。
- 断开连接:
TCP连接的关闭由四次报文挥手组成,这样设计的根本目的就是确保数据的完整收发。
当然网络原理不仅局限于端到端之间的通信,一对多、多对多的数据收发在现今的直播推流、网络游戏等高实时性的场景有着大量的应用。(从TCP到可靠传输UDP - +_+0526 - 博客园)
与此同时,整个计算机网络还需要在宏观层面进行调控,这就涉及到一系列拥塞控制的操作。
高性能网络框架
- 基于epoll的reactor事件驱动(基于reactor的百万并发和webserver、websocket应用 - +_+0526 - 博客园)
epoll脱胎于poll,事件的管理在poll的事件循环基础上优化为红黑树,使得其无论是对事件的查找、插入、删除等操作都达到了O(logN)的程度;对于就绪事件使用双向链表做到O(1)的就绪事件的插入删除。从理论程度上达到了C10k+以上的性能。
reactor在epoll事件管理的基础上,将就绪事件替换为回调函数,将业务与网络分离实现解耦。
将事件注册到epoll中,main loop循环等待就绪事件,当事件就绪将就绪fd返回并执行IO操作。在这整个流程中epoll不关心具体的IO操作,它只负责管理并随时监听事件。
在reactor架构中,fd被根据状态被对应到不同的回调函数。此时epoll完全与 IO操作抽离,所有的后续操作都交给回调函数处理。这也就是所说的网络与业务解耦。在这基础上修改回调函数就可以适配任何的业务,将这种架构抽象出来就形成了reactor。
- 基于协程的网络IO框架(协程详解以及网络IO的协程框架 - +_+0526 - 博客园)
协程为并发执行异步操作提供了更多的可能性。协程的上下文切换及协调构成了整个协程框架,它的核心目标是:既然当前的IO还需要等待一定时间来完成,那么我不妨将当前线程的CPU分配给其他需要处理的IO,在大量IO的场景下将所有IO操作交给一个统一的程序协调管理。
- Proactor架构
proactor模型不同于reactor,它的核心是将会导致线程阻塞的IO接管执行,只返回给用户结果。这一类的模型一般会组织两个队列:请求队列、完成/就绪队列。而用户唯一需要做的事情,就是往请求队列中加入事件、从就绪队列中取出结果。采用这种架构的框架,将IO操作做到了真正的异步:用户提交IO即使操作没有完成,也会立即返回,真正的结果由main loop发起就绪通知或用户主动调用。
Linux平台常用IO_Uring框架。(异步IO之IO_Uring - +_+0526 - 博客园)
windows采用iocp。(Windows异步IO之IOCP - +_+0526 - 博客园)
- 基于DPDK的用户态协议栈(用户态协议栈:基于dpdk的自定义网络协议栈 - +_+0526 - 博客园)
DPDK的目标更为简单粗暴,网络IO操作的低效源于内核态/用户态的频繁切换。那么DPDK则是直接绕过了繁琐的状态切换,直接去网卡拿数据。这种架构使得DPDK得以更高的速度进行数据的读写。
向上暴露的极大自由度,使得用户完全可以针对业务定制网络协议栈,唯一的缺点DPDK的高自由度要求用户底层及网络协议有较深的理解。
性能测试
- reactor
- 不同数据包的qps
- C1000K
- IO_Uring
- 不同数据包qps
- C1000K
- 协程
- 不同数据包qps
- C1000K
相关面试问题
- TCP三次握手、四次挥手。
- udp如何实现高并发。
udp不基于连接,通过模拟tcp的握手机制实现可靠连接。每一次连接分配不同的端口,模拟tcp的状态机。当连接建立成功后再开辟新的端口用于数据传输。
- tcp与udp的使用场景。
- tcp基于连接,传输的数据有极强可靠性,并且数据严格按照发送的顺序接收。因此适用于一些强要求数据可靠的应用,例如采用http协议的网页应用、FTP的文件传输、以及数据库操作等强烈要求可靠传输的应用。
- udp在协议栈中不需要建立连接,省去了可靠传输的繁琐验证具有较强实时性。在实时视频/音频流、量化交易等需要短时间交互大量小型数据包的应用中频繁使用。
考量因素 | 倾向TCP | 倾向UDP |
---|---|---|
可靠性要求 | 必须保证数据完整 | 可以容忍少量丢包 |
实时性要求 | 可接受一定延迟 | 需要极低延迟 |
数据量 | 大数据量传输 | 小数据包频繁传输 |
连接性质 | 长连接 | 无连接或短连接 |
网络状况 | 适应各种网络条件 | 稳定网络环境下更有效 |
开发复杂度 | 系统自动处理可靠性 | 需自行实现可靠性机制 |
- 使用tcp传输大数据包的分包与粘包问题。
因为MTU的限制,通过tcp传输大型数据包时往往需要进行分包,tcp协议中有相关机制确保数据按照顺序到达接收端,但并不代表应用层的数据会按照发送时的消息边界接收,因此我们需要处理这种tcp应用层数据的分包与粘包问题。
例如发送方发送"Hello World",经过分包对端可能经过两次接收:"Hel"、"lo World",简单的文本消息可能还能勉强解读,如果是复杂的自定义协议会造成应用完全无法运行。
常用两种方法解决分包与粘包问题:
- 固定长度消息
每一次的数据发送都与客户端约定一个固定的数据收发长度。
message = "Hello".ljust(1024, '\0') // 固定1024字节
sock.send(message)
data = sock.recv(1024) # 总是读取1024字节
- 分隔符
收发双方约定同一个符号作为消息的边界值,发送时编码消息接收时解析消息。
// 发送方
void send_msg(int sock, char* msg) {
char buf[MAX_SIZE];
sprintf(buf, "%s\n", msg); // 添加换行符作为分隔符
send(sock, buf, strlen(buf), 0);
}
// 接收方
void recv_msg(int sock) {
static char buffer[BUFFER_SIZE]; // 静态缓冲区保存剩余数据
static int remain = 0; // 剩余数据长度
int recv_len = recv(sock, buffer + remain, BUFFER_SIZE - remain, 0);
remain += recv_len;
char* end = strchr(buffer, '\n'); // 查找分隔符
while (end) {
*end = '\0'; // 截断成独立消息
process(buffer); // 处理消息
memmove(buffer, end + 1, remain - (end - buffer) - 1); // 移动剩余数据
remain -= (end - buffer) + 1;
end = strchr(buffer, '\n'); // 继续查找
}
}
- 长度前缀
发送方在待发送的数据前加入两字节数据标识该数据的长度;接收数据时分两次接收,先接收标识长度的字节,后根据解析的字节值读取数据。
// 发送方
void send_msg(int sock, char* msg) {
int len = strlen(msg);
send(sock, &len, sizeof(len), 0); // 先发长度(4字节)
send(sock, msg, len, 0); // 再发内容
}
// 接收方
void recv_msg(int sock) {
int len;
recv(sock, &len, sizeof(len), 0); // 先收长度
char* buf = malloc(len + 1); // 分配缓冲区
recv(sock, buf, len, 0); // 再收内容
buf[len] = '\0'; // 添加结束符
process(buf); // 处理消息
free(buf);
}
- 上文中介绍了四类高性能网络框架,他们的优劣点是什么,分别适合什么业务场景?分别例举几个常用业务。
- reactor
通过epoll监听大量文件描述符,当事件就绪时调用预注册的回调函数。在主函数中执行的epoll只负责事件分发,IO操作在事件触发后由工作线程异步进行。经过多年迭代趋于稳定,有成熟的跨平台方案支持;但复杂编程场景下容易出现回调地狱,且后续的IO操作仍需要阻塞线程等待,属于C10K问题常用解决方案。常在Web服务器、API网关等高并发场景使用。
- io_uring
自Linux5.1引入的异步IO框架,通过共享环形队列实现用户态/内核态零拷贝提交和完成事件,支持批量提交方案。io_uring框架下,系统状态切换大大减少且支持polling模式批量提交进一步降低延迟;实现了真正的异步IO,IO操作由内核完成。同时对Linux版本内核有一定要求,以及零拷贝存在的内存对齐和队列溢出等问题。常用用于数据库存储及大文件异步传输。
- dpdk
绕过内核协议栈,零拷贝直接从网卡映射到用户内存,用户应用直接轮询网卡队列。dpdk绕过了用户态/内核态之间的一系列高延时操作实现了纳秒级延迟。具有百万级PPS能力、同时开源社区有完备的各类协议库。但同样因为高自由度,开发极其复杂且需要使用指定网卡。
- 协程
运行在用户态的轻量级线程,由主线程统一调度,在IO阻塞时自动切换上下文。协程自身并不解决IO阻塞问题,需配合其他异步IO模式。实现较为简单,以同步逻辑编辑避免异步的回调函数,同时上下文切换近似等于函数调用。但某些场景导致一个线程阻塞会导致调度器崩溃,同时需要结合多进程才能多核协作。常用于游戏服务器和即时通讯等服务器中。
特性 | Reactor (epoll) | io_uring | DPDK | 协程 (Coroutine) |
---|---|---|---|---|
核心思想 | 事件驱动、同步非阻塞 I/O | 异步 I/O、用户/内核共享队列 | 用户态轮询、内核旁路 | 用户态轻量级线程、协作式调度 |
编程模型 | 回调函数 (Callback) | 回调或协程 (co_await) | 轮询 (Polling) | 同步风格写异步代码 |
性能 | 高 (万级 QPS) | 极高 (十万级 QPS,减少系统调用) | 极致 (百万级 PPS,零拷贝、无中断) | 高 (低切换开销,但依赖底层 I/O 模型) |
CPU 利用率 | 高 | 非常高 | 极致 (独占 CPU,无上下文切换) | 高 (但单协程阻塞会拖垮调度器) |
延迟 | 低 | 极低 (批处理、提前提交) | 超低 (微秒级,避免内核中断) | 低 (切换开销小) |
开发复杂度 | 中高 (回调地狱) | 中高 (需管理队列) | 极高 (需重写协议栈、绑定 CPU) | 低 (线性代码逻辑) |
适用场景 | Web 服务器、API 网关、即时通讯 | 高性能存储、数据库、低延迟 Web 服务 | 防火墙、路由器、5G UPF、金融交易所 | 高并发连接服务 (游戏、IM)、微服务 |
代表应用 | Nginx、Netty、Redis | co-uring-http、ScyllaDB、QEaaS | OVS、VPP、LigHTTPD、TRex | Gevent (Python)、Boost.Asio (C++)、Golang net |