网络IO模型

网络IO模型

在《UNIX网络编程》一书中,总结归纳了5种IO模型:

  • 阻塞IO(Blocking IO)
  • 非阻塞IO(Nonblocking IO)
  • IO多路复用(IO Multiplexing)
  • 信号驱动IO(Signal Driven IO)
  • 异步IO(Asynchronous IO)

阻塞IO

应用程序想要去读取数据,他是无法直接去读取磁盘数据的,他需要先到内核里边去等待内核操作硬件拿到数据,这个过程就是1,是需要等待的,等到内核从磁盘上把数据加载出来之后,再把这个数据写给用户的缓存区,这个过程是2,如果是阻塞IO,那么整个过程中,用户从发起读请求开始,一直到读取到数据,都是一个阻塞状态。

具体流程如下图

用户去读取数据时,会去先发起recvform一个命令,去尝试从内核上加载数据,如果内核没有数据,那么用户就会等待,此时内核会去从硬件上读取数据,内核读取数据之后,会把数据拷贝到用户态,并且返回ok,整个过程,都是阻塞等待的,这就是阻塞IO。

阶段一

  • 用户进程尝试读取数据(比如网卡数据)
  • 此时数据尚未到达,内核需要等待数据
  • 此时用户进程也处于阻塞状态

阶段二

  • 数据到达并拷贝到内核缓冲区,代表已就绪
  • 将内核数据拷贝到用户缓冲区
  • 拷贝过程中,用户进程依然阻塞等待
  • 拷贝完成,用户进程解除阻塞,处理数据

可以看到,阻塞IO模型中,用户进程在两个阶段都是阻塞状态。

缺点

当有client与服务器连接之后,服务器端就会调用read()函数等待client发送数据,如果client没有发送数据,那么服务器端就会一直阻塞等待,直到client发送数据。

若服务器端采用单线程处理用户请求,如果有client连接到服务器,在这个client与服务器断开连接之前,服务器端是无法处理其他client的请求的,这样就会导致服务器端的性能下降;如果服务器端是多线程的,即每当有一个client连接时,在新建一个线程去处理这个client的请求,那么当client连接数过多时,服务器端的线程数也会过多,这样会导致服务器端的性能下降,而且线程也是OS非常宝贵的资源,线程之间的切换也是需要消耗CPU资源的。

上述在多线程情况下的缺点有两种解决方案

  • 采用线程池,即预先创建一定数量的线程,当有client连接时,从线程池中取出一个线程去处理client的请求,当client断开连接时,该线程回到线程池中,等待下一个client的连接。
  • NIO(非阻塞IO):

非阻塞IO

顾名思义,非阻塞IO的recvfrom操作会立即返回结果而不是阻塞用户进程。

阶段一:

  • 用户进程尝试读取数据(比如网卡数据)
  • 此时数据尚未到达,内核需要等待数据
  • 返回异常给用户进程
  • 用户进程拿到error后,再次尝试读取
  • 循环往复,直到数据就绪

阶段二:

  • 将内核数据拷贝到用户缓冲区
  • 拷贝过程中,用户进程依然阻塞等待
  • 拷贝完成,用户进程解除阻塞,处理数据

可以看到,非阻塞IO模型中,用户进程在第一个阶段是非阻塞,第二个阶段是阻塞状态。虽然是非阻塞,但性能并没有得到提高。而且忙等机制会导致CPU空转,CPU使用率暴增。

NIO实质上是使用轮询来替代异步阻塞,即讲所有连接存在一个队列中,然后不断轮询这个队列,看看是否有连接有数据可读,如果有,就读取数据,如果没有,就继续轮询。

缺点:

  • 如果连接数量过大,轮询的效率会很低,因为大部分连接都是没有数据可读的,但是仍然需要轮询每个连接。
  • 轮询遍历的过程是出于用户态的,而判断连接是否有数据可读是出于内核态的,这样就需要用户态和内核态之间的切换,这样会导致CPU资源的浪费。

解决方案:IO多路复用

IO多路复用

select

select是Linux最早是由的I/O多路复用技术。

