网络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函数。总体上会涉及两大逻辑:

睡眠等待逻辑

  • selectpollepoll_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
2
3
4
5
6
7
8
9
10
private int sk_event;
void poll()
{
//其他逻辑
if (recieve queque is not empty)
{
sk_event |= POLL_IN;
}
//其他逻辑
}

解释: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
2
3
4
5
for (socket in readfds) {
sk_event.evt = socket.poll();
sk_event.sk = socket;
return_event_for_process;
}

总结一下我们发现select有两个问题:

  1. 被监控的readfds需要从用户空间拷贝到内核空间。为了减少数据拷贝带来的性能损坏,内核对被监控的fds集合大小做了限制,并且这个是通过宏控制的(可以通过sizeof(fd_set)得到总字节大小,但是fd是通过每个bit表示的),大小不可改变,只能通过重新编译内核等复杂方式改变。
  2. 被监控的readfds集合中,只要有一个有数据可读,整个socket集合就会被遍历一次并且调用socket的poll函数收集可读事件。

说到这,我们有几个问题要解决:

  1. 被监控的fds集合限制为1024(通常在32位机器是这样的),1024太小了,我们希望能够有个比较大的可监控fds集合。
  2. fds集合需要从用户空间拷贝到内核空间的问题,我们希望不需要拷贝。
  3. 当被监控的fds中某些有数据可读的时候,我们希望通知更加精细一点,就是我们希望能够从通知中得到有可读事件的fds列表,而不是需要遍历整个fds来收集。

C语言select使用参考

3.3. poll

首先这里的poll不是上面select中用来判断socket状态的poll,对于socketpoll方法是sock_pollsock_poll根据情况会调用到tcp_poll,udp_poll或者datagram_poll

poll非常鸡肋,只解决了第一个问题——1024的问题。poll改变了fds集合的描述方式,使用了pollfd结构而不是selectfd_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解决了。

这里抄一段话,计算机世界里,有两大解决问题的方式:

  1. 计算机科学领域的任何问题, 都可以通过添加一个中间层来解决。
  2. 变集中处理为分散处理。

首先可以看这篇文章关于epoll操作过程部分,了解基本使用过程。

fds集合拷贝问题的解决

对于IO多路复用,有两件事是必须要做的(对于监控可读事件而言):

  1. 准备好需要监控的fds集合。
  2. 探测并返回fds集合中哪些fd可读了。

细看select或poll的函数原型,我们会发现,每次调用select或poll都在重复地准备(集中处理)整个需要监控的fds集合。然而对于频繁调用的select或poll而言,fds集合的变化频率要低得多,我们没必要每次都重新准备(集中处理)整个fds集合。

于是,epoll引入了epoll_ctl系统调用,将高频调用的epoll_wait和低频的epoll_ctl隔离开。epoll_ctl通过(EPOLL_CTL_ADDEPOLL_CTL_MODEPOLL_CTL_DEL)三个操作来分散对需要监控的fds集合的修改,做到了有变化才变更,将selectpoll高频、大块内存拷贝变成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,并且,与selectpoll不同的是,epoll的Process不需要同时插入到多路复用的socket集合的所有睡眠队列中,相反Process只是插入到中间层的epoll的单独睡眠队列中(即single_epoll_wait_list),Process睡眠在epoll的单独队列上,等待事件的发生。同时,引入一个中间代理的wait_entry_sk,它与某个socket密切相关,wait_entry_sk睡眠在socket的睡眠队列上,其callback函数逻辑是将当前socket排入到epollready_list中,并唤醒epollsingle_epoll_wait_list。而single_epoll_wait_list上睡眠的Process的回调函数就明朗了:遍历ready_list上的所有socket,挨个调用socket的poll函数收集事件,然后唤醒Process从epoll_wait返回。
语言很苍白,我们看下图:

四张图代表四种状态:

  1. 调用epoll之前,我们希望我们的MyProcess可以管理四个socket。
  2. 四个socket都没有事件,这时候MyProcess进入single_epoll_wait_list并且sleep
  3. 有一个socket(大红色)收到了数据,触发其wait_entry_sk,把这个socket加入到ready_list里。
  4. MyProcess被唤醒(从single_epoll_wait_list出来了表示被唤醒),来处理ready_list中的所有socket:遍历epollready_list,挨个调用每个socket的poll逻辑收集发生的事件,对于监控可读事件而已,ready_list上的每个socket都是有数据可读的,这里的遍历必要的。
  5. 此外,这里的MyProcess并不是直接在single_epoll_wait_list中的,而是通过一个睡眠实体wait_entry_proc,所以上面第四步,并不是直接在用户态调用socket的poll逻辑,还是在内核态中调用,并且是由epoll来操作的。这点在后面会讲述。
  6. 如果还是不能理解请参考这篇文章的代码部分,或者自行搜索示例代码进行理解

至此,我们就可以理解说为什么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

2. 关于边缘触发和水平触发

我们说两种模式前,要先说一下epoll的唤醒逻辑:

  1. 协议数据包到达网卡并被排入socket的接收队列。
  2. 睡眠在socket的睡眠队列wait_entry被唤醒,wait_entry_sk的回调函数epoll_callback_sk被执行。
  3. epoll_callback_sk将当前socket插入epollready_list中。
  4. 唤醒睡眠在epoll的单独睡眠队列single_epoll_wait_listwait_entry_procwait_entry_proc被唤醒执行回调函数epoll_callback_proc
    1. 遍历epollready_list,挨个调用每个socket的poll逻辑收集发生的事件。
    2. 将每个socket收集到的事件,通过epoll_wait传入的events数组回传并唤醒相应的Process。

大的逻辑其实没有变化,我们之前提过wait_entry_procepoll_callback_proc,这里简单说一下:

wait_entry_proc:睡眠实体,将当前Process关联给wait_entry_proc,并设置回调函数为epoll_callback_proc。我们之前说睡眠在single_epoll_wait_list中的是Process,其实是包了一层的wait_entry_proc

epoll_callback_proc:回调函数,主要逻辑是遍历epollready_list,挨个调用每个socket的poll逻辑收集发生的事件,然后将每个socket收集到的事件,通过epoll_wait传入的events数组回传并唤醒相应的Process。

那么两种触发模式的本质,就在唤醒逻辑里我们说的第4-1步。真实的情况应该是这样的:

对于边沿触发(ET)
遍历epollready_list,将socket从ready_list中移除,然后调用该socket的poll逻辑收集发生的事件。

对于水平触发(LT)

  1. 遍历epollready_list,将socket从ready_list中移除,然后调用该socket的poll逻辑收集发生的事件。
  2. 如果该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. 参考

三篇一样的文章1

三篇一样的文章2

三篇一样的文章3

从源码分析

4. 总结

这篇文章基本总结了网络I/O模型中的问题,除去基本使用的代码没有具体给出,目前主要的问题基本在于I/O多路复用,所以花了大部分来讲述,不过其实异步I/O也是一门大学问,这里只是简单点了一下,毕竟是一篇博客记录,太长也没耐心看,但是我也是花了几天时间才彻底搞懂其中的道理,大家耐心看完会有所收获的。