之前面试准备过这里的八股,但觉得理解不够深,故打算再探深些

五种 IO 模型

  1. 阻塞 IO
  2. 非阻塞 IO
  3. IO 多路复用
  4. 信号驱动 IO
  5. 异步 IO

阻塞 IO

最简单,最易懂

厨子做菜,做不好服务生也等着,等到菜做好再上菜。 不难看出,效率很低,但是实现简单,对应到计算机世界中,这里用 C 语言的 read() 函数去举例(虽然 C 语言烂的一坨:)

  1. 用户态调用 read(fd, buf, count)
  2. 软中断切入内核态,内核根据 fd 查文件描述符表,找到对应的内核文件描述符
  3. 检查页缓存,是不是存在数据(之前用过,缓存还在)
  4. 缓存没有,触发磁盘 IO,等待数据,进程阻塞挂起
  5. 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 多路复用常见的栗子

对于一个餐厅,假设此时有很多顾客都在点菜,点好菜后要通知服务生去告知后厨处理菜,此时有两种做法:

  1. 只要有顾客在点菜,就安排一个服务生站在他旁边等候,服务生能第一时刻获取到点菜的消息。
  2. 安排一个服务生等待,当用户点好菜后,呼叫服务生过来拿菜单,这样虽然处理效率可能低了点,但是减少了人力资源浪费。

上例对应的第二种方案,就是 IO 多路复用的核心理念,用较少的效率降低,减少资源浪费,试想一下,如果这个餐厅现在有 10000 个顾客呢?那我就要有 10000 个服务生与之对应。对应到计算机里,就要有 10000 个线程去监听顾客。

Select、Poll、Epoll

这三个函数是在了解 IO 多路复用时,无法绕开的函数,其实这三个函数的作用机理是类似的,大致来讲都是:

  1. 用户进程调用select、poll、epoll 中的一个,注册多个 fd
  2. 用户进程阻塞,内核把对应用户进程挂在内核的等待队列里
  3. 当 fd 事件就绪,触发回调逻辑,内核通过等待队列寻找到 fd 对应的用户进程
  4. 内核将就绪的进程返回给用户进程,用户进场再处理这些 fd

(此处应有插图,自己按照理解画的,有误麻烦指正) image.png

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 的特点是:

  1. 使用红黑树管理 fd,每个 fd 只需要在添加时传入一次,无需用户每次都传入
  2. 通过异步 IO 事件找到就绪的 fd,而不是使用不断轮询的方式
  3. 使用队列存储就绪的文件描述符,且会按需返回就绪的文件描述符,无须再次遍历

epoll 主要涉及下面三个数据结构:

  1. wq:每个用户注册的每个 fd 事件,都会维护一个等待队列,这个等待队列项会挂到 底层设备驱动/文件的等待队列 上。比如说对 socket fd注册的时候,内核会在 socket 的 wq 中挂一个回调,当 socket 数据到达会触发这个回调,从而找到对应的 fd。(归根结底是解决了找 fd 遍历的问题)。
  2. rbr:红黑树,管理注册的 fd。插入、删除、查找操作是 O(log n)
  3. 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内核等待 & 拷贝内核完成