前面有说到,采用NIO(非阻塞)的方式在高并发的情况下,会导致CPU空转,CPU使用率暴增,而且轮询的效率也会很低,因为大部分连接都是没有数据可读的,但是仍然需要轮询每个连接,而且需要频繁的用户态和内核态之间的切换,这样会导致CPU资源的浪费。

基于以上问题,select就应运而生了。它的思想是把需要轮询的fd集合复制到内核空间,然后由内核来负责轮询,这样就避免了用户态和内核态之间的切换,也避免了轮询的效率低下的问题。

简单说,就是我们把需要处理的数据封装成FD,然后在用户态时创建一个fd的集合(这个集合的大小是要监听的那个FD的最大值+1,但是大小整体是有限制的 ),这个集合的长度大小是有限制的,同时在这个集合中,标明出来我们要控制哪些数据。

比如要监听的数据,是1,2,5三个数据,此时会执行select函数,然后将整个fd发给内核态,内核态会去遍历用户态传递过来的数据,如果发现这里边都数据都没有就绪,就休眠,直到有数据准备好时,就会被唤醒,唤醒之后,再次遍历一遍,看看谁准备好了,然后再将处理掉没有准备好的数据,最后再将这个FD集合写回到用户态中去,此时用户态就知道了,奥,有人准备好了,但是对于用户态而言,并不知道谁处理好了,所以用户态也需要去进行遍历,然后找到对应准备好数据的节点,再去发起读请求,我们会发现,这种模式下他虽然比阻塞IO和非阻塞IO好,但是依然有些麻烦的事情, 比如说频繁的传递fd集合,频繁的去遍历FD等问题。

select的缺点

  • select中存放文件描述符(fd)的数组大小FD_SETSIZE为1024,进程的文件描述符上限默认是1024,正是因为这个原因,select设计时才把数组大小设计为1024,所以一个进程最多只能处理1024个客户端
  • fd数组拷贝到了内核态仍然有开销(只是相对于之前要从用户态切换到内核态少了系统调用切换上下文的开销。(内核层可以优化为异步事件通知))。
  • select并没有通知用户态哪一个socket有数据,仍然需要用户态自己去做一次\(O(n)\)的遍历。(可优化为只返回给用户就绪的文件描述符,无序用户做多余遍历)

poll

poll是select的改进版,但是性能提升不明显,部分流程如下:

  • 创建pollfd数组,向其中添加关注的fd信息,数组大小自定义
  • 调用poll函数,将pollfd数组拷贝到内核空间,转链表存储,无上限
  • 内核遍历fd,判断是否就绪
  • 数据就绪或超时后,拷贝pollfd数组到用户空间,返回就绪fd数量n
  • 用户进程判断n是否大于0,大于0则遍历pollfd数组,找到就绪的fd

与select对比:

  • select模式中的fd_set大小固定为1024,而pollfd在内核中采用链表,理论上无上限
  • 监听FD越多,每次遍历消耗时间也越久,性能反而会下降

缺点

  • pollfd数组拷贝到了内核态仍然有开销,poll在每次调用的时候都会存在一个将pollfd结构体数组中的每个结构体元素从用户态向内核态中的一个链表节点拷贝的过程,而内核中的这个链表并不会一直保存,当poll运行一次就会重新执行一次上述的拷贝过程.
  • poll并没有通知用户态哪一个socket有数据,仍然需要用户态自己去做一次\(O(n)\)的遍历。

epoll

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
int epoll_create(int size)//创建一个epoll的句柄,size用来告诉内核这个监听的数目一共有多大
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event)
int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);

1. int epoll_create(int size);
// 创建一个epoll的句柄,size用来告诉内核这个监听的数目一共有多大,这个参数不同于select()中的第一个参数,给出最大监听的fd+1的值,参数size并不是限制了epoll所能监听的描述符最大个数,只是对内核初始分配内部数据结构的一个建议。
// 当创建好epoll句柄后,它就会占用一个fd值,在linux下如果查看/proc/进程id/fd/,是能够看到这个fd的,所以在使用完epoll后,必须调用close()关闭,否则可能导致fd被耗尽。

