一文读懂io多路复用技术

IO多路复用:I/O是指网络I/O,多路指多个TCP连接(即socket或者channel),复用指复用一个或几个线程。意思说一个或一组线程处理多个TCP连接。最大优势是减少系统开销小,不必创建过多的进程/线程,也不必维护这些进程/线程。  IO多路复用使用两个系统调用(select/poll/epoll和recvfrom),blocking IO只调用了recvfrom;select/poll/epoll 核心是可以同时处理多个connection,而不是更快,所以连接数不高的话,性能不一定比多线程+阻塞IO好,多路复用模型中,每一个socket,设置为non-blocking,阻塞是被select这个函数block,而不是被socket阻塞的。

了解IO多路复用前先要了解IO模型,常见IO模型分为以下四种:

  1. 同步阻塞IO(Blocking IO):即传统的IO模型。
  2. 同步非阻塞IO(Non-blocking IO):默认创建的socket都是阻塞的,非阻塞IO要求socket被设置为NONBLOCK。注意这里所说的NIO并非Java的NIO(New IO)库。
  3. IO多路复用(IO Multiplexing):即经典的Reactor设计模式,有时也称为异步阻塞IO,Java中的Selector和Linux中的epoll都是这种模型。
  4. 异步IO(Asynchronous IO):即经典的Proactor设计模式,也称为异步非阻塞IO。

同步和异步的概念描述的是用户线程与内核的交互方式:同步是指用户线程发起IO请求后需要等待或者轮询内核IO操作完成后才能继续执行;而异步是指用户线程发起IO请求后仍继续执行,当内核IO操作完成后会通知用户线程,或者调用用户线程注册的回调函数。

阻塞和非阻塞的概念描述的是用户线程调用内核IO操作的方式:阻塞是指IO操作需要彻底完成后才返回到用户空间;而非阻塞是指IO操作被调用后立即返回给用户一个状态值,无需等到IO操作彻底完成。

  1. 同步阻塞模型

用户线程通过系统调用read发起IO读操作,由用户空间转到内核空间。内核等到数据包到达后,然后将接收的数据拷贝到用户空间,完成read操作。

2.异步非阻塞

用户线程发起IO请求时立即返回。但并未读取到任何数据,用户线程需要不断地发起IO请求,直到数据到达后,才真正读取到数据,继续执行。

3.IO多路复用

先将需要进行IO操作的socket添加到select中,然后阻塞等待select系统调用返回。当数据到达时,socket被激活,select函数返回。用户线程正式发起read请求,读取数据并继续执行。

4.异步IO

“真正”的异步IO需要操作系统更强的支持。在IO多路复用模型中,事件循环将文件句柄的状态事件通知给用户线程,由用户线程自行读取数据、处理数据。而在异步IO模型中,当用户线程收到通知时,数据已经被内核读取完毕,并放在了用户线程指定的缓冲区内,内核在IO完成后通知用户线程直接使用即可。

IO多路复用的三种实现方式:

select

根据fd_size的定义,它的大小为32个整数大小(32位机器为32*32,所有共有1024bits可以记录fd),每个fd一个bit,所以最大只能同时处理1024个fd。在Linux上一般为1024,可以通过修改宏定义甚至重新编译内核的方式提升这一限制,但是这样也会造成效率的降低。

select 采用轮询方式遍历fd_size

每次调用select,都需要把fd集合从用户态拷贝到内核态,这个开销在fd很多时会很大

poll

描述fd集合的方式不同,poll使用 pollfd 结构而不是select结构fd_set结构,所以poll是链式的,没有最大连接数的限制

poll有一个特点是水平触发,也就是通知程序fd就绪后,这次没有被处理,那么下次poll的时候会再次通知同个fd已经就绪

epoll

epoll没有明确的fd数量限制,我们知道每个epoll监听一个fd,所以最大数量与能打开的fd数量有关,1g的内存的机器上,能打开10万个左右

epoll不需要每次都从用户空间将fd集合复制到内核空间,epoll在用epoll_ctl函数进行事件注册的时候,已经将fd复制到内核中,所以不需要每次都重新复制一次

