IO多路复用

小结

  1. 原始做法:我们需要为每个连接起一个进程来处理,进程内不断调用内核的 read 方法轮询,判断 fd 的就绪状态。对于那些未就绪的 fd,每次 read 都是无效且耗费资源的操作

  2. 通过 select 方法,我们可以将一批 fd 交给内核,内核来进行遍历。内核会给就绪的 fd 打上标记,我们再遍历一次 fd列表,处理其中就绪的 fd 就可以了。相比1来说,复用一个进程处理连接而不是多个进程;且省去了每次循环里用户态切到内核态再调用 read 的开销

  3. poll 方法去掉了 select 只能监听 1024 个 fd 的限制

  4. epoll 方法对 select 的一些缺点进行了优化,如:

    1. 用户调用时不再需要复制一份 fd 列表给内核,而是通过 epoll_ctl 方法直接向内核中写入 fd

    2. 内核仅会将就绪的 fd 返回给用户,不再需要用户自行遍历 fd 列表

    3. 内核也不是通过轮询的方式找到就绪的 fd 了 ,而是通过异步唤醒机制,性能更好

  5. epoll 有边缘触发和水平触发两种方式,NginxGo 使用的都是边缘触发,触发次数少效率高;redis 使用水平触发,保障数据一致性

《UNIX 网络编程》中总结了五种IO模型,分别是:

  • 阻塞I/O

  • 非阻塞I/O

  • I/O复用

  • 信号驱动式I/O(SIGIO)

  • 异步I/O(POSIX的aio_系列函数)

阻塞 IO

想一下如果让你来写一个处理网络请求的服务端,你会怎么写?

最基础的做法是,先起一个线程监听所有的网络连接。成功建立连接后,处理该请求。请求处理完成后,建立下一个连接,处理下一个请求。如此串行的、阻塞的处理。

img

把服务端处理请求的细节展开,得到如下图所示的同步阻塞网络 IO 的数据接收流程:

img

这就是传统的阻塞 IO。如果这个连接的客户端一直不发数据,那么服务端线程将会一直阻塞在 recvfrom 函数上不返回,也无法接受其他客户端连接。

非阻塞IO

为了解决上面的问题,其关键在于改造这个 read 函数。当没有数据到达时,立刻返回一个错误值(-1),而不是阻塞地等待。

Image

操作系统提供了这样的功能,只需要在调用 read 前,将文件描述符设置为非阻塞即可。

这样,就需要用户线程循环调用 read,直到返回值不为 -1,再开始处理业务。

这种循环调用 read 的方式通常称为轮询 (polling),持续轮询内核,以这种方式查看某个操作是否就绪。selectpollepoll 都需要通过轮询的方式调用。

642

这里注意一个细节:

非阻塞的 read,指的是在数据到达前,即数据还未到达网卡,或者到达网卡但还没有拷贝到内核缓冲区之前,这个阶段是非阻塞的。

当数据已到达内核缓冲区,此时调用 read 函数仍然是阻塞的,需要等待数据从内核缓冲区拷贝到用户缓冲区,才能返回。

也就是说,非阻塞IO模型将原来的两处阻塞变成了一处阻塞。

IO 多路复用

先解释一下多路复用这个词的概念:上面讨论的都是一个连接的情况。想要并发处理多个连接,最简单的方式就是为每一个连接启动一个线程处理,( apache 就是这样做的),但这样很浪费性能,因为每个连接只有一段时间在工作,其他时间是空闲的。因此有一个更聪明的方案:只起 1 个线程处理连接。处理时使用上面提到的非阻塞IO,这样连接有数据时线程去处理,没数据时线程便切换至下一个连接。这种使用一个线程处理多个连接,也即多个连接共用一个线程的做法,就叫多路复用。

这样的设计首先需要一个队列保存连接:

然后起一个的线程去不断遍历这个数组,使用非阻塞 read 方法判断该连接上是否有数据到达。

基于这种设计,Linux 下提供了 3 种多路复用函数:select, poll, epoll

select

select:Linux 下多路复用最简单的实现。通过它,我们可以不用为每个 fd 调用一次 read 了,而是一次传一批 fd, 让操作系统去遍历,确定哪个文件描述符可以读写, 然后告诉我们去处理。

643

select 系统调用的函数定义如下。

fd (file descriptor) 文件描述符是一个非负整数,可视为文件的ID/身份证号。

