之前面试准备过这里的八股,但觉得理解不够深,故打算再探深些
五种 IO 模型
- 阻塞 IO
- 非阻塞 IO
- IO 多路复用
- 信号驱动 IO
- 异步 IO
我们先来看看 DMA 存在的必要性
当数据到达设备端,比如说网卡。网卡接收到数据后,传统非 DMA 的形式,此时会直接触发中断,此时 CPU 介入,负责解析数据,并且将数据 Copy 到 CPU 寄存器中,然后再将 CPU 寄存器中的数据 Copy 到内核内存中,然后解析再送到 Socket 缓冲区中,最后唤醒被阻塞的线程。分析上述流程,CPU 作为整个计算机的大脑,让他来处理这种傻白甜的反复读写操作,过于浪费,这就是 DMA 存在的必要性。当网卡收到数据后,通知 DMA,DMA 负责直接将数据 Copy 到内核内存中,然后网卡触发中断,CPU 将数据进行网络协议栈的解析,然后 Copy 到对应 Socket 的缓冲区中,然后唤醒线程。等到线程被调度的时候,会进入内核态,发现数据已经准备好,然后 Copy 到内存中直接使用,这就是现代计算机的一次阻塞 IO 过程。
阻塞 IO
最简单,最易懂
厨子做菜,做不好服务生也等着,等到菜做好再上菜。 上面其实已经讲过一次阻塞 IO 的流程了,只不过是站在 DMA 和 CPU 的角度来看的。我们来看看阻塞 IO 的瓶颈有哪些?
- 线程在 Read 数据的时候,如果数据没有准备好,那就陷入阻塞。假设数据不是必要的,有其他重要的事情做,那这里就会成为瓶颈,线程被阻塞,无法做事。
- 在阻塞 IO 的模型,一个 Scoket 对应一个线程,如果此时有上万个网络连接,那内核线程能否支撑这样大的并发量,所以这里也会成为瓶颈。 所以,阻塞 IO 虽然实现起来傻白甜,那是内核要考虑的问题就多了。
非阻塞 IO
厨子做菜,服务生不断轮询反问厨子有没有做好。 这一点对应到 CPU 就是进程不断调用 read(),如果数据准备好了,read()出数据,如果数据没有准备好,read()出 fasle,得到 false 的进程就去继续调用 read()。
while true:
data = read(fd) // 非阻塞调用
if data == EAGAIN or data == EWOULDBLOCK:
// 没有数据,立刻返回错误码
do_other_work() // 去做其他事情
else:
process(data) // 一旦有数据就处理
break
也就是说,用户线程需要不断询问内核数据是否准备好,虽然能及时获取到数据,但是在没有获取到数据的时候,不会主动交出 CPU 资源,而一直占用 CPU。 那么非阻塞 IO 的瓶颈在哪里呢?
- 忙等,占用 CPU 无意义的等待。而对于阻塞 IO 而言,他是不管三七二十一就直接阻塞等,而对于非阻塞 IO,虽然他没有傻阻塞,但是他实际上就是忙等,也不去做其他事,要时时刻刻看 fd 有没有准备好。我们希望的是什么呢?线程注册了事件后,去忙其他事情,然后当有数据准备好,他能回来做事。
- fd 数量多的时候,要一个一个 Check,效率低下。
IO 多路复用
举个 IO 多路复用常见的栗子
对于一个餐厅,假设此时有很多顾客都在点菜,点好菜后要通知服务生去告知后厨处理菜,此时有两种做法:
- 只要有顾客在点菜,就安排一个服务生站在他旁边等候,服务生能第一时刻获取到点菜的消息。
- 安排一个服务生等待,当用户点好菜后,呼叫服务生过来拿菜单,这样虽然处理效率可能低了点,但是减少了人力资源浪费。
上例对应的第二种方案,就是 IO 多路复用的核心理念,用较少的效率降低,减少资源浪费,试想一下,如果这个餐厅现在有 10000 个顾客呢?那我就要有 10000 个服务生与之对应。对应到计算机里,就要有 10000 个线程去监听顾客。
Select、Poll、Epoll
这三个函数是在了解 IO 多路复用时,无法绕开的函数,其实这三个函数的作用机理是类似的,大致来讲都是:
- 用户进程调用 select、poll、epoll 中的一个,注册多个 fd
- 用户进程阻塞,内核把对应用户进程挂在内核的等待队列里
- 当 fd 事件就绪,触发回调逻辑,内核通过等待队列寻找到 fd 对应的用户进程
- 内核将就绪的进程返回给用户进程,用户进场再处理这些 fd
(此处应有插图,自己按照理解画的,有误麻烦指正)