select 和 poll 都是主动轮询机制,需要遍历每个 FD来确认获取的Active Event; epoll是被动触发方式,给fd注册相应事件的时候,我们为每一个fd指定了一个回调函数,当数据准备好之后,就会把就绪的fd加入一个就绪的队列中,epoll_wait的工作方式实际上就是在这个就绪队列中查看有没有就绪的fd,如果有,就唤醒就绪队列上的等待者,然后调用回调函数。

epoll对文件描述符的操作有两种模式:LT(level trigger)和ET(edge trigger)。LT模式是默认模式

LT模式:当epoll_wait检测到描述符事件发生并将此事件通知应用程序,应用程序可以不立即处理该事件。下次调用epoll_wait时,会再次响应应用程序并通知此事件。

ET模式:当epoll_wait检测到描述符事件发生并将此事件通知应用程序,应用程序必须立即处理该事件。如果不处理,下次调用epoll_wait时,不会再次响应应用程序并通知此事件。

通过比较 select、 poll和 epoll处理 I/O 的过程来剖析其中的原因:

用户态将文件描述符传入内核的方式

select:创建3个文件描述符集并拷贝到内核中,分别监听读、写、异常动作。这里受到单个进程可以打开的 fd数量限制,默认是1024。

poll:将传入的 struct pollfd结构体数组拷贝到内核中进行监听。

epoll:执行 epoll_create会在内核的高速 cache区中建立一颗红黑树以及就绪链表(该链表存储已经就绪的文件描述符)。接着用户执行的 epoll_ctl函数添加文件描述符会在红黑树上增加相应的结点。

内核态检测文件描述符是否可读可写的方式

select:采用轮询方式,遍历所有 fd,最后返回一个描述符读写操作是否就绪的 mask掩码,根据这个掩码给 fd_set赋值。

poll:同样采用轮询方式,查询每个 fd的状态,如果就绪则在等待队列中加入一项并继续遍历。

epoll:采用回调机制。在执行 epoll_ctl的 add操作时,不仅将文件描述符放到红黑树上,而且也注册了回调函数,内核在检测到某文件描述符可读/可写时会调用回调函数,该回调函数将文件描述符放在就绪链表中。

如何找到就绪的文件描述符并传递给用户态

select:将之前传入的 fd_set拷贝传出到用户态并返回就绪的文件描述符总数。用户态并不知道是哪些文件描述符处于就绪态,需要遍历来判断。

poll:将之前传入的 fd数组拷贝传出用户态并返回就绪的文件描述符总数。用户态并不知道是哪些文件描述符处于就绪态,需要遍历来判断。

epoll: epoll_wait只用观察就绪链表中有无数据即可,最后将链表的数据返回给数组并返回就绪的数量。内核将就绪的文件描述符放在传入的数组中,所以只用遍历依次处理即可。这里返回的文件描述符是通过 mmap让内核和用户空间共享同一块内存实现传递的,减少了不必要的拷贝。

继续重新监听时如何重复以上步骤

select:将新的监听文件描述符集合拷贝传入内核中,继续以上步骤。

poll:将新的 struct pollfd结构体数组拷贝传入内核中,继续以上步骤。

epoll:无需重新构建红黑树,直接沿用已存在的即可。

通过以上步骤我们可以发现以下几点

select和 poll的动作基本一致,只是 poll采用链表来进行文件描述符的存储,而 select采用 fd标注位来存放,所以 select会受到最大连接数的限制,而 poll不会。

select、 poll、 epoll虽然都会返回就绪的文件描述符数量。但是 select和 poll并不会明确指出是哪些文件描述符就绪,而 epoll会。造成的区别就是,系统调用返回后,调用 select和 poll的程序需要遍历监听的整个文件描述符找到是谁处于就绪,而 epoll则直接处理就行了。

select、 poll都需要将有关文件描述符的数据结构拷贝进内核,最后再拷贝出来。而 epoll创建的有关文件描述符的数据结构本身就存于内核态中,系统调用返回时也采用 mmap共享存储区,需要拷贝的次数大大减少。