2. int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event)
/*
函数是对指定描述符fd执行op操作。
- epfd:是epoll_create()的返回值。
- op:表示op操作,用三个宏来表示:添加EPOLL_CTL_ADD,删除EPOLL_CTL_DEL,修改EPOLL_CTL_MOD。分别添加、删除和修改对fd的监听事件。
- fd:是需要监听的fd(文件描述符)
- epoll_event:是告诉内核需要监听什么事,struct epoll_event结构如下:
*/
struct epoll_event {
__uint32_t events; /* Epoll events */
epoll_data_t data; /* User data variable */
};

//events可以是以下几个宏的集合:
// EPOLLIN :表示对应的文件描述符可以读(包括对端SOCKET正常关闭);
// EPOLLOUT:表示对应的文件描述符可以写;
// EPOLLPRI:表示对应的文件描述符有紧急的数据可读(这里应该表示有带外数据到来);
// EPOLLERR:表示对应的文件描述符发生错误;
// EPOLLHUP:表示对应的文件描述符被挂断;
// EPOLLET: 将EPOLL设为边缘触发(Edge Triggered)模式,这是相对于水平触发(Level Triggered)来说的。
// EPOLLONESHOT:只监听一次事件,当监听完这次事件之后,如果还需要继续监听这个socket的话,需要再次把这个socket加入到EPOLL队列里
3. int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);
// 等待epfd上的io事件,最多返回maxevents个事件。
// 参数events用来从内核得到事件的集合,maxevents告之内核这个events有多大,这个maxevents的值不能大于创建epoll_create()时的size,参数timeout是超时时间(毫秒,0会立即返回,-1将不确定,也有说法说是永久阻塞)。该函数返回需要处理的事件数目,如返回0表示已超时。

epoll模式是对select和poll的改进,它提供了三个函数:

  • epoll_create: 创建一个epoll的句柄,size用来告诉内核这个监听的数目一共有多大,这个参数不同于select()中的第一个参数,给出最大监听的fd+1的值,参数size并不是限制了epoll所能监听的描述符最大个数,只是对内核初始分配内部数据结构的一个建议。
  • epoll_ctl: 函数是对指定描述符fd执行op操作。见上面注释。
  • epoll_wait: 等待epfd上的io事件,最多返回maxevents个事件。参数events用来从内核得到事件的集合,maxevents告之内核这个events有多大。

epoll和poll的一个很大的区别在于,poll每次调用时都会存在一个将pollfd结构体数组中的每个结构体元素从用户态向内核态中的一个链表节点拷贝的过程,而内核中的这个链表并不会一直保存,当poll运行一次就会重新执行一次上述的拷贝过程,这说明一个问题:poll并不会在内核中为要监听的文件描述符长久的维护一个数据结构来存放他们,而epoll内核中维护了一个内核事件表,它是将所有的文件描述符全部都存放在内核中,系统去检测有事件发生的时候触发回调,当你要添加新的文件描述符的时候也是调用epoll_ctl函数使用EPOLL_CTL_ADD宏来插入,epoll_wait也不是每次调用时都会重新拷贝一遍所有的文件描述符到内核态。

那么如何在内核中维护这个内核事件表呢?内核事件表时常要有插入、查找和删除的操作,而这些操作会对内核的效率产生不小的影响,因此需要一种插入、查找和删除效率都很高的数据结构,红黑树就是一个不错的选择。

epoll的优点:

  • 没有最大并发连接的限制,能打开的FD的上限远大于1024(1G的内存上能监听约10万个端口)。
  • 效率提升,不是轮询的方式,不会随着FD数目的增加效率下降。只有活跃可用的FD才会调用callback函数,即Epoll最大的优点就在于它只管你“活跃”的连接,而跟连接总数无关,因此在实际的网络环境中,Epoll的效率就会远远高于select和poll。
  • 与poll不同,epoll每个FD只需要执行一次epoll_ctl添加到红黑树,以后每次epol_wait无需传递任何参数,无需重复拷贝FD到内核空间

epoll底层实现细节

