深入剖析网络IO复用
IO复用好像是个比较复古的话题,从最早21世纪初的Dan Kegel提出的C10K问题,一直到Linux 2.6版本引入epoll支持IO复用,再到应用层软件Nginx实现全异步的IO处理,单机网络并发问题才逐步得到标准解决方案。然而到了最近的微服务时代,云原生概念和底层基础设施的蓬勃发展,使大规模部署越来越简单,仿佛不再特别强调单机的处理能力。再加上java, golang之类的高级语言不断开疆扩土,网络相关交互的封装越来越高级,IO复用的底层实现离业务代码越来越远,甚至有的同学不知道底层的socket接口为何物。
但无论技术如何演进,网络编程都有其底层追求:如何用最小资源监控最多连接。所以本文将目光回溯,再聊一聊这个古早的话题,深入的探讨一下网络IO复用的底层原理,那我们就从原始的 socket 系统接口开始。
Socket接口
计算机网络可以说是二十世纪最伟大的发明之一,其后世发展之迅速甚至超过了设计计算机网络先辈们的预估。正是计算机网络将千千万万的终端用户联系在一起,最终催生了互联网产业,所形成的第三次信息革命浪潮到现在都还在滚翻演进。
现代操作系统都将网络协议栈封装在了操作系统内核中,对外暴露TCP, UDP协议接口。这些接口,使得我们可以在用户层操作收发网络数据,我们先简单看一下接口都有哪些。因为计算机网络中80%的流量都是TCP流量,我们这里的接口以TCP为例,其实UDP也大同小异。
1.socket接口
int socket(int domain, int type, int protocol);
网络编程的起点,通过这个接口向操作系统申请一个socket资源,返回用户层一个socket描述符,内核会准备好网络操作的相关资源,包括:接收数据缓存,发送数据缓存,拥塞控制状态,TCP状态等。
2. bind 接口
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
将刚才申请到的socket绑定到本地一个端口上,操作系统内核通过ip地址来寻址找到主机,通过端口来找到用户层应用。
3. listen 接口
int listen(int sockfd, int backlog);
启动端口的监听,服务端通过该接口检测是否有新的连接请求到来。
4. accept 接口
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
接收新的socket连接,服务端通过此接口获取新的客户端连接,accept到的新socket已经在内核中完成了建连的三次握手。
5. send 接口
ssize_t send(int sockfd, const void *buf, size_t len, int flags);
发送数据,内核协议栈将要发送的数据,从用户层的应用内存拷贝到内核的发送缓存。
6. recv 接口
ssize_t recv(int sockfd, void *buf, size_t len, int flags);
接收数据,内核协议栈将收到的数据从内核接收缓存拷贝到用户层应用内存。
7. close 接口
int close(int fd);
关闭socket连接,主动调用close的一端会开启四次挥手断开连接。
8. connect 接口
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
发起连接,客户端通过这个接口向服务端主动发起连接,开启三次握手。
上述的接口是操作系统提供的最基础网络接口,绝大多数TCP网络交互通过这些接口完成。在描述中有一些三次握手和四次挥手之类的协议实现描述,是TCP协议经典的面试问题,本文不聚焦于网络协议的实现,不再细表网络实现相关的内容,我们主要着眼在上述的接口调用上。
阻塞接口的问题
之前介绍的操作系统接口中有几个比较特殊:
accept: 当服务端调用这个接口之后,没有新的连接请求进来怎么办?默认情况下会阻塞住,一直等到有新的连接请求到来为止。
send: 当内核的发送缓存不足以容纳应用要发送的数据怎么办?也是会阻塞住,一直等到协议栈将数据发送出去,缓存有空间来存放数据。
recv: 当内核的接收缓存没有数据可读的时候怎么办?还是会阻塞住,一直等到协议栈收到了新的数据。
其他的接口即使也有阻塞的过程,但都没有这三个致命,因为上述三个接口在服务端的处理过程中会多次调用,阻塞式的处理过程无疑会大大降低服务端的处理能力。在IO复用技术出现以前,处理思路主要是通过一个线程来阻塞式得等待客户端的请求连接,每收到一个请求,再新建一个线程或使用线程池技术申请一个空闲线程来处理新的客户端连接。总的来说,就是one socket per thread。这样的处理有什么问题呢?之前的文章《软件性能之CPU》中已经提到过大量的创建线程会导致宝贵的cpu时间消耗在上下文切换以及cache line更新等待上。
既然one socket per thread有问题,那么自然而然的想到multi socket per thread,但还不能简单的将多个socket塞进一个线程。因为一个socket的接口调用阻塞,会导致socket都无法得到处理,所以需要有一个机制来实现哪个socket需要数据处理,就操作哪个socket。首先我们就不能再让接口阻塞,在Linux系统上,我们可以通过设置socket的 NONBLOCK 属性:
int32_t SocketNoblocking(uint64_t sock) {
int32_t old_option = fcntl(sock, F_GETFL);
int32_t new_option = old_option | O_NONBLOCK;
fcntl(sock, F_SETFL, new_option);
return old_option;
}
设置O_NONBLOCK之后,再调用之前的 accept, send, recv 接口,不论成功还是失败,都不会再阻塞,那就需要面对另外三个问题:
-
如何判断是否真实执行了操作(accept到新连接,send新数据,recv新数据,还是没真实操作但是因为不阻塞返回了)
-
什么时候去调用socket的接口,应该操作哪个socket?
非阻塞之后
我们首先来看第一个问题:操作系统接口都近似C语言函数,所以在执行判断上也类似C语言函数的错误处理,一是要看函数返回值,二是要看系统的errno值。我们这里以recv接口为例,在Linux系统上,recv接口的返回值有几种含义:
-
正常有数据接收的情况下,返回值大于0,表示真实接收的数据大小(其实是从内核接收缓存拷贝到用户层内存的数据大小,什么时候接收多少数据对应用层是透明的)
-
返回值等于0的时候,表示socket连接关闭
-
返回值小于0的时候,表示数据接收异常,具体是何种异常,则需要进一步判断errno值
errno的值在不同操作系统上的定义有所差异,这里依然以Linux系统为例:
-
EAGAIN: 当socket为非阻塞,当内核无数据可读时,返回此错误码
-
EWOULDBLOCK: 等同于 EAGAIN
-
EINTR: 信号中断调用,应该接着执行recv操作
有了上述值的含义,我们就可以在recv调用之后从容的判断到底该怎么处理。
我们再看第二个问题。首先想到的方式是:我就在线程里循环调用接口可不可以,反正也能判断是否发生了真实操作,当内核没有数据的时候,就再试下一个socket。理论上是可行的,但有几个问题:
-
socket接口有send,recv该调用哪个呢(accept接口和recv道理类似,也可以视为读事件处理)
-
不间歇的死循环会将CPU功耗打满,不利于节能减排(这很重要)
对于这两个问题,操作系统提供了第一代IO复用接口:select 和 poll。这两个接口支持:
-
当没有socket读写操作的时候,调用会阻塞在select, poll的接口调用上。
-
当有socket读写操作的时候,select, poll会返回,并告知具体是读操作还是写操作。
这有些类似编程设计里的消息通知机制,所以在IO复用接口中将socket的读写操作定义为读写事件,我们将socket的读写事件注册到select, poll接口中,当有相关读写事件就绪的时候,这些接口就会返回通知我们具体的事件类型。
既然select, poll已经解决了开始我们提到的问题,为什么还出现了epoll, IOCP, kqueue呢?select, poll主要问题是:内核管理socket的时候使用了单一的数据结构,select使用了固定长度的数组,poll使用了链表。我们注册事件的时候会添加到这个数组或链表中,当有读写事件到来的时候,select, poll也会返回这个数组或链表的全量数据,具体哪个socket应该执行读写操作,还得遍历一下这个返回的全量数据才行。当一个线程处理10000个socket的时候,一个socket触发读写事件,就要把这10000个socket都遍历一下,显然是不可取的。
epoll驱动模型
有了前文的讨论作为铺垫,我们可以开始正式介绍epoll(mac os的kqueue和epoll几乎一样,windows的IOCP则风格迥异, 但本文以Linux为主,所以主要介绍epoll)。我们再来回顾下epoll要解决的问题:
-
什么时候有了读写事件?
-
哪些socket发生了事件?
-
具体是读事件还是写事件?
socket通过三个接口配合来解决上述问题:
1. 创建epoll实例
int epoll_create(int size);
返回一个epoll描述符。size历史上用于指定监控fd数量(Linux 2.6.8+忽略此值,内核动态分配)
2. 管理监控列表
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
-
epfd: epoll_create返回的描述符
-
op: 操作类型
-
EPOLL_CTL_ADD: 添加新fd到监控集
-
EPOLL_CTL_MOD: 修改已有fd的监控事件
-
EPOLL_CTL_DEL: 从监控集移除fd
-
fd: 要操作的目标文件描述符(Linux将所有资源都视为文件,所以这里的fd可以是文件描述符,也可以是socket描述符)
-
event: 监控事件配置(传入NULL会出错)
epoll_event定义为:
typedef union epoll_data {
void *ptr; // 用户数据指针
int fd; // 可直接指定关联fd
uint32_t u32; uint64_t u64;
} epoll_data_t;
struct epoll_event {
uint32_t events; // 要监控的事件标志集
epoll_data_t data; // 用户数据(事件触发时原样返回)
};
常用事件标志:
-
EPOLLIN:数据可读
-
EPOLLOUT:数据可写
-
EPOLLRDHUP:对端关闭连接(或关闭写)
-
EPOLLPRI:紧急数据可读(TCP带外数据)
-
EPOLLERR:发生错误(自动监控无需显式设置)
-
EPOLLHUP:挂起(对端断开等)
-
EPOLLET:设置为边缘触发(Edge Triggered)模式(默认水平触发LT)
-
EPOLLONESHOT:事件只通知一次(需重新注册)
3. 等待事件就绪
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
-
epfd: epoll实例描述符
-
events: 输出参数,存放就绪事件的数组
-
maxevents: events数组最大容量(必须>0)
-
timeout: 超时时间(毫秒)
a. -1: 永久阻塞
b. 0: 立即返回
c. >0: 等待指定毫秒数
这几个接口的语义还是比较明确的。首先通过epoll_create创建一个epoll,然后将socket的读写事件通过epoll_ctl注册到epoll上,最后调用epoll_wait等待socket读写事件就绪。epoll_wait返回的events列表里只有真实发生读写事件的socket,免去了select, poll的遍历过程。
因为最终调用的epoll_wait调用依然是阻塞的,所以这类IO复用技术又叫做半阻塞式IO复用。
全异步式IO复用依赖操作系统提供全异步接口,将上述的监听调度过程都下放到内核中实现,只是现在好像还没有普及使用,目前市面上依然以半阻塞式IO复用技术为主。
epoll实现原理
上文提到通过epoll的三个接口,就解决了socket异步通知的问题,那epoll内部是怎么实现的呢?
epoll内部主要通过两个数据结构来完成:
-
红黑树: 管理所有注册的socket
-
双向链表:所有触发读写事件的socket都会放到这个双向链表中
epoll_create接口调用,内核会准备上述两个数据结构实例
epoll_ctl接口调用,内核会在红黑树上操作添加,修改删除节点
epoll_wait接口调用,内核会将双向链表数据返回
就这么简单?就这么简单。我们接下来看一下一个socket读事件从网卡到内核再到用户层的全过程:
-
网卡收到数据,将数据通过DMA技术将数据包放到预分配的缓存上
-
触发硬件中断,通知cpu处理网卡数据
-
内核线程处理数据包,通过协议栈处理识别到数据包的协议五元组:协议类型,源端口,目标端口,源IP, 目标IP
-
通过五元组定位到唯一socket,将数据存入socket的接收缓存区,触发socket就绪回调
-
epoll接收到socke就绪回调,将socket添加到双向链表中
-
epoll_wait接口返回,将双向链表数据返回给用户层
-
处理双向链表
最后对双向链表的不同处理,产生了面试高频问题:epoll的水平触发(LT)和边缘触发(ET):
-
水平触发(LT): 检测socket是否依然有数据可读,如果有,则将socket保留在活动链表中,等待下一次epoll_wait返回
-
边缘触发(ET): 只要epoll_wait返回结束,则将socket从双向链表删除
多线程处理
我们有了epoll之后,就可以解决单个线程中处理多个socket的问题。但为了支持更高的并发量,很显然需要多线程,那么在多个线程的情况下,如何使用epoll呢?是一个线程申请一个epoll描述符?还是多个线程复用一个epoll描述符?accept socket应该放在哪个线程中?如果多个线程同时accept会有什么问题?凡事遇到多线程并发,都会变得复杂起来,这里也不例外。
我们逐个来看一下上述的系列问题:
多个线程一个epoll还是多个线程多个epoll?
这本质上是处理socket与线程的关系,当每个线程一个epoll的时候,意味着所有的读写操作都将在这一个线程中被唤醒,即这个socket的所有读写过程乃至整个连接的生命周期都只在这一个线程中活动。而多个线程共用一个epoll的时候,则socket的读写事件无法预测在哪个线程中被唤醒,这意味着需要对socket的读写操作进行竞争条件控制,避免多线程同时读写引起并发问题。再者epoll本身的接口并不保证在多个线程同时调用epoll_ctl操作一个socket时候的线程安全,也需要应用层来加锁控制。
accept 的 socket放在哪个线程中?
是多个线程多个socket,还是多个线程一个socket,是用锁控制,还是用操作系统机制控制?
这其实是经典的惊群问题,我们的控制条件很多,处理手段也很多。这里把所有的条件都组合一下,可以得到一个这样的表格:
EPOLLEXCLUSIVE | reuse_port | 监听socket个数 | epoll描述符个数 | 线程数 | 唤醒线程数 | 成功accept线程数 | 没有惊群 |
❌ | ❌ | 1 | 1 | 8 | 1~2 | 1 | ❌ |
❌ | ❌ | 1 | 8 | 8 | 3~8 | 1 | ❌ |
❌ | ✅ | 8 | 1 | 8 | 1~2 | 1 | ❌ |
❌ | ✅ | 8 | 8 | 8 | 1 | 1 | ✅ |
✅ | ❌ | 1 | 1 | 8 | 1~2 | 1 | ❌ |
✅ | ❌ | 1 | 8 | 8 | 1 | 1 | ✅ |
我们来归纳下方案:
-
可以通过一定的机制来控制,将这个accept socket间接的放到每个线程中,但同一时期只有一个线程会注册这个socket的读取事件,这个机制要有一定的负载均衡能力,以保证每个线程中处理的socket数量是接近的,这就是Nginx解决惊群问题的方式。随着Nginx的发展和普及,这种方式几乎到达了家喻户晓的程度。我刚毕业面试的时候,凡涉及到Nginx,则必有此问题,所以此种方案没有在上述表格中体现。
-
我们还可以设置accept socket启用端口复用机制(REUSE_PORT),开启端口复用后可以创建多个socket bind到相同的地址端口上。我们为每个线程都创建一个socket然后bind相同的端口,这样每次有新连接到来的时候,内核会控制负载均衡过程,决定唤醒哪个线程来接收新的客户端连接。但这样的机制下,每个监听socke都t拥有独立的协议栈控制块,即连接过程的半连接队列和全连接队列,如果线程销毁,投递到这个线程的新建连请求都会得不到响应而导致客户端建连超时。
-
epoll 还支持 EPOLLEXCLUSIVE 参数,来控制线程唤醒的惊群问题,使用也非常简单,只需要在调用epoll_ctl注册socket的时候添加上EPOLLEXCLUSIVE参数即可,epoll内部会处理多个线程唤醒的负载均衡调度过程。但这个参数是在Linux4.5版本才引入的,需要检测一下系统版本是否支持。
每个处理线程都维护一个主循环,主循环阻塞在epoll_wait调用上,收到内核唤醒后开始异步处理所有的socket操作,接着再次阻塞在epoll_wait调用上,等待下一次唤醒,这就是经典的 one loop per thread 线程处理模型。
如何组织缓存
到现在,我们已经解决了异步网络接口调用和多线程处理问题,再往上,就要进行应用层的协议处理,不论文本传输的HTTP/1,还是二进制序列化的HTTP/2, grpc等协议,都需要完整的消息体才能进行之后的处理操作,以HTTP/1为例子:
HTTP/1一个请求分为:
-
请求行
-
请求头
-
请求体
虽然这三部分内容是可以单独拆开处理的,但请求头和请求体往往不能只处理半部分,而且这里的半还是无法掌控的,具体是在哪里进行的拆分,完全是TCP协议内部拥塞控制算法,滑动窗体,内核缓存大小等因素控制的,作为HTTP协议的下层,TCP自然不会也无法兼顾HTTP的拆包解包问题。那如果调用recv接口收到了request body的一部分该怎么办呢?应用层无法只通过部分body做处理,内核缓存大小固定,而且一旦我们recv读取,内核就会释放对应的缓存内存。
我们只能自己维护一个缓存来存放接收到的数据,这个缓存最好能满足以下几点要求:
-
能够缓存未知大小的数据,因为收到的网络数据大小是不确定的
-
为了提高效率,应该最小化内存拷贝次数
为了支持上述特性,我们可以使用内存池技术来实现这个缓存。组织一个单向链表,每个节点都是一个固定大小的内存块儿,再通过一个内存池维护所有空闲的内存块。数据的写入读取过程就是一个指针从左往右移动的过程。
-
写入过程:优先查看链表尾部的内存块是否已满,如果已经写满数据,则向内存池申请一个新的内存块添加到链表尾部,执行写入。
-
读取过程:查看头部内存块是否有数据可读,读取完成后将头部内存块归还给内存池,头部指针后移,接着读下一个内存块。
单个内存块内部通过两个指针控制读写位置,依次只能向右移动。
我们如何高效的将数据从分块缓存写入和写出呢?对上,我们可以封装一下支持协议解析的接口,使应用层不必完整的拷贝数据就可以实现协议解析,比如HTTP/1的情况下,缓存的接口支持查找 \r\n 标识。对下,我们可以使用操作系统提供的高级IO接口: readv 和 writev,中文叫做集中读和分散写,这两个接口使得我们可以将分块的缓存地址传入内核,内核直接从接收缓存中拷贝数据到指定的内存块上,只需一次拷贝即可实现分块的数据读写。
通过上述策略,就可以实现一个功能完备的数据缓存。
java的NIO
写java的同学讲到高性能,多会谈及到NIO,Java的NIO提供了三个组件来支持异步IO过程,分别是:select, channel 和 buffer。
通过前文的分析,我们可以清晰的认识到这三个类其实是对不同的系统资源做了封装:
-
select 是对epoll之类的事件驱动机制做了封装,不同的操作系统提供了不同的事件驱动机制,而java要做到跨平台,所以需要实现一个select的封装,在不同平台上使用不同的底层机制来支持半异步的IO处理过程。
-
channel 是对socket的封装,我们对channel的读写操作,最终都会调用到底层的socket读写接口上。
-
buffer 是对缓存的封装。java又分别提供了两种实现:ByteBuffer和DirectBuffer。ByteBuffer的内存申请在jvm中,其生命周期由jvm的垃圾回收算法控制。DirectBuffer则直接申请在操作系统的堆上,和C的malloc有些类似,需要java自己控制内存的生命周期。
这又引生出另外一个问题:java的内存普遍是由jvm控制的,为什么有了ByteBuffer之后还需要实现DirectBuffer呢?
要回答这个问题,需要简单的概述一下java的gc(垃圾回收)算法,不论是Parallel GC 还是 G1GC都需要经历内存的整理过程,这个整理过程简单来讲就是将还在使用的内存数据拷贝集中到一起,以减少内存碎片提高内存的使用效率。如果我们使用ByteBuffer发送数据,在调用send之后刚好开始gc会发生什么?ByteBuffer所指向的内存可能会被gc算法挪走!所以即使我们调用了channel.send(ByteBuffer), java还是会内部创建一个临时的DirectBuffer拷贝数据,以找到一个固定的不会被移动的内存位置。
从根源上讲HeapBuffer添加的原因是和gc不兼容导致的。如果我们可以修改gc算法,标记一下正在发送的缓存不要被gc算法清除整理,理论上也可以实现直接使用ByteBuffer进行数据发送,但这就使得gc算法实现更加复杂。
golang的协程魔法
写golang的同学可以轻易使用协程来处理网络调用过程,甚至以同步调用的方式实现高性能的网络编程。golang的协程到底使用了什么黑魔法?
太阳底下没有新鲜事,我们通过前文的讨论已经知道操作系统层面只提供了epoll之类的半阻塞IO复用机制(AIO尚未普及), 所以golang底层一定也是使用了这种半阻塞的机制。大家都知道协程是轻量级的用户层资源,其调度的过程对操作系统而言是透明的。当我们在golang中调用了send之类的阻塞接口,其实golang并没有阻塞底层的系统执行线程,而是将这个协程从线程上调度走,更换一个非阻塞的协程来执行(golang 经典的GMP协程调度框架)。那么这个协程什么时候被唤醒呢? Bingo! epoll通知到这个socket有读写事件的时候。
所以从本质上讲,golang是在epoll之类的事件驱动模型上套了一个协程的架子:
-
在阻塞调用的时候将事件注册到epoll中,将协程切走
-
在epoll唤醒通知socket读写的时候,更改协程状态标记为待执行
你如果打开golang的源码库,就可以在runtime的模块下找到epoll的相关封装实现。
腾讯有一个libco开源库,其对epoll调用和协程的封装则更为直接:通过hook send, recv等相关系统网络接口, 替换对应的实现。当有recv等接口被调用时,将对应的协程调度出去,对应的socket事件注册到epoll中,当epoll_wait唤醒后,再将对应socket的协程唤醒,在调用方看来就是调用了一个send阻塞接口而已。以同步的编码方式实现异步网络编程,避免陷入到无尽的回调地狱中。
上述讲到的协程都是有栈协程,那么无栈协程是怎么处理的?
我们首先区分下什么是有栈协程和无栈协程:
-
有栈协程:即每个协程都有自己的调用栈,调度的时候有个地方会存储当前协程执行的上下文栈信息,协程切换的过程就是将cpu的寄存器指到对应协程的调用栈上,可以在任意时候进行协程切换,协程使用方是无感知的。
-
无栈协程:本质上是个状态机,只不过是编译器在编译阶段进行的函数修改。编译器会将协程函数添加标记,在不同阶段执行不同的代码段。调度的过程依然遵循函数调用逻辑,不能随意切换,使用方也需要显式的标记co_wait等协程关键字来提醒编译器进行协程编译。
所以如果在无栈协程中实现recv等接口的调用,那么编译器会recv的调用函数拆分为两个部分:
-
前半部分到recv接口调用,这时候函数返回,状态机标记一阶段执行完成,将对应的socket添加到epoll中监听读取事件
-
后半部分在epoll_wait唤醒通知socket事件后再次被调用,从recv接口返回处开始执行后续代码
总结
不觉间已经写了接近一万字,我们从最基础的socket接口开始讲起,一直到异步IO系统机制,再到缓存管理,还聊到高级语言对网络调用的封装。但网络编程是个复杂的话题,依然有很多技术点我们没有讨论到:比如零拷贝技术,IOCP的Proactor模式,新的异步IO io_uring等等,即使万字长文也难以穷尽。
回顾我们整个讨论的过程,可以发现底层原理都是一样的,只不过不同的技术封装对上层有了不同的技术表现,也再次论证了 “基础知识,一通百通” 的道理。
在后端服务编程领域,计算机网络是绕不过去的一个话题,不论是哪种计算机语言,或者哪种技术框架,是nginx, 是envoy, 还是是pingora,都是在本文讨论的技术原理之上去追求更好的性能,更高的灵活性。如果大家能通过此文了解到这些异步IO的基础原理,下次遇到相关问题虽不能说驾轻就熟,但至少不会摸不着头脑,与我就算是一件善事。
与诸君共勉。