fd_set 是一个默认总大小为 __FD_SETSIZE = 1024 的 bitmap ( [32]int64),每位可标记一个 fd

select 内部会遍历前 nfdsfd,对每个 fd 判断是否就绪,如果就绪,则修改相应 fd_set 上对应的位为 1。调用者再检查 fd_set

select 的实现

设程序 A 同时监视 sock1, sock2, sock3 三个 socket

在调用 select 之后,操作系统把进程A分别加入这三个 socket 的等待队列中。

当任何一个 socket 收到数据后,中断程序将唤起进程A。所谓唤起进程,就是遍历所有的 socket,将进程A从所有 socket 的等待队列中移除,然后加入到工作队列里面。

img

select的使用

服务端代码,这样来写。首先一个线程不断接收客户端连接,并把 socket fd 放到一个 list 里。

然后,另一个线程调用 select,将这个 list 交给操作系统去遍历。

不过,当 select 函数返回后,用户依然需要遍历刚刚提交给操作系统的 list。操作系统只会将准备就绪的文件描述符做上标识,并返回 ready 的数量。

644

可以看出几个问题(这几点将在 epoll 中得到优化):

  1. select 调用时会陷入内核,需要将传参中的 fd_set 拷贝到内核空间;select 执行完毕后,还需要将 fd_set 从内核拷贝回用户空间;高并发场景下这样的拷贝消耗的资源是惊人的。(epoll优化为不拷贝)

  2. select 在内核层仍然是是在遍历每个 socket,是个同步过程,只不过无系统调用切换上下文的开销。(epoll优化为异步事件通知)

  3. select 仅仅返回可读文件描述符的个数,具体哪个可读还是要用户自己遍历。(epoll为只返回给用户就绪的文件描述符,无需用户做无效的遍历)

  4. 正是因为遍历操作开销大,出于效率的考量,才会规定 select 的最大监视数量,默认只能监视 1024 个 socket

整个 select 的流程图如下。

645

可以看到,这种方式,既做到了一个线程处理多个客户端连接(文件描述符),又减少了系统调用的开销(多个文件描述符只有一次 select 的系统调用 + n 次就绪状态的文件描述符的 read 系统调用)。

poll

poll 的实现和 select 非常相似,只是描述 fd 集合的方式不同,select 使用了 bitmap 作为 fd_set,设置了最大大小为 1024个。poll 改用了一个链表 pollfd ,因此没有这个限制。和 select 相比只是实现细节上的区分,并没有本质上的区别。

epoll

epoll 是最终的大 boss,它解决了 selectpoll 的一些问题。

上面说了 select 的三个问题, epoll 主要就是针对这三点进行了改进。

  1. 内核中使用红黑树保存一份文件描述符集合,每个文件描述符只在添加时传入一次,无需每次调用 select 时都整体重新传入

    1. 解决了selectfd_set 重复拷贝到内核的问题

  2. 内核不再通过轮询的方式找到就绪的文件描述符,而是通过异步 IO 事件唤醒

  3. 内核仅会将有 IO 事件的文件描述符返回给用户,用户也无需遍历整个文件描述符集合

具体,操作系统提供了这三个函数。

使用方法如下:

646

epoll对象 主要结构体如下:

img

epoll 采用红黑树来存储所有监听的 fd,红黑树本身插入和删除性能比较稳定,时间复杂度 O(logN)

epoll 利用 epoll_ctl 来插入或者删除一个 fd,实现用户态到内核态的数据拷贝,这确保了每一个 fd 在其生命周期只需要被拷贝一次,而不是每次调用 epoll_wait 的时候都拷贝一次。相比 select & poll 需要把全部监听的 fd 集合从用户态拷贝至内核态的做法,效率高出了一大截。

当把 fd 添加进来的时候时候会完成关键的一步:该 fd 会与相应的设备(网卡)驱动程序建立回调关系,也就是在内核中断处理程序为它注册一个回调函数,当 fdsocket 有数据到达后,会触发中断,内核处理中断时就会调用这个回调函数。这个回调函数的内容就是把这个 fd 添加到一个就绪链表 rdllist 中。

epoll_wait 实际上就是去检查 rdllist 链表是否为空,不空说明有就绪的 fd,从 wq 里找到并唤醒之前阻塞的用户进程,把 rdllist 中就绪的 fd 返回给该进程,让进程调用 recv 把该 fdsocket 里的数据拷贝到用户空间使用。