epoll 在 Linux 内核中申请了一个简易的文件系统,把原先的一个 select 或 poll 调用分成了 3 部分:

1
2
3
int epoll_create(int size);  
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
int epoll_wait(int epfd, struct epoll_event *events,int maxevents, int timeout);
  1. 调用 epoll_create 建立一个 epoll 对象(在 epoll 文件系统中给这个句柄分配资源);
  2. 调用 epoll_ctl 向 epoll 对象中添加这 100 万个连接的套接字;
  3. 调用 epoll_wait 收集发生事件的连接。

这样只需要在进程启动时建立 1 个 epoll 对象,并在需要的时候向它添加或删除连接就可以了,因此,在实际收集事件时,epoll_wait 的效率就会非常高,因为调用 epoll_wait 时并没有向它传递这 100 万个连接,内核也不需要去遍历全部的连接。

当某一进程调用 epoll_create 方法时,Linux 内核会创建一个 eventpoll 结构体,这个结构体中有两个成员与 epoll 的使用方式密切相关,如下所示:

1
2
3
4
5
6
7
8
9
struct eventpoll {
  ...
  /*红黑树的根节点,这棵树中存储着所有添加到epoll中的事件,
  也就是这个epoll监控的事件*/
  struct rb_root rbr;
  /*双向链表rdllist保存着将要通过epoll_wait返回给用户的、满足条件的事件*/
  struct list_head rdllist;
  ...
};

我们在调用 epoll_create 时,内核除了帮我们在 epoll 文件系统里建了个 file 结点,在内核 cache 里建了个红黑树用于存储以后 epoll_ctl 传来的 socket 外,还会再建立一个 rdllist 双向链表,用于存储准备就绪的事件,当 epoll_wait 调用时,仅仅观察这个 rdllist 双向链表里有没有数据即可。有数据就返回,没有数据就 sleep,等到 timeout 时间到后即使链表没数据也返回。所以,epoll_wait 非常高效。

所有添加到 epoll 中的事件都会与设备(如网卡)驱动程序建立回调关系,也就是说相应事件的发生时会调用这里的回调方法。这个回调方法在内核中叫做 ep_poll_callback,它会把这样的事件放到上面的 rdllist 双向链表中。

在 epoll 中对于每一个事件都会建立一个 epitem 结构体,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
struct epitem {
  ...
  //红黑树节点
  struct rb_node rbn;
  //双向链表节点
  struct list_head rdllink;
  //事件句柄等信息
  struct epoll_filefd ffd;
  //指向其所属的eventepoll对象
  struct eventpoll *ep;
  //期待的事件类型
  struct epoll_event event;
  ...
}; // 这里包含每一个事件对应着的信息。

当调用 epoll_wait 检查是否有发生事件的连接时,只是检查 eventpoll 对象中的 rdllist 双向链表是否有 epitem 元素而已,如果 rdllist 链表不为空,则这里的事件复制到用户态内存(使用共享内存提高效率)中,同时将事件数量返回给用户。因此 epoll_waitx 效率非常高。epoll_ctl 在向 epoll 对象中添加、修改、删除事件时,从 rbr 红黑树中查找事件也非常快,也就是说 epoll 是非常高效的,它可以轻易地处理百万级别的并发连接。

总结:执行 epoll_create()时,创建了红黑树和就绪链表;执行 epoll_ctl()时,如果增加 socket 句柄,则检查在红黑树中是否存在,存在立即返回,不存在则添加到树干上,然后向内核注册回调函数,用于当中断事件来临时向准备就绪链表(是一个双向链表)中插入数据;执行 epoll_wait()时,如果 rdllist 链表不为空,则这里的事件复制到用户态内存(使用共享内存提高效率)中,同时将事件数量返回给用户。

小总结

select模式存在的三个问题

  • 能监听的FD最大不超过1024
  • 每次select都需要把所有要监听的FD都拷贝到内核空间
  • 每次都要遍历所有FD来判断就绪状态

poll模式的问题

  • poll利用链表解决了select中监听FD上限的问题,但依然要遍历所有FD,如果监听较多,性能会下降

