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 并发多个进程,所以也叫做时分多路复⽤。
select
,poll
,epoll
是 Linux 内核提供给用户调用的三个多路复用接口,我们将重点介绍它们。
select/poll
应用进程通过调用 select
函数,可以同时监控多个文件描述符
,在 select
函数监控的文件描述符
中,只要有任何一个数据状态准备就绪了,select
函数就会返回可读状态,这时应用进程再发起 recvfrom
系统调用请求去读取数据。
select
函数返回后是通过遍历 ⽂件描述符
集合来找到就绪的描述符。(仅知道有I/O事件发生,却不知是哪几个流,所以遍历所有流)
比如在 Socket 网络编程中,select
将已连接的 Socket
都放到⼀个⽂件描述符
集合,然后调⽤ select
函数将⽂件描述符集合拷⻉到内核⾥,让内核来检查是否有⽹络事件产⽣,检查的⽅式很粗暴,就是通过遍历⽂件描述符集合的⽅式,当检查到有事件产⽣后,将此 Socket
标记为可读或可写,接着再把整个⽂件描述符集合拷⻉回⽤户态⾥,然后⽤户态还需要再通过遍历的⽅法找到可读或可写的 Socket
,然后再对其处理。所以,对于 select
这种⽅式,需要进⾏ 2 次「遍历」⽂件描述符集合,⼀次是在内核态⾥,⼀个次是在⽤户态⾥ ,⽽且还会发⽣ 2 次「拷⻉」⽂件描述符集合,先从⽤户空间传⼊内核空间,由内核修改后,再传出到⽤户空间中。
poll
不再⽤ BitsMap 来存储所关注的⽂件描述符
,取⽽代之⽤动态数组,以链表形式来组织,突破了 select
的⽂件描述符个数限制,当然还会受到系统⽂件描述符限制。
但是 poll
和 select
并没有太⼤的本质区别,都是使⽤「线性结构」存储进程关注的 ⽂件描述符
集合,因此都需要遍历⽂件描述符集合来找到可读或可写的 ⽂件描述符
,时间复杂度为 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 操作,直到系统调⽤(如 read
和 write
)返回错误,错误类型为 EAGAIN
或 EWOULDBLOCK
。
⼀般来说,边缘触发的效率⽐⽔平触发的效率要⾼,因为边缘触发可以减少 epoll_wait
的系统调⽤次数,系统调⽤也是有⼀定的开销的的,毕竟也存在上下⽂的切换。
总结
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