Skip to content

04 · IO 模型与零拷贝:等设备的时候,CPU 在干什么 ​

这篇是 OS 模块里最"工程"的一篇,也是面试问得最深的一篇。 它同时串起了 01(阻塞态)、03(调度)、02(mmap)。

一句话本质 ​

IO 的本质是**"等":等磁盘转、等网卡收包。所谓"五种 IO 模型",区别只在于这段时间谁来等、CPU 能不能干别的**。

生活类比:点外卖 ​

IO 模型外卖场景
阻塞 IO你站在门口一直等,什么都不干,直到骑手到
非阻塞 IO你每 30 秒下楼看一次,没到就回去干点活(但来回跑很累)
IO 多路复用你装了个门铃,同时等 5 个外卖,谁到了门铃响,你只处理到了的那个
信号驱动 IO你告诉骑手"到了给我打电话",然后安心干活,电话来了再去拿
异步 IO你雇了个跑腿,他拿到外卖送到你手上才叫你

关键差别在最后一格:前四种都是"数据到了,你自己去搬",只有异步 IO 是"数据搬完了才通知你"。

核心机制 ​

一、一次网络读操作的两个阶段(这是理解一切的基础) ​

阶段一:等数据到达
  网卡收到包 > 内核把数据从网卡拷到内核缓冲区(socket 接收缓冲区)
  这期间:数据还没到用户能拿的地方

阶段二:把数据从内核缓冲区拷到用户缓冲区
  内核 > 用户,一次内存拷贝(这一步一定要 CPU 参与)

五种 IO 模型的差别,全部落在这两个阶段上:

                阶段一(等数据)        阶段二(拷贝数据)
阻塞 IO          阻塞                  阻塞
非阻塞 IO        轮询(不阻塞)          阻塞
IO 多路复用       阻塞在 select/epoll    阻塞
信号驱动 IO       不阻塞(信号通知)      阻塞
异步 IO          不阻塞                 不阻塞  < 只有这个第二阶段也不阻塞

一句话记住:前四种是"同步 IO"(因为第二阶段都要你自己等拷贝完成),只有 AIO 是真正的异步。

二、IO 多路复用:select / poll / epoll ​

select(1983)
  每次调用:把整个 fd 集合从用户态拷到内核态 > 内核遍历所有 fd 检查 > 返回后用户再遍历一遍找谁就绪
  问题:① fd 上限 1024(FD_SETSIZE)② 每次都要全量拷贝 ③ O(n) 遍历

poll(1997)
  用链表代替位图,解决了 1024 上限
  问题:全量拷贝和 O(n) 遍历还在

epoll(2002,Linux 2.6)
  三个关键改动:
  ① epoll_create 建一个内核事件表,fd 只需注册一次(不用每次拷)
  ② 内核用回调机制:fd 就绪时主动把事件挂到就绪链表(不用遍历)
  ③ epoll_wait 只返回就绪的那几个(O(1) 级别,与总 fd 数无关)

epoll 快的真正原因(必答三点):

  1. 不用每次全量拷贝 fd 集合(注册一次,长期有效)。
  2. 不用遍历所有 fd(内核回调把就绪的挂到链表,直接取)。
  3. 只返回就绪的 fd(用户态也不用遍历)。

注意:epoll 不是"永远更快"。fd 少且都活跃时(比如 10 个连接全都一直在收发),select 的遍历开销很小,epoll 的回调注册反而有额外成本。epoll 的优势在fd 多但活跃的少(典型 Web 服务器:1 万连接,同时活跃几十个)。

三、水平触发(LT)vs 边缘触发(ET) ​

水平触发(LT,默认):
  只要缓冲区还有数据,epoll_wait 就一直返回这个 fd
  > 编程简单:可以一次只读一点,下次还会通知
  > 但如果忘了读,会一直空转通知(忙循环)

边缘触发(ET):
  只在"状态变化"时通知一次(从没数据 > 有数据)
  > 必须一次读到 EAGAIN 为止,否则剩下的数据没人通知你
  > 效率更高(通知次数少),但编程容易出错

ET 模式的铁律:必须配合非阻塞 fd + 循环读到 EAGAIN。否则会漏数据。

四、Reactor 与 Proactor ​

Reactor(对应 IO 多路复用)
  主线程:epoll_wait 等事件 > 分发给 handler > handler 自己 read/write
  特点:事件通知的是"可以读了",读的动作还是你自己做