epoll模式中如何解决这些问题的?

  • 基于epoll实例中的红黑树保存要监听的FD,理论上无上限,而且增删改查效率都非常高
  • 每个FD只需要执行一次epoll_ctl添加到红黑树,以后每次epol_wait无需传递任何参数,无需重复拷贝FD到内核空间
  • 利用ep_poll_callback机制来监听FD状态,无需遍历所有FD,因此性能不会随监听的FD数量增多而下降

epoll中的ET和LT

当FD有数据可读时,我们调用epoll_wait(或者select、poll)可以得到通知。但是事件通知的模式有两种:

  • LevelTriggered:简称LT,也叫做水平触发。只要某个FD中有数据可读,每次调用epoll_wait都会得到通知。
  • EdgeTriggered:简称ET,也叫做边沿触发。只有在某个FD有状态变化时,调用epoll_wait才会被通知。

举个栗子:

  • 假设一个客户端socket对应的FD已经注册到了epoll实例中
  • 客户端socket发送了2kb的数据
  • 服务端调用epoll_wait,得到通知说FD就绪
  • 服务端从FD读取了1kb数据回到步骤3(再次调用epoll_wait,形成循环)

两种不同通知方式的结果:

  • 如果我们采用LT模式,因为FD中仍有1kb数据,则第 3 步依然会返回结果,并且得到通知
  • 如果我们采用ET模式,因为第 3 步已经消费了FD可读事件,第⑤步FD状态没有变化,因此epoll_wait不会返回,数据无法读取,客户端响应超时。

基于epoll的服务器端流程

  1. 服务器启动以后,服务端会去调用epoll_create,创建一个epoll实例,epoll实例中包含两个数据
    • 红黑树(初始为空):rb_root 用来去记录需要被监听的FD
    • 链表(初始为空):list_head,用来存放已经就绪的FD
  2. 创建好了之后,会去调用epoll_ctl函数,此函数会会将需要监听的数据添加到rb_root中去,并且对当前这些存在于红黑树的节点设置回调函数,当这些被监听的数据一旦准备完成,就会被调用,而调用的结果就是将红黑树的fd添加到list_head中去(但是此时并没有完成)
  3. 当第二步完成后,就会调用epoll_wait函数,这个函数会去校验是否有数据准备完毕(因为数据一旦准备就绪,就会被回调函数添加到list_head中),在等待了一段时间后(可以进行配置),如果等够了超时时间,则返回没有数据,如果有,则进一步判断当前是什么事件,如果是建立连接时间,则调用accept() 接受客户端socket,拿到建立连接的socket,然后建立起来连接,如果是其他事件,则把数据进行写出

信号驱动IO

信号驱动IO是与内核建立SIGIO的信号关联并设置回调,当内核有FD就绪时,会发出SIGIO信号通知用户,期间用户应用可以执行其它业务,无需阻塞等待。

阶段一:

  • 用户进程调用sigaction,注册信号处理函数
  • 内核返回成功,开始监听FD
  • 用户进程不阻塞等待,可以执行其它业务
  • 当内核数据就绪后,回调用户进程的SIGIO处理函数

阶段二:

  • 收到SIGIO回调信号
  • 调用recvfrom,读取
  • 内核将数据拷贝到用户空间
  • 用户进程处理数据

缺点:当有大量IO操作时,信号较多,SIGIO处理函数不能及时处理可能导致信号队列溢出,而且内核空间与用户空间的频繁信号交互性能也较低。

异步IO

上面的信号驱动方式用户调用recvfrom读取数据之后,需要等待内核将数据从内核空间拷贝到用户空间,而这个过程中用户是阻塞等待的,而异步IO则是用户调用recvfrom读取数据之后,内核会将数据从内核空间拷贝到用户空间,然后内核会给用户发送一个信号,告诉用户数据已经拷贝完成,此时用户就可以去处理数据了,这个过程用户不会阻塞。

五个I/O模型对比


网络IO模型
https://gstarmin.github.io/2023/09/13/网络IO模型/
作者
Starmin
发布于
2023年9月13日
许可协议