I/O 多路复用: 从入门到放弃(一)

I/O 多路复用: 从入门到放弃(一)

背景

最近在学习 k8s 的控制器模式时,作者最后有个灵魂一问:控制器模式和事件驱动的不同是什么?有些人答曰是 select 和 epoll 的差别,也有人答曰事件驱动是一次性的、被动的,控制器模式是主动的。我呢,越听越迷糊,时隔半年又看到 epoll,我竟一点都想不起来它的原理,所以我这次一定要把它搞明白,这就是我写这篇文章的初衷。这篇文章将从根上开始谈起,NIC(网卡)和内核、I/O 多路复用、select & epoll、事件驱动设计模式…

废话少说,Let's Go!


一个 TCP 连接是一种特殊的 I/O 资源,在 Linux 中都是依赖于文件描述符(fd)处理 I/O,也就是一个 TCP 连接对应一个 fd。1

I/O 事件

I/O 事件包含可读和可写两种。

  • 可读事件是指内核缓冲区非空,用户可以从缓冲区中读数据;
  • 可写事件是指内核缓冲区不满,用户可以向缓冲区中写数据。

NIC & Kernel

I/O 事件为什么必须要内核的参与?用户进程不能直接处理内核请求吗?

不行,Linux 系统出于安全考虑,涉及到与硬件交互的操作必须要由内核处理,在网络 I/O 中涉及到的硬件是网卡(NIC)。那么 NIC 与 kernel 是如何互动的呢?2

  1. NIC 接收到数据,通过 DMA 方式写入内存(Ring Buffer 和 sk_buff);
  2. NIC 发出中断请求(IRQ),告诉内核有新的数据过来了;
  3. Linux 内核响应中断,系统切换为内核态,处理 Interrupt Handler,从 RingBuffer 拿出一个 Packet, 并处理协议栈,填充 Socket 并交给用户进程;
  4. 系统切换为用户态,用户进程处理数据内容。

数据包到达后需要内核填充 Socket 交给用户。

非阻塞 I/O & 阻塞 I/O

非阻塞 I/O(Non-Block I/O)和阻塞 I/O 都是同步的,他们的区别在于是否占用 CPU 时间

非阻塞 I/O 非常简单,进程等不到数据就通过一个 for loop 无限循环的等下去,直到数据准备好。只要数据没准备好 CPU 就一直进行轮询操作,最终导致 CPU 有效利用率降低。

为什么会导致 CPU 有效利用率低?

因为 I/O 任务的速度太慢,跟不上 CPU 的速度,以网络 I/O 为例,数据包传递的延迟一般是 ms 级,而 CPU 处理一次任务事件一般是 ns 级,数据不全时 CPU 的轮询操作属于无意义的工作且时间过长,CPU 有效利用率自然就显著降低了。

我们可以想到:如果数据不全的时候让进程阻塞,等内核确认数据可用时再把进程唤醒,这样就避免了 CPU 轮询导致的空耗问题了,这就是阻塞 I/O 的初衷。

如果进程被标记为阻塞的进程,进程调度器不会给它分配 CPU 时间。从进程的角度,因为缺少数据而阻塞等待(同步)。从全局的角度,等待数据的进程不再占用 CPU 时间,其他任务可以获得更多的 CPU 时间,CPU 利用率提高了。

那么被阻塞的进程被谁唤醒?什么时候才能被唤醒呢?

被内核唤醒,因为只有内核才知道哪个进程的数据准备好了。众所周知一个端口对应着一个进程,内核可以根据 TCP 包中的端口号识别当前数据包是哪个进程的,然后唤醒对应进程。

至此,一个进程阻塞和唤醒形成了闭环(没错,就是一个完美的闭环🤪)。

那么非阻塞 I/O 和阻塞 I/O 到底谁更拉?

答案是看情况,在一般场景下非阻塞 I/O 更拉。因为非阻塞 I/O 一直占用着 CPU,虽然没有上下文切换的消耗,但是 I/O 处理速度和 CPU 处理速度不在一个量级上,导致严重的 CPU 空耗。阻塞 I/O 其实也没好到哪去,在数据包频繁到达时系统需要响应中断并切换上下文进入内核态,高频率切换同样会对效率造成严重影响。

优化:I/O 多路复用

上面提到过,如果数据包频繁的到达,那么操作系统就要频繁上下文切换,这种切换代价是非常大的,所以自然而然就会把多次数据包缓存下来,变成一次批处理。有一种方案是配合网卡的 New API(NAPI) 把数据囤一段时间后批处理,但是这种方案不在我们的讨论范围,我们重点来看 Linux 内核中的单进程(线程)I/O 多路复用。

I/O 多路复用 = 阻塞 I/O + 非阻塞 I/O,这应该要怎么理解呢?3

原始模型下,一个进程(线程)只关系自己需要的 fd 是否准备就绪,假设一个进程(线程)只关心 1 个 fd 状态,那么 10000 个 fd 需要 10000 个进程(线程),特别是在一般场景下,10000 个 fd 中活跃的 fd 可能只有 10-20 个,那剩下的 9980 个进程(线程)就是在空耗 CPU。

I/O 多路复用则是让一个专有进程(线程)监控多个 fd 是否准备就绪,用户进程采用阻塞 I/O。

新模型下,1 个进程(线程)负责监控 10000 个 fd,假设活跃的只有 10-20 个,所以这个进程努努力的话可以一个人处理这 10-20 个活跃 fd,用户进程被阻塞直到专用进程(线程)将他们唤醒。

但是 I/O 多路复用不是灵丹妙药。我们一直在说一个假设:活跃的只有 10-20 个。那么我再假设:活跃 fd 数量很多,比如 10000 个 fd 中 9999 个都是活跃的 fd,那么 I/O 多路复用的性能一定不如非阻塞 I/O 的,因为一个进程(线程)再怎么努力也忙不过来。

这就是 I/O 多路复用最基本的思路,但又因为实现方式的差异,导致执行效率显著不同,他们之间的实现细节将在下一篇文章中详细讨论。

  1. https://segmentfault.com/a/1190000038901651

  2. https://os.51cto.com/art/202103/649405.htm

  3. https://imageslr.com/2020/02/27/select-poll-epoll.html#%E4%B8%BA%E4%BB%80%E4%B9%88-io-%E5%A4%9A%E8%B7%AF%E5%A4%8D%E7%94%A8%E5%86%85%E9%83%A8%E9%9C%80%E8%A6%81%E4%BD%BF%E7%94%A8%E9%9D%9E%E9%98%BB%E5%A1%9E-io

All rights reserved
Except where otherwise noted, content on this page is copyrighted.