Proactor(对应异步 IO)
  主线程:发起 aio_read > 内核读完数据 > 通知"读完了,数据在这"
  特点:事件通知的是"读完了",你直接处理数据

Linux 上的 AIO 一直不成熟(io_uring 是 2019 年才真正好用),所以主流网络框架(Nginx、Redis、Netty)都是 Reactor。Windows 的 IOCP 是真正的 Proactor。

五、零拷贝:为什么要"零" ​

普通读文件 + 发网络,数据要拷 4 次、切换 4 次:

磁盘 > 内核页缓存    (DMA 拷贝,不占 CPU)
内核页缓存 > 用户缓冲区  (CPU 拷贝 ①)
用户缓冲区 > socket 缓冲区 (CPU 拷贝 ②)
socket 缓冲区 > 网卡   (DMA 拷贝)
+ 4 次用户态/内核态切换

sendfile:数据不经过用户态,直接从页缓存到 socket 缓冲区。

磁盘 > 页缓存(DMA) > socket 缓冲区(CPU 拷贝) > 网卡(DMA)
+ 2 次切换,1 次 CPU 拷贝

sendfile + DMA gather(真正的零拷贝):连那 1 次 CPU 拷贝也省了,只传"页的描述符"给网卡,由网卡自己按描述符去内存取。

磁盘 > 页缓存(DMA) > 网卡(DMA,直接按描述符取)
+ 2 次切换,0 次 CPU 拷贝

mmap + write:把文件映射进用户地址空间,省掉"读"的那次拷贝,但"写"的那次还在。适合需要读写文件内容的场景(Kafka 用这个)。

为什么这么设计 ​

反事实 1:如果没有 IO 多路复用,会怎样 ​

一个服务器要处理 1 万个连接。用"一连接一线程":

  • 1 万线程 × 1MB 栈 = 10GB 内存
  • 每个线程大部分时间在阻塞等数据,但内核要维护 1 万个线程的调度

用"一连接一进程"更惨。这就是 C10K 问题的由来——问题不是 CPU 不够,是"等待"的方式太贵。

反事实 2:如果没有零拷贝,会怎样 ​

一个静态文件服务器,发 1GB 文件:

  • 4 次拷贝里,2 次是 CPU 参与的
  • 每次拷贝都要占用内存带宽和 CPU 周期
  • 高并发时,CPU 全耗在"搬数据"上,而不是"处理请求"上

零拷贝把 CPU 从搬运工变成调度员,同样的硬件能扛几倍的吞吐。

反事实 3:如果 select 没有 1024 限制,还需要 epoll 吗 ​

需要。1024 只是表面问题,真正的瓶颈是 O(n) 遍历。哪怕放宽到 10 万,每次 select 遍历 10 万个 fd 也要几十毫秒——而此时可能只有 3 个 fd 有数据。

面试怎么问 ​

Q:五种 IO 模型的区别?

用"两个阶段"回答,不要罗列名字。核心是:前四种的第二阶段(内核>用户拷贝)都是阻塞的,所以都是同步 IO;只有 AIO 第二阶段也不阻塞。

Q:epoll 为什么比 select 快?

三点:不用全量拷贝(注册一次)、不用遍历(回调挂就绪链表)、只返回就绪 fd。 加分句:fd 少且全活跃时 epoll 不一定快,它的优势在"连接多、活跃少"。

Q:LT 和 ET 怎么选?

LT 编程简单、不容易漏事件,适合大多数场景(Nginx 默认用 ET 是因为它自己封装好了)。 ET 通知次数少、效率高,但必须配合非阻塞 + 循环读到 EAGAIN,否则丢数据。

Q:什么是零拷贝?

不是"不拷贝",是"减少 CPU 参与的拷贝次数"。普通方式 4 次拷贝 4 次切换,sendfile 降到 1 次 CPU 拷贝,sendfile + DMA gather 降到 0 次。

Q:为什么 Redis 单线程还这么快?

四个原因,别只说"内存": ① 纯内存操作,没有磁盘 IO 的等待。 ② IO 多路复用(epoll),一个线程管上万连接,没有线程切换和锁竞争。 ③ 单线程避免了锁,所有操作天然串行,不需要同步。 ④ 高效的数据结构(SDS、跳表、压缩列表),常数小。 补一句:Redis 6.0 之后网络 IO 改成多线程了,但命令执行仍是单线程——因为瓶颈从来不在 CPU。

