IO 多路复用

一般单次 I/O 请求会分为两个阶段,每个阶段对于 I/O 的处理方式是不同的。首先,I/O 会经历一个等待资源的阶段,比方说,等待网络传输数据可用,在这个过程中我们对 I/O 会有两种处理方式:

  • 阻塞。指的是在数据不可用时,I/O 请求一直阻塞,直到数据返回;
  • 非阻塞。指的是数据不可用时,I/O 请求立即返回,直到被通知资源可用为止。

然后是使用资源的阶段,比如说从网络上接收到数据,并且拷贝到应用程序的缓冲区里面。在这个阶段我们也会有两种处理方式:

  • 同步处理。指的是 I/O 请求在读取或者写入数据时会阻塞,直到读取或者写入数据完成;
  • 异步处理。指的是 I/O 请求在读取或者写入数据时立即返回,当操作系统处理完成 I/O 请求,并且将数据拷贝到用户提供的缓冲区后,再通知应用 I/O 请求执行完成。

将这两个阶段的四种处理方式,做一些排列组合,再做一些补充,就得到了我们常见的五种 I/O 模型:

  • 同步阻塞IO:假设应用程序的进程发起IO调用,但是如果内核的数据还没准备好的话,那应用程序进程就一直在阻塞等待,一直等到内核数据准备好了,从内核拷贝到用户空间,才返回成功提示,此次IO操作,称之为阻塞IO
  • 同步非阻塞IO:如果内核数据还没准备好,可以先返回错误信息给用户进程,让它不需要等待,而是通过轮询的方式再来请求。
  • 同步IO多路复用:本文接下来重点介绍的模型。
  • 信号驱动模型:信号驱动IO不再用主动询问的方式去确认数据是否就绪,而是向内核发送一个信号,然后应用用户进程可以去做别的事,不用阻塞。当内核数据准备好后,再通过信号通知应用进程,此时数据复制到用户空间的时候,应用进程还是阻塞的。
  • 异步IO(AIO):以上四种IO模型在数据复制到用户缓存空间的时候,都是阻塞的。而AIO实现了全流程的非阻塞,当数据从内核复制到用户空间完成后,系统会发送型号给用户进行通知。

IO多路复用

⼀个进程虽然任⼀时刻只能处理⼀个请求,但是处理每个请求的事件时,耗时控制在 1 毫秒以内,这样 1 秒内就可以处理上千个请求,把时间拉⻓来看,多个请求复⽤了⼀个进程,这就是多路复⽤,这种思想很类似⼀个 CPU 并发多个进程,所以也叫做时分多路复⽤

selectpollepoll是 Linux 内核提供给用户调用的三个多路复用接口,我们将重点介绍它们。

select/poll

应用进程通过调用 select 函数,可以同时监控多个文件描述符,在 select 函数监控的文件描述符中,只要有任何一个数据状态准备就绪了,select 函数就会返回可读状态,这时应用进程再发起 recvfrom 系统调用请求去读取数据。

select 使⽤固定⻓度的 BitsMap,表示⽂件描述符集合,⽽且所⽀持的⽂件描述符的个数是有限制的,在 Linux 系统中,由内核中的 FD_SETSIZE 限制, 默认最⼤值为 1024 ,只能监听 0~1023 的⽂件描述符。

select 函数返回后是通过遍历 ⽂件描述符集合来找到就绪的描述符。(仅知道有I/O事件发生,却不知是哪几个流,所以遍历所有流)

比如在 Socket 网络编程中,select 将已连接的 Socket 都放到⼀个⽂件描述符集合,然后调⽤ select 函数将⽂件描述符集合拷⻉到内核⾥,让内核来检查是否有⽹络事件产⽣,检查的⽅式很粗暴,就是通过遍历⽂件描述符集合的⽅式,当检查到有事件产⽣后,将此 Socket 标记为可读或可写,接着再把整个⽂件描述符集合拷⻉回⽤户态⾥,然后⽤户态还需要再通过遍历的⽅法找到可读或可写的 Socket,然后再对其处理。所以,对于 select 这种⽅式,需要进⾏ 2 次「遍历」⽂件描述符集合,⼀次是在内核态⾥,⼀个次是在⽤户态⾥ ,⽽且还会发⽣ 2 次「拷⻉」⽂件描述符集合,先从⽤户空间传⼊内核空间,由内核修改后,再传出到⽤户空间中。

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

但是 pollselect 并没有太⼤的本质区别,都是使⽤「线性结构」存储进程关注的 ⽂件描述符 集合,因此都需要遍历⽂件描述符集合来找到可读或可写的 ⽂件描述符,时间复杂度为 O(n),⽽且也需要在⽤户态与内核态之间拷⻉⽂件描述符集合,这种⽅式随着并发数上来,性能的损耗会呈指数级增⻓。