Select & Poll
二者是一类,poll 是 select 的优化版本。select 最高只支持注册 1024 个 fd,而 poll 无上限。 select 的作用方式是,将注册的所有 fd 组合成一个 list,然后将所有的 fd 都拷贝到内核(第一次 fd 拷贝),交由内核托管。内核先遍历一遍 fd,如果没有 fd 准备就绪,则休眠用户进程。当有 fd 的数据准备就绪后,实际上内核并不知道哪一个准备就绪了,所以只能进行一次全部遍历,然后找到就绪的 fd,再进行一次拷贝(第二次 fd 拷贝),这次拷贝则是告诉用户哪些 fd 已经就绪。之后,用户再通过 read 将 socket 缓冲区中的内核数据拷贝出来。 我们可以发现,select 和 poll 有个致命的缺点就是数据拷贝要经过至少两次 fd 遍历和两次 fd 的用户——内核拷贝。效率很低,即使只有一个 fd 准备就绪了,也需要遍历所有 fd,然后拷贝。epoll 应运而生。
Epoll
epoll 在原有的 select 和 poll 的基础上,引入了红黑树来管理 fd,使得无需多次遍历和拷贝。 epoll 的特点是:
- 使用红黑树管理 fd,每个 fd 只需要在添加时传入一次,无需用户每次都传入
- 通过异步 IO 事件找到就绪的 fd,而不是使用不断轮询的方式
- 使用队列存储就绪的文件描述符,且会按需返回就绪的文件描述符,无须再次遍历
epoll 主要涉及下面三个数据结构:
- wq:每个用户注册的每个 fd 事件,都会维护一个等待队列,这个等待队列项会挂到 底层设备驱动/文件的等待队列 上。比如说对 socket fd 注册的时候,内核会在 socket 的 wq 中挂一个回调,当 socket 数据到达会触发这个回调,从而找到对应的 fd。(归根结底是解决了找 fd 遍历的问题)。
- rbr:红黑树,管理注册的 fd。插入、删除、查找操作是 O(log n)。
- rdllist:就绪队列,把已经就绪的 fd 组织起来,供 epoll_wait 返回给用户态。
综上,epoll 的大致作用流程如下:
用户进程 epoll_ctl 注册 fd
↓
fd 事件加入 rbr
↓
fd 事件 挂 wq 回调
↓
设备有事件 → wq 回调触发
↓
fd 加入 rdllist
↓
用户进程 epoll_wait 拿到就绪 fd
Epoll 解决了当数据就绪时,需要遍历寻找 fd 的问题,也避免了 fd 的全量拷贝,而是只拷贝就绪的 fd。 那为什么不用 Hash 表管理 fd 呢?增删改查不是更快? 其实是有道理的,但是要纠正一点是,增真的快吗?而增到某一个地步,删改差还真的快?而这个时候再做扩容,性能是否会抖动呢?这些我认为是不采用 Hash 的缘由。
信号驱动 IO
当进程发起一个 IO 操作,会向内核注册一个信号处理函数,然后进程返回不阻塞;当内核数据就绪时会发送一个信号给进程,进程便在信号处理函数中调用 IO 读取数据。 但其实本质这种 IO 方式还是阻塞 IO,为什么呢?因为从内核拷贝数据的时候,还是会阻塞,反映到现实,也就是在 read 这一步还是会阻塞。
异步 IO
这是真正的非阻塞 IO,其实上面四种都是阻塞 IO,因为在 read 这一步,都会被阻塞。而异步 IO 的实现机理是,用户进程提交 I/O 请求后立即返回, 内核在数据准备好并拷贝到用户缓冲区后通知进程(回调、事件、future)。 区别就在于:真正的“异步”是内核负责等待+拷贝+通知一步到位,不像多路复用那样用户还得自己调用 read()。
小结
| 模型 | 等待数据 | 数据拷贝 | 是否阻塞调用线程 |
|---|---|---|---|
| 阻塞 I/O | 阻塞 | 阻塞 | 是 |
| 非阻塞 I/O | 轮询 | 阻塞 | 否(但轮询耗 CPU) |
| I/O 多路复用 | 阻塞等待事件 | 阻塞拷贝 | 是(事件等待阶段) |
| 信号驱动 I/O | 信号通知 | 阻塞拷贝 | 否 |
| 异步 I/O | 内核等待 & 拷贝 | 内核完成 | 否 |