之前面试准备过这里的八股,但觉得理解不够深,故打算再探深些
五种 IO 模型
- 阻塞 IO
- 非阻塞 IO
- IO 多路复用
- 信号驱动 IO
- 异步 IO
阻塞 IO
最简单,最易懂
厨子做菜,做不好服务生也等着,等到菜做好再上菜。 不难看出,效率很低,但是实现简单,对应到计算机世界中,这里用 C 语言的 read() 函数去举例(虽然 C 语言烂的一坨:)
- 用户态调用 read(fd, buf, count)
- 软中断切入内核态,内核根据 fd 查文件描述符表,找到对应的内核文件描述符
- 检查页缓存,是不是存在数据(之前用过,缓存还在)
- 缓存没有,触发磁盘 IO,等待数据,进程阻塞挂起
- DMA 数据 Copy 完成,触发硬件中断,进而内核把页缓存的数据 copy_to_user 到用户空间 buf
上面就是一次完整的阻塞 IO,我们可以看出,在用户态触发 read() 后,后面的逻辑基本上都是交由内核态进程去接管的,但是该用户态进程此时卡死在这里,如果 fd 对应的是网络请求,那一个 fd 就得拉一个进程接管,假设上万个请求,那系统中会有上万个阻塞进程,所以不适合并发规模较大的场景。
非阻塞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 多路复用
举个 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 | 内核等待 & 拷贝 | 内核完成 | 否 |