epoll

epoll使用事件驱动来实现,流程如下:

epoll 通过两个⽅⾯,很好解决了 select/poll 的问题:

  • epoll 在内核⾥使⽤红⿊树来跟踪进程所有待检测的⽂件描述符,把需要监控的 socket 通过 epoll_ctl() 函数加⼊内核中的红⿊树⾥,红⿊树是个⾼效的数据结构,增删查⼀般时间复杂度是 O(logn),通过对这棵⿊红树进⾏操作,这样就不需要像 select/poll 每次操作时都传⼊整个 socket 集合,只需要传⼊⼀个待检测的 socket,减少了内核和⽤户空间⼤量的数据拷⻉和内存分配。
  • epoll 使⽤事件驱动的机制,内核⾥维护了⼀个链表来记录就绪事件,当某个 socket 有事件发⽣时,通过回调函数内核会将其加⼊到这个就绪事件列表中,当⽤户调⽤ epoll_wait() 函数时,只会返回有事件发⽣的⽂件描述符的个数,不需要像 select/poll 那样轮询扫描整个 socket 集合,⼤⼤提⾼了检测的效率。

epoll 的⽅式即使监听的 socket 数量越多的时候,效率不会⼤幅度降低,能够同时监听的 socket 的数⽬也⾮常的多了,上限就为系统定义的进程打开的最⼤⽂件描述符个数。

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

  • 边缘触发:当被监控的 Socket 描述符上有可读事件发⽣时,服务器端只会从 epoll_wait 中苏醒⼀次,即使进程没有调⽤ read 函数从内核读取数据,也依然只苏醒⼀次,因此我们程序要保证⼀次性将内核缓冲区的数据读取完;
  • 水平触发:当被监控的 Socket 上有可读事件发⽣时,服务器端不断地从 epoll_wait 中苏醒,直到内核缓冲区数据被 read 函数读完才结束,⽬的是告诉我们有数据需要读取;
⽔平触发的意思是只要满⾜事件的条件,⽐如内核中有数据需要读,就⼀直不断地把这个事件传递给⽤户;⽽边缘触发的意思是只有第⼀次满⾜条件的时候才触发,之后就不会再传递同样的事件了。

如果使⽤⽔平触发模式,当内核通知⽂件描述符可读写时,接下来还可以继续去检测它的状态,看它是否依然可读或可写。所以在收到通知后,没必要⼀次执⾏尽可能多的读写操作。

如果使⽤边缘触发模式,I/O 事件发⽣时只会通知⼀次,⽽且我们不知道到底能读写多少数据,所以在收到通知后应尽可能地读写数据,以免错失读写的机会。因此,我们会循环从⽂件描述符读写数据,那么如果⽂件描述符是阻塞的,没有数据可读写时,进程会阻塞在读写函数那⾥,程序就没办法继续往下执⾏。所以,边缘触发模式⼀般和⾮阻塞 I/O 搭配使⽤,程序会⼀直执⾏ I/O 操作,直到系统调⽤(如 readwrite )返回错误,错误类型为 EAGAINEWOULDBLOCK

⼀般来说,边缘触发的效率⽐⽔平触发的效率要⾼,因为边缘触发可以减少 epoll_wait 的系统调⽤次数,系统调⽤也是有⼀定的开销的的,毕竟也存在上下⽂的切换。

select/poll 只有⽔平触发模式,epoll 默认的触发模式是⽔平触发,但是可以根据应⽤场景设置为边缘触发模式。Nginx 使用边缘触发,Redis 使用水平触发。

总结

select poll epoll
底层数据结构 数组 链表 红黑树和双链表
获取就绪的fd 遍历 遍历 事件回调
事件复杂度 O(n) O(n) O(logn)
最大连接数 1024 无限制 无限制
fd数据拷贝 每次调用select,需要将fd数据从用户空间拷贝到内核空间 每次调用poll,需要将fd数据从用户空间拷贝到内核空间 使用内存映射(mmap),不需要从用户空间频繁拷贝fd数据到内核空间

参考

[1] 看一遍就理解:IO模型详解 –> https://juejin.cn/post/7036518015462015006#heading-7

[2] 图解系统-小林 –> https://xiaolincoding.com/os/8_network_system/selete_poll_epoll.html#_9-2-i-o-%E5%A4%9A%E8%B7%AF%E5%A4%8D%E7%94%A8-select-poll-epoll


IO 多路复用
https://uponclouds.top/2025/07/15/IO 多路复用/
作者
Gentle He
发布于
2025年7月15日
许可协议