追问链:

一次 read 发生了什么?
  > 为什么数据要先到内核缓冲区?(用户态不能直接访问硬件/网卡)
    > 那为什么还要再拷一次到用户态?(隔离,用户不能直接读内核内存)
      > 能不能不拷?(mmap / 零拷贝)
        > 一万个连接怎么等?(IO 多路复用)
          > select 有什么问题?(1024 + O(n))
            > epoll 怎么解决的?(回调 + 就绪链表)
              > LT 和 ET 的区别?(通知时机)

和其他知识的连接 ​

自测 3 问 ​

Q1:为什么说前四种 IO 都是"同步 IO"?

点开看答案

因为它们的第二阶段(把数据从内核缓冲区拷到用户缓冲区)都是阻塞的,都要等这个拷贝完成才能继续。

"同步/异步"的分界不在"等数据到达时能不能干别的",而在**"数据搬运这件事是谁完成的、完成时通不通知你"**:

  • 同步:你自己调 read,调用的那一刻数据才被搬到你手上,read 返回时搬运已完成。
  • 异步:你发起 aio_read 就返回了,内核搬完之后回调通知你,数据已经在你指定的缓冲区里了。

所以非阻塞 IO 虽然第一阶段不阻塞,但第二阶段还是你自己等,仍属同步。

Q2:ET 模式为什么必须"循环读到 EAGAIN"?

点开看答案

因为 ET 只在状态变化时通知一次。

假设缓冲区来了 10KB 数据,你只读了 4KB 就返回了。此时状态没有变化(缓冲区一直"有数据"),内核不会再通知你。剩下的 6KB 就一直躺在那里,直到有新数据到达才可能再次触发——但可能永远不来,连接就"假死"了。

所以必须循环读到 read 返回 EAGAIN(表示"现在没数据了"),确保缓冲区被读空。而且必须用非阻塞 fd,否则最后那次 read 会把你阻塞住。

Q3:零拷贝真的"零"吗?

点开看答案

不是零次拷贝,是零次 CPU 参与的拷贝。

数据仍然要从磁盘到内存(DMA 拷贝,不占 CPU)、从内存到网卡(DMA 拷贝,不占 CPU)。省掉的是"内核缓冲区 > 用户缓冲区 > socket 缓冲区"这两次 CPU memcpy。

所以更准确的说法是"零 CPU 拷贝"或"零用户态拷贝"。它的收益在于:省掉 CPU 周期和内存带宽,同时把 4 次上下文切换降到 2 次。

类比失效处 / 易错点 ​

  • "门铃"类比会让人以为多路复用是"并行"。实际上 epoll 仍是单线程串行处理就绪的 fd,只是"等待"这件事被合并了。真正的并行要靠多线程/多进程(Nginx 的 worker)。
  • epoll 不是"永远更快"。连接少且全活跃时,select 可能更省。
  • "零拷贝"不能用于需要修改数据的场景。sendfile 数据不经过用户态,你就没法加工它。Kafka 用 sendfile 是因为它只做"转发",不做处理;要加密/压缩就得用 mmap 或退回普通方式。
  • AIO 在 Linux 上长期不成熟。io_uring 之前,Linux 的 POSIX AIO 是用线程模拟的,不是真异步。所以"Linux 支持 AIO"这句话要加限定。
  • epoll_wait 的 O(1) 是"返回就绪 fd"的开销,不是"处理事件"的开销。事件处理本身还是 O(就绪数)。

✅ 验收清单 ​

  • 能画出"两个阶段"的图,并把五种 IO 模型填进表格(哪个阶段阻塞/不阻塞)
  • 能说出"为什么前四种是同步 IO、只有 AIO 是异步"的判据
  • 能说出 epoll 比 select 快的三个原因,并能补一句"什么时候 epoll 不占优"
  • 能解释 LT 和 ET 的区别,以及 ET 为什么必须循环读到 EAGAIN
  • 能画出普通 IO 的 4 次拷贝 4 次切换,并说出 sendfile 省掉了哪几次
  • 能用四个原因解释"Redis 单线程为什么快",并说出 6.0 的改动
  • 合上书,用自己的话讲 3 分钟,中途不卡壳