select、 poll采用轮询的方式来检查文件描述符是否处于就绪态,而 epoll采用回调机制。造成的结果就是,随着fd的增加, select和 poll的效率会线性降低,而 epoll不会受到太大影响,除非活跃的 socket很多。

最后总结一下, epoll比 select和 poll高效的原因主要有两点:

减少了用户态和内核态之间的文件描述符拷贝

减少了对就绪文件描述符的遍历。

IO模型介绍(select、poll、epoll)

IO中的I就是input,O就是output,IO模型即输入输出模型,而比较常听说的便是磁盘IO,网络IO。

我们如果需要对磁盘进行读取或者写入数据的时候必须得有主体去操作,这个主体就是应用程序。 应用程序是不能直接进行一些读写操作(IO)的,因为用户可能会利用此程序直接或者间接的对计算机造成破坏,只能交给底层软件—操作系统.也就是说应用程序想要对磁盘进行读取或者写入数据,只能通过操作系统对上层开放的API来进行。在任何一个应用程序里面,都会有进程地址空间,该空间分为两部分,一部分称为用户空间(允许应用程序进行访问的空间),另一部分称为内核空间(只能给操作系统进行访问的空间,它受到保护)。

IO调用:应用程序进程向操作系统内核发起调用【1】。

IO执行:操作系统内核完成IO操作【2】。

•数据准备阶段:内核等待I/O设备准备好数据(从网卡copy到内核缓冲区)【3】。

•数据copy阶段:将数据从内核缓冲区copy到用户进程缓冲区【4】。

应用程序一次I/O流程如下:



一个完整的IO过程包括以下几个步骤:

1.应用程序进程向操作系统发起IO调用请求。

2.操作系统准备数据,外部设备的数据通过网卡加载到内核缓冲区。

3.操作系统拷贝数据,即将内核缓冲区的数据copy到用户进程缓冲区。

服务端为了处理客户端的连接和数据处理:

伪代码具体如下:

上面的伪代码中我们可以看出,服务端处理客户端的请求阻塞在两个地方,一个是 accept、一个是 read ,我们这里主要研究 read 的过程,可以分为两个阶段:等待读就绪(等待数据到达网卡 & 将网卡的数据拷贝到内核缓冲区)、读数据。

阻塞IO流程如下:



非阻塞式 IO 我们应该让操作系统提供一个非阻塞的 read() 函数,当第一阶段读未就绪时返回 -1 ,当读已就绪时才进行数据的读取。

非阻塞IO往往需要程序员循环的方式反复尝试读写文件描述符, 这个过程称为轮询(for(connfd : arr)). 这对CPU来说是较大的浪费, 一 般只有特定场景下才使用.

伪代码具体如下:

所谓非阻塞 IO 只是将第一阶段的等待读就绪改为非阻塞,但是第二阶段的数据读取还是阻塞的,非阻塞 read 最重要的是提供了我们在一个线程内管理多个文件描述符的能力

非阻塞具体流程如下:



上面的实现看着很不错,但是却存在一个很大的问题,我们需要不断的调用 read() 进行系统调用,这里的系统调用我们可以理解为分布式系统的 RPC 调用,性能损耗十分严重,因为这依然是用户层的一些小把戏。

多路复用就是系统提供了一种函数可以同时监控多个文件描述符的操作,这个函数就是我们常说到的select、poll、epoll函数,可以通过它们同时监控多个文件描述符,只要有任何一个数据状态准备就绪了,就返回可读状态,这时询问线程再去通知处理数据的线程,对应线程此时再发起read()请求去读取数据。实际上最核心之处在于IO多路转接能够同时等待多个文件描述符的就绪状态,来达到不必为每个文件描述符创建一个对应的监控线程,从而减少线程资源创建的目的。

select 是操作系统提供的系统函数,通过它我们可以将文件描述符发送给系统,让系统内核帮我们遍历检测是否可读,并告诉我们进行读取数据。

伪代码如下:

流程简图:



1.减少大量系统调用。

2.系统内核帮我们遍历检测是否可读。

• 每次调用需要在用户态和内核态之间拷贝文件描述符数组,但高并发场景下这个拷贝的消耗是很大的。

