深色模式
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 快的真正原因(必答三点):
- 不用每次全量拷贝 fd 集合(注册一次,长期有效)。
- 不用遍历所有 fd(内核回调把就绪的挂到链表,直接取)。
- 只返回就绪的 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 的区别?(通知时机)和其他知识的连接
- < 来自 进程与线程:谁在跑,跑的时候带着什么:阻塞 IO 让进程进入阻塞态,状态机里的那条边
- < 来自 调度与并发同步:CPU 分给谁,共享数据谁先动:IO 多路复用本质是"一个线程等多个事件",替代了多线程阻塞
- < 来自 虚拟内存:让每个程序都以为自己独占一台机器:
mmap是虚拟内存的应用,零拷贝建立在页缓存之上 - > 影响 分层模型与一次请求的一生:内核协议栈收包后放进 socket 缓冲区,应用才读得到
- > 影响 事务与 ACID:
fsync强制刷盘,本质是"绕过页缓存的延迟" - > 影响 工程实践:Nginx / Redis / Netty / Kafka 的高性能都建立在这篇之上
自测 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 分钟,中途不卡壳