边缘触发 / 水平触发

epoll 支持两种事件触发模式,分别是边缘触发(edge-triggered,ET)和水平触发(level-triggered,LT)

二者的主要区别在于什么时候通知应用程序进行读写操作。边缘触发模式只在状态变化时通知一次,而水平触发模式会在处于就绪状态时不断通知。

边缘触发 Edge-triggered(ET)

使用边缘触发模式时,如果被监控的 fd socket 上有数据到达,根据上面介绍的流程,该 fd 会被放入 rdllist。如果此时我们调用 epoll_wait() 获取该就绪队列,那么该队列会被清空,下次再调用 epoll_wait() 就不会再返回该 fd 了。

也就是说,进程只会从 epoll_wait() 中苏醒一次,即使进程没有调用 read() 函数从内核读取数据,也依然只苏醒一次,因此我们程序要保证一次性将内核缓冲区的数据读取完

举个例子,你的快递被放到了一个快递箱里,如果快递箱只会通过短信通知你一次,即使你一直没有去取,它也不会再发送第二条短信提醒你。

如果使用边缘触发模式,I/O 事件发生时只会通知一次,而且我们不知道到底能读写多少数据

因此,我们需要循环从文件描述符读写数据。那么如果文件描述符是阻塞的,没有数据可读写时,进程会阻塞在读写函数那里,程序就没办法继续往下执行。所以,边缘触发模式一般和非阻塞 I/O 搭配使用,程序会一直执行 I/O 操作,直到系统调用(如 readwrite)返回错误,错误类型为 EAGAINEWOULDBLOCK

一般来说,边缘触发的效率比水平触发的效率要高,因为边缘触发可以减少 epoll_wait 的系统调用次数,系统调用也是有一定的开销的的。

使用时是类似这样的:

Go 里的 netpoll 用的就是边缘触发模式:

水平触发 Level-triggered (LT)

水平触发是默认的模式。

使用水平触发模式时,只要被监控的 fd socket 缓冲区中有数据,epoll_wait() 就会返回这个 fd,直到 socket 缓冲区中没有数据了,它才会在下一次调用 epoll_wait() 时被从 rdlist 中移除

仍然用上面快递的例子,如果快递箱发现你的快递没有被取出,它就会不停地发短信通知你,直到你取出了快递,它才消停

如何选择?

NginxGonetpoll 里是用的都是边缘模式,这种模式触发次数少,效率更高

Redis 采用的是水平模式,因为边缘模式下,如果读取或写入操作没有读写完全,内核将不会再次通知该文件描述符上的可读/可写事件,这样可能导致事件丢失。对于 Redis 这种数据库,数据一致性要求较高的场景来说,水平模式更合适一些。

同步非阻塞

所有I/O多路复用操作都是同步的,涵盖 select / poll / epoll

阻塞/非阻塞是相对于同步I/O来说的,与异步I/O无关。

使用 select / poll / epoll 时,仍然需要起一个死循环,循环内显式的调用 select / poll / epoll_wait 查询是否有就绪的 fd,如果有,再自行执行后续的回调函数。因此只是非阻塞了,但回调仍然是同步执行的,即同步非阻塞调用。就像餐厅排队,取了号去逛街,逛一会儿就得回来问下到号了没有,需要不断的询问。

真正的异步 I/O,应当是把回调函数交给操作系统,系统在 fd 就绪时,自动执行回调,而不是用户进程自己检查、自己执行。

另外,select / poll / epoll 实际上也提供了阻塞的调用方式。将参数里的 timeout 设成 NULL / -1,就变成阻塞的了。不过应该没人这么用吧。

问题

IO多路复用,复用的是什么?目的是什么?

  • 复用的是进程资源。这样可以避免每个IO操作都创建一个新进程,减小进程的创建和销毁开销,提高系统的效率和性能。

  • Nginx 的 worker 复用一个进程来处理多个IO事件,Go 的 netpoller 复用一个协程处理所有的网络连接。

UNIX、MacOS 下没有 epoll,取而代之的是 kqueue,实现和 epoll 类似

参考

闪客sun - 你管这破玩意叫 IO 多路复用?

同步非阻塞的讨论

小林coding - IO 多路复用是什么意思?

mingguangtu - 深入学习IO多路复用 select/poll/epoll 实现原理

罗培羽 - 如果这篇文章说不清epoll的本质,那就过来掐死我吧! (1)

Last updated