• 内核检测文件描述符可读还是通过遍历实现,当文件描述符数组很长时,遍历操作耗时也很长。

• 内核检测完文件描述符数组后,当存在可读的文件描述符数组时,用户态需要再遍历检测一遍。

• poll 和 select 原理基本一致,最大的区别是去掉了最大 1024 个文件描述符的限制。

• select 使用固定长度的 BitsMap,表示文件描述符集合,而且所支持的文件描述符的个数是有限制的,在 Linux 系统中,由内核中的 FD_SETSIZE 限制, 默认最大值为 1024,只能监听 0~1023 的文件描述符。

• poll 不再用 BitsMap 来存储所关注的文件描述符,取而代之用动态数组,以链表形式来组织,突破了 select 的文件描述符个数限制,当然还会受到系统文件描述符限制。

epoll 主要优化了上面三个问题实现:

epoll 基于高效的红黑树结构,提供了三个核心操作:epoll_create、epoll_ctl、epoll_wait。

用于创建epoll文件描述符,该文件描述符用于后续的epoll操作,参数size目前还没有实际用处,我们只要填一个大于0的数就行。



epoll_ctl函数用于增加,删除,修改epoll事件,epoll事件会存储于内核epoll结构体红黑树中.



epoll_wait用于监听套接字事件,可以通过设置超时时间timeout来控制监听的行为为阻塞模式还是超时模式。



整体运转如下:



伪代码如下:

1.socket读触发:socket接收缓冲区有数据,会一直触发epoll_wait EPOLLIN事件,直到数据被用户读取完。

2.socket写触发:socket可写,会一直触发epoll_wait EPOLLOUT事件。

1.socket读触发:当被监控的 Socket 描述符上有可读事件发生时,服务器端只会从 epoll_wait 中苏醒一次,即使进程没有调用 read 函数从内核读取数据,也依然只苏醒一次,因此我们程序要保证一次性将内核缓冲区的数据读取完。

2.socket写触发:socket可写,会触发一次epoll_wait EPOLLOUT事件。

1.红黑树红黑树提高epoll事件增删查改效率。

2.回调通知机制:当epoll监听套接字有数据读或者写时,会通过注册到socket的回调函数通知epoll,epoll检测到事件后,将事件存储在就绪队列(rdllist)。

3.就绪队列:epoll_wait返回成功后,会将所有就绪事件存储在事件数组,用户不需要进行无效的轮询,从而提高了效率。

多路转接解决了一个线程可以监控多个fd的问题,但是select采用无脑的轮询就显得有点暴力,因为大部分情况下的轮询都是无效的,所以有人就想,别让我总去问数据是否准备就绪,而是等你准备就绪后主动通知我,这边是信号驱动IO。

信号驱动IO是在调用sigaction时候建立一个SIGIO的信号联系,当内核准备好数据之后再通过SIGIO信号通知线程,此fd准备就绪,当线程收到可读信号后,此时再向内核发起recvfrom读取数据的请求,因为信号驱动IO的模型下,应用线程在发出信号监控后即可返回,不会阻塞,所以一个应用线程也可以同时监控多个fd。

应用只需要向内核发送一个读取请求,告诉内核它要读取数据后即刻返回;内核收到请求后会建立一个信号联系,当数据准备就绪,内核会主动把数据从内核复制到用户空间,等所有操作都完成之后,内核会发起一个通知告诉应用,我们称这种模式为异步IO模型。

异步IO的优化思路是解决应用程序需要先后发送询问请求、接收数据请求两个阶段的模式,在异步IO的模式下,只需要向内核发送一次请求就可以完成状态询问和数拷贝的所有操作。

同步和异步关注的是消息通信机制.

同步:就是在发出一个调用时,自己需要参与等待结果的过程,则为同步,前面四个IO都自己参与了,所以也称为同步IO.

异步:则指出发出调用以后,到数据准备完成,自己都未参与,则为异步IO。

本文作者及来源:Renderbus瑞云渲染农场https://www.renderbus.com

点赞 0
收藏 0

文章为作者独立观点不代本网立场,未经允许不得转载。