网络I/O模型以及多路复用
i/o模型事实上是一个通用的概念,本文主要通过介绍网络i/o模型以及具体解析i/o多路复用模型的原理来方便大家理解。
1. 简介
网络通信双方通过socket建立连接后,当其中一个进程要进行一次read操作时,需要两个阶段
第一阶段:等待数据
第二阶段:从内核复制数据到用户进程
在这两个阶段的不同处理方式产生了五种网络I/O模型:
2. 五种I/O模型
2.1. 阻塞I/O
两个阶段全阻塞,recvfrom调用阻塞到syscall,确定数据准备好了,内核自动开始第二阶段,应用程序一直阻塞到返回数据
缺点:每个连接需要独立的进程/线程单独处理,当并发请求量大时为了维护程序,内存、线程切换开销较大,这种模型在实际生产中很少使用。
2.2. 非阻塞I/O
第一阶段费非阻塞,即每次调用recvfrom,内核将立马返回是否可读可写,如果不可读可写,那么应用程序将一直调用来检查,第二阶段同样是一直阻塞到内核返回数据
缺点:轮询是通过系统调用来获取,来回切换用户态和内核态,当fd特别多时,成本几何上升
2.3. I/O 多路复用
由于同步非阻塞方式需要不断主动轮询,轮询占据了很大一部分过程,轮询会消耗大量的CPU时间,而 “后台” 可能有多个任务在同时进行,人们就想到了循环查询多个任务的完成状态,只要有任何一个任务完成,就去处理它。如果轮询不是进程的用户态,而是有人帮忙就好了。那么这就是所谓的 “IO 多路复用”。
以select为例,当应用程序调用select时,会将多个socketfd交给内核,内核将循环扫描可用的socketfd,就是将轮询的任务交给了内核,此时应用程序阻塞在select上,直到select返回某个可用的fd,就可以开始第二阶段来获取内核的数据,这个阶段同样是阻塞的
2.4. 信号驱动I/O
首先开启套接口信号驱动I/O功能,并通过系统调用sigaction
执行一个信号处理函数(此系统调用立即返回,进程继续工作,它是非阻塞的),这时候应用程序可以做其他事情。
当数据准备就绪时,内核就为该进程生成一个SIGIO
信号,通过信号回调通知应用程序调用recvfrom来读取数据,并通知应用程序处理数据,使得应用程序可以开始第二阶段调用recvfrom阻塞读取数据了
就是在第一阶段构造一个信号处理器,并且发送给内核,这时候应用程序可以做其他事情,当内核准备好数据后通过主动给应用程序发送信号,使得应用程序可以开始第二阶段调用recvfrom阻塞读取数据了
信号驱动 I/O 尽管对于处理 UDP 套接字来说有用,即这种信号通知意味着到达一个数据报,或者返回一个异步错误。
但是,对于 TCP 而言,信号驱动的 I/O 方式近乎无用,因为导致这种通知的条件为数众多,每一个来进行判别会消耗很大资源,与前几种方式相比优势尽失。
2.5. 异步I/O
上面4中其实都被归纳为同步io,主要是因为第二阶段真正进行I/O操作时都是阻塞进程的,只有异步 I/O 模型才与 POSIX 定义的异步 I/O 相匹配,linux提供了AIO库函数实现异步,用户进程只需要发起aio_read,就可以做其他的事情了,内核会将两个阶段的事情全部做完后给用户进程发起通知,用户进程就可以处理数据了
缺点:要实现真正的异步 I/O,操作系统需要做大量的工作。目前 Windows 下通过 IOCP 实现了真正的异步 I/O。
2.6. 参考
https://juejin.im/entry/5b9122fee51d450e3d2c9687
https://segmentfault.com/a/1190000021587865#item-1-2
3. I/O多路复用的几种方式
这么多模型,最常用的就是IO多路复用模型,这个和实际生产有关,因为当采用了复杂的模型,就会提升编程难度,但是简单模型又不能达到我们的业务需求,所以取中间值,,,IO多路复用模型(PS:我猜的/狗头)
3.1. Linux的socket 事件wakeup callback机制
在介绍select、poll、epoll前,有必要说说linux(2.6+)内核的事件wakeup callback机制,这是IO多路复用机制存在的本质。
Linux通过socket睡眠队列来管理所有等待socket的某个事件的process,同时通过wakeup机制来异步唤醒整个睡眠队列上等待事件的process,通知process相关事件发生。
我们可以理解为,每个socket维护了一个队列,比如socket可读的时候,内核就会唤醒队列里的各个Process,并且执行每个Process的callback
函数。总体上会涉及两大逻辑:
睡眠等待逻辑:
select
、poll
、epoll_wait
陷入内核,判断监控的socket是否有关心的事件发生了,如果没,则为当前Process构建一个wait_entry
节点,然后插入到监控socket的sleep_list
里。- Linux调用
schedule
函数进行Process的状态转换,shcedule
函数是Linux的调度Process的函数,这里指的是Process进入sleep
直到超时或者事件发生,简单说就是将没有事件发生的process阻塞睡眠。 - 关心的事件发生后,将当前Process的
wait_entry
节点从socket的sleep_list
中删除。
唤醒逻辑:
- socket的事件发生了,然后socket顺序遍历其睡眠队列
sleep_list
,依次调用每个wait_entry
节点(对应各个Process)的callback
函数。 - 直到完成队列的遍历或遇到某个
wait_entry
节点是排他的才停止。 - 一般情况下
callback
包含两个逻辑:- wait_entry自定义的私有逻辑;
- 唤醒的公共逻辑,主要用于将该wait_entry的process放入CPU的就绪队列,让CPU随后可以调度其执行。
3.2. select
基本原理
大多情况下一个服务进程(线程)process需要同时处理多个socket,我们需要公平对待所有socket,对于read而言,哪个socket有数据可读,process就去读取该socket的数据来处理。
于是对于read,一个基本的需求就是关心的N个socket是否有数据可读,也就是我们期待可读事件的通知,而不是盲目地对每个socket调用recv/recvfrom来尝试接收数据。
我们应该block在等待事件的发生上,这个事件简单点就是关心的N个socket中一个或多个socket有数据可读了,当block解除的时候,就意味着,我们一定可以找到一个或多个socket上有可读的数据。
另一方面,根据上面的socket wakeup callback
机制,我们不知道什么时候,哪个socket会有读事件发生,于是,process需要同时插入到这N个socket的sleep_list上等待任意一个socket可读事件发生而被唤醒,当时process被唤醒的时候,其callback里面应该有个逻辑去检查具体那些socket可读了,然后把这些事件反馈给用户程序,用户程序就知道,哦,我该处理这些socket了,我可以从这些socket里读取数据了。
于是,select的多路复用逻辑就清晰了,select为每个socket引入一个poll逻辑,该poll逻辑用于收集socket发生的事件,对于可读事件来说,简单伪码如下:
1 | private int sk_event; |
解释:当receive queue
不为空的时候,我们就给这个socket的sk_event
添加一个POLL_IN
事件,用来表示当前这个socket可读。将来Process遍历到这个socket,发现其sk_event
包含POLL_IN
的时候,就可以对这个socket进行读取数据操作了。
接下来就到select的逻辑了,下面是select的函数原型:5个参数,后面4个参数都是in/out类型(当有可读事件发生时,3个fd_set
参数会被修改指向一个新的fd_set
,就是可读的集合,方便调用FD_ISSET
来判断是否有可读fd)
1 | int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout); |
当用户Process调用select
的时候,select
会将需要监控的readfds
集合拷贝到内核空间(因为内核才能通知说某个socket可读),然后遍历自己监控的socket,挨个调用socket的poll逻辑以便检查该socket是否有可读事件。
遍历完所有的socket后,如果没有任何一个sk可读,那么select
会调用schedule
,使得Process进入睡眠(或者睡眠timeout这么长时间)。如果在timeout时间内某个socket上有数据可读了,或者等待timeout了,则调用select的Process会被唤醒。
接下来select
就是遍历监控的socket集合,挨个收集可读事件并返回给用户了,相应的伪码如下:
1 | for (socket in readfds) { |
总结一下我们发现select
有两个问题:
- 被监控的
readfds
需要从用户空间拷贝到内核空间。为了减少数据拷贝带来的性能损坏,内核对被监控的fds
集合大小做了限制,并且这个是通过宏控制的(可以通过sizeof(fd_set)
得到总字节大小,但是fd是通过每个bit表示的),大小不可改变,只能通过重新编译内核等复杂方式改变。 - 被监控的
readfds
集合中,只要有一个有数据可读,整个socket集合就会被遍历一次并且调用socket的poll
函数收集可读事件。
说到这,我们有几个问题要解决:
- 被监控的fds集合限制为1024(通常在32位机器是这样的),1024太小了,我们希望能够有个比较大的可监控fds集合。
- fds集合需要从用户空间拷贝到内核空间的问题,我们希望不需要拷贝。
- 当被监控的fds中某些有数据可读的时候,我们希望通知更加精细一点,就是我们希望能够从通知中得到有可读事件的fds列表,而不是需要遍历整个fds来收集。
3.3. poll
首先这里的poll
不是上面select
中用来判断socket
状态的poll
,对于socket
,poll
方法是sock_poll
,sock_poll
根据情况会调用到tcp_poll
,udp_poll
或者datagram_poll
。
poll
非常鸡肋,只解决了第一个问题——1024的问题。poll改变了fds集合的描述方式,使用了pollfd
结构而不是select
的fd_set
结构,使得poll
支持的fds集合限制远大于select的1024。
但是,poll
并没改变大量描述符数组被整体复制于用户态和内核态的地址空间之间,以及个别描述符就绪触发整体描述符集合的遍历的低效问题。poll
随着监控的socket
集合的增加性能线性下降,poll
不适合用于大并发场景。
1 | int poll(struct pollfd *fds, nfds_t nfds, int timeout); |
3.4. epoll
1. 基本原理
问题1被poll解决了,2和3就被epoll
解决了。
这里抄一段话,计算机世界里,有两大解决问题的方式:
- 计算机科学领域的任何问题, 都可以通过添加一个中间层来解决。
- 变集中处理为分散处理。
首先可以看这篇文章关于epoll操作过程部分,了解基本使用过程。
fds集合拷贝问题的解决
对于IO多路复用,有两件事是必须要做的(对于监控可读事件而言):
- 准备好需要监控的fds集合。
- 探测并返回fds集合中哪些fd可读了。
细看select或poll的函数原型,我们会发现,每次调用select或poll都在重复地准备(集中处理)整个需要监控的fds集合。然而对于频繁调用的select或poll而言,fds集合的变化频率要低得多,我们没必要每次都重新准备(集中处理)整个fds集合。
于是,epoll
引入了epoll_ctl
系统调用,将高频调用的epoll_wait
和低频的epoll_ctl
隔离开。epoll_ctl
通过(EPOLL_CTL_ADD
、EPOLL_CTL_MOD
、EPOLL_CTL_DEL
)三个操作来分散对需要监控的fds集合的修改,做到了有变化才变更,将select
或poll
高频、大块内存拷贝变成epoll_ctl
的低频、小块内存的拷贝,避免了大量的内存拷贝。这就是我们说的,分散处理方式。
同时,对于高频epoll_wait
的可读就绪的fd集合返回的拷贝问题,epoll
通过内核与用户空间mmap
同一块内存来解决。mmap
将用户空间的一块地址和内核空间的一块地址同时映射到相同的一块物理内存地址(不管是用户空间还是内核空间都是虚拟地址,最终要通过地址映射映射到物理地址),使得这块物理内存对内核和对用户均可见,减少用户态和内核态之间的数据交换。
另外,epoll
通过epoll_ctl
来对监控的fds集合来进行增、删、改,那么必须涉及到fd的快速查找问题。于是,一个低时间复杂度的增、删、改、查的数据结构来组织被监控的fds集合是必不可少的了。
在linux 2.6.8之前的内核,epoll
使用hash
来组织fds集合,于是在创建epoll fd
的时候,epoll
需要初始化hash
的大小。于是epoll_create(int size)
有一个参数size
,以便内核根据size
的大小来分配hash
的大小。在linux 2.6.8以后的内核中,epoll
使用红黑树来组织监控的fds集合,所以参数size
实际上已经没有意义了。
按需遍历就绪的fds集合
通过上面的socket的睡眠队列唤醒逻辑我们知道,socket唤醒睡眠在其睡眠队列的wait_entry
的时候会调用wait_entry
的回调函数callback
,并且,我们可以在callback
中做任何事情。为了做到只遍历就绪的fd,我们需要有个地方来组织那些已经就绪的fd。
为此,epoll
引入了一个中间层,一个双向链表ready_list
,一个单独的睡眠队列single_epoll_wait_list
,并且,与select
或poll
不同的是,epoll
的Process不需要同时插入到多路复用的socket集合的所有睡眠队列中,相反Process只是插入到中间层的epoll
的单独睡眠队列中(即single_epoll_wait_list
),Process睡眠在epoll
的单独队列上,等待事件的发生。同时,引入一个中间代理的wait_entry_sk
,它与某个socket密切相关,wait_entry_sk
睡眠在socket的睡眠队列上,其callback
函数逻辑是将当前socket排入到epoll
的ready_list
中,并唤醒epoll
的single_epoll_wait_list
。而single_epoll_wait_list
上睡眠的Process的回调函数就明朗了:遍历ready_list
上的所有socket,挨个调用socket的poll
函数收集事件,然后唤醒Process从epoll_wait
返回。
语言很苍白,我们看下图:
四张图代表四种状态:
- 调用
epoll
之前,我们希望我们的MyProcess
可以管理四个socket。 - 四个socket都没有事件,这时候
MyProcess
进入single_epoll_wait_list
并且sleep
。 - 有一个socket(大红色)收到了数据,触发其
wait_entry_sk
,把这个socket加入到ready_list
里。 MyProcess
被唤醒(从single_epoll_wait_list
出来了表示被唤醒),来处理ready_list
中的所有socket:遍历epoll
的ready_list
,挨个调用每个socket的poll
逻辑收集发生的事件,对于监控可读事件而已,ready_list
上的每个socket都是有数据可读的,这里的遍历必要的。- 此外,这里的
MyProcess
并不是直接在single_epoll_wait_list
中的,而是通过一个睡眠实体wait_entry_proc
,所以上面第四步,并不是直接在用户态调用socket的poll逻辑,还是在内核态中调用,并且是由epoll来操作的。这点在后面会讲述。 - 如果还是不能理解请参考这篇文章的代码部分,或者自行搜索示例代码进行理解
至此,我们就可以理解说为什么epoll
才是真正高性能的方式:
- 首先通过共享内存的方式减少用户态到内核态的数据拷贝
- 然后分离两种操作:高频调用的
epoll_wait
和低频的epoll_ctl
,并且所有的fds由epoll来管理 - 每次
epoll_wait
,都会返回所有可用的ready_list
,保证了每个都是需要处理的socket,但是select和poll不一样:- select返回一个
fd_set
,它包含了一个所有fd的集合,fd_set
是采用一个bit来标识每个fd的,所以需要我们遍历调用FD_ISSET
宏来判断每个fd是否可用,当然也可以单独用数组保存自己需要监控的fd然后去遍历。 - poll返回的是原数组
struct pollfd fds[]
,只是修改了其中的fd事件属性,虽然不是说每次都需要遍历固定大小例如1024的次数来判断,但是至少需要判断所有需要监控的fd
- select返回一个
2. 关于边缘触发和水平触发
我们说两种模式前,要先说一下epoll
的唤醒逻辑:
- 协议数据包到达网卡并被排入socket的接收队列。
- 睡眠在socket的睡眠队列
wait_entry
被唤醒,wait_entry_sk
的回调函数epoll_callback_sk
被执行。 epoll_callback_sk
将当前socket插入epoll
的ready_list
中。- 唤醒睡眠在
epoll
的单独睡眠队列single_epoll_wait_list
的wait_entry_proc
,wait_entry_proc
被唤醒执行回调函数epoll_callback_proc
:- 遍历
epoll
的ready_list
,挨个调用每个socket的poll
逻辑收集发生的事件。 - 将每个socket收集到的事件,通过
epoll_wait
传入的events
数组回传并唤醒相应的Process。
- 遍历
大的逻辑其实没有变化,我们之前提过wait_entry_proc
和epoll_callback_proc
,这里简单说一下:
wait_entry_proc:睡眠实体,将当前Process关联给wait_entry_proc
,并设置回调函数为epoll_callback_proc
。我们之前说睡眠在single_epoll_wait_list
中的是Process,其实是包了一层的wait_entry_proc
。
epoll_callback_proc:回调函数,主要逻辑是遍历epoll
的ready_list
,挨个调用每个socket的poll
逻辑收集发生的事件,然后将每个socket收集到的事件,通过epoll_wait
传入的events
数组回传并唤醒相应的Process。
那么两种触发模式的本质,就在唤醒逻辑里我们说的第4-1步。真实的情况应该是这样的:
对于边沿触发(ET):
遍历epoll
的ready_list
,将socket从ready_list
中移除,然后调用该socket的poll
逻辑收集发生的事件。
对于水平触发(LT):
- 遍历
epoll
的ready_list
,将socket从ready_list
中移除,然后调用该socket的poll
逻辑收集发生的事件。 - 如果该socket的
poll
函数返回了关心的事件(对于可读事件来说,就是POLL_IN
事件),那么该socket被重新加入到epoll的ready_list中!
对于可读事件而言,在ET模式下,如果某个socket有新的数据到达,那么该sk就会被排入epoll的ready_list,从而epoll_wait就一定能收到可读事件的通知(调用sk的poll逻辑一定能收集到可读事件)。于是,我们通常理解的缓冲区状态变化(从无到有)的理解是不准确的,准确的理解应该是是否有新的数据达到缓冲区。
而在LT模式下,某个sk被探测到有数据可读,那么该sk会被重新加入到read_list,那么在该sk的数据被全部取走前,下次调用epoll_wait就一定能够收到该sk的可读事件(调用sk的poll逻辑一定能收集到可读事件),从而epoll_wait就能返回。
PS:下面的性能和复杂度可以考虑跳过,因为在实际使用过程中自然会有感悟。
性能
通过上面的概念介绍,我们知道对于可读事件而言,LT比ET多了两个操作:
- 对ready_list的遍历的时候,对于收集到可读事件的sk会重新放入ready_list
- 下次epoll_wait的时候会再次遍历上次重新放入的sk,如果sk本身没有数据可读了,那么这次遍历就变得多余了。
在服务端有海量活跃socket的时候,LT模式下,epoll_wait返回的时候,会有海量的socket sk重新放入ready_list。如果,用户在第一次epoll_wait返回的时候,将有数据的socket都处理掉了,那么下次epoll_wait的时候,上次epoll_wait重新入ready_list的sk被再次遍历就有点多余,这个时候LT确实会带来一些性能损失。然而,实际上会存在很多多余的遍历么?
先不说第一次epoll_wait返回的时候,用户进程能否都将有数据返回的socket处理掉。在用户处理的过程中,如果该socket有新的数据上来,那么协议栈发现sk已经在ready_list中了,那么就不需要再次放入ready_list,也就是在LT模式下,对该sk的再次遍历不是多余的,是有效的。同时,我们回归epoll高效的场景在于,服务器有海量socket,但是活跃socket较少的情况下才会体现出epoll的高效、高性能。因此,在实际的应用场合,绝大多数情况下,ET模式在性能上并不会比LT模式具有压倒性的优势,
复杂度
我们知道,对于可读事件而言,在阻塞模式下,是无法识别队列空的事件的,并且,事件通知机制,仅仅是通知有数据,并不会通知有多少数据。于是,在阻塞模式下,在epoll_wait返回的时候,我们对某个socket_fd调用recv或read读取并返回了一些数据的时候,我们不能再次直接调用recv或read,因为,如果socket_fd已经无数据可读的时候,进程就会阻塞在该socket_fd的recv或read调用上,这样就影响了IO多路复用的逻辑(我们希望是阻塞在所有被监控socket的epoll_wait调用上,而不是单独某个socket_fd上),造成其他socket饿死,即使有数据来了,也无法处理。
接下来,我们只能再次调用epoll_wait来探测一些socket_fd,看是否还有数据可读。在LT模式下,如果socket_fd还有数据可读,那么epoll_wait就一定能够返回,接着,我们就可以对该socket_fd调用recv或read读取数据。然而,在ET模式下,尽管socket_fd还是数据可读,但是如果没有新的数据上来,那么epoll_wait是不会通知可读事件的。这个时候,epoll_wait阻塞住了,这下子坑爹了,明明有数据你不处理,非要等新的数据来了在处理,那么我们就死扛咯,看谁先忍不住。
等等,在阻塞模式下,不是不能用ET的么?是的,正是因为有这样的缺点,ET强制需要在非阻塞模式下使用。在ET模式下,epoll_wait返回socket_fd有数据可读,我们必须要读完所有数据才能离开。因为,如果不读完,epoll不会在通知你了,虽然有新的数据到来的时候,会再次通知,但是我们并不知道新数据会不会来,以及什么时候会来。由于在阻塞模式下,我们是无法通过recv/read来探测空数据事件,于是,我们必须采用非阻塞模式,一直read直到EAGAIN。因此,ET要求socket_fd非阻塞也就不难理解了。
另外,epoll_wait原本的语意是:监控并探测socket是否有数据可读(对于读事件而言)。LT模式保留了其原本的语意,只要socket还有数据可读,它就能不断反馈,于是,我们想什么时候读取处理都可以,我们永远有再次poll的机会去探测是否有数据可以处理,这样带来了编程上的很大方便,不容易死锁造成某些socket饿死。相反,ET模式修改了epoll_wait原本的语意,变成了:监控并探测socket是否有新的数据可读。
于是,在epoll_wait返回socket_fd可读的时候,我们需要小心处理,要不然会造成死锁和socket饿死现象。典型如listen_fd返回可读的时候,我们需要不断的accept直到EAGAIN。假设同时有三个请求到达,epoll_wait返回listen_fd可读,这个时候,如果仅仅accept一次拿走一个请求去处理,那么就会留下两个请求,如果这个时候一直没有新的请求到达,那么再次调用epoll_wait是不会通知listen_fd可读的,于是epoll_wait只能睡眠到超时才返回,遗留下来的两个请求一直得不到处理,处于饿死状态。
3.5. 小总结
Select/poll
两者都是将一定的fd集合发送给内核,然后内核通过轮询的方式获取可用fd,然后返回所有的合集,其中select有最大fd限制,默认为1024,而poll则没有,因为poll采用了链表的数据结构,但是两者的缺点很明显,当每次只有一个fd可用时,太浪费时间,并且有内存浪费,因为每次都要将fd集合复制到内核
epoll
- epoll支持的FD上限是操作系统的最大文件句柄数,这个数字远远大于1024
- 通过mmap保存fd集合,不需要每次都复制到内核
- 只会对“活跃”的socket进行操作-这是因为在内核实现中,epoll是根据每个fd上面的callback函数实现的。那么只有“活跃”的socket才会去主动调用callback函数,其他idle状态的socket则不会。
- ET模式就是对于每个事件,只返回一次,LT模式就是对于每个事件,只要socket的poll逻辑判断发生了,就能一直返回
3.6. 参考
4. 总结
这篇文章基本总结了网络I/O模型中的问题,除去基本使用的代码没有具体给出,目前主要的问题基本在于I/O多路复用,所以花了大部分来讲述,不过其实异步I/O也是一门大学问,这里只是简单点了一下,毕竟是一篇博客记录,太长也没耐心看,但是我也是花了几天时间才彻底搞懂其中的道理,大家耐心看完会有所收获的。