Appearance
IO 模型:多路复用、epoll 与零拷贝
一句话总览:一次「读数据」都要经历 ① 等待数据就绪 和 ② 从内核拷贝到用户态 两个阶段。五种 IO 模型的差别,只在于这两阶段谁来等、怎么等;epoll 优化的是「等待」,零拷贝优化的是「拷贝」。这两条主线撑起了高并发服务器(Nginx / Redis / Kafka / Netty)的全部性能故事。
一、IO 的本质
Q1. 一次 read 到底经历了什么?
答案
以读磁盘/网卡为例,read(fd, buf, n) 分两个阶段:
- 等待数据就绪:数据先从设备到达内核缓冲区(内核要把数据准备好)。这一步线程是否阻塞,是区分 IO 模型的关键。
- 数据拷贝:把内核缓冲区里的数据拷贝到用户缓冲区
buf(由 CPU 执行的memcpy)。这一步一定存在(除非用零拷贝绕过)。
所以 IO 的开销 = 等待时间(取决于设备)+ 拷贝时间(CPU 参与)。
Q2. 设备管理有哪几种方式?DMA 为什么重要?
答案
- 程序控制 IO(轮询 / busy-wait):CPU 反复读设备状态寄存器,直到就绪。CPU 空转,浪费严重。
- 中断驱动 IO:设备就绪时发中断,CPU 响应处理。比轮询高效,但每字节/每小块一次中断,高吞吐下中断开销大。
- DMA(直接内存访问):CPU 只负责发起传输(告诉 DMA 控制器:从设备搬到哪个内存地址),之后设备通过 DMA 直接把数据搬进内存,搬完发一次中断通知 CPU。
现代 IO 普遍是 中断 + DMA 结合:DMA 完成大块搬运,中断只做「收尾通知」,把 CPU 从搬运工作中解放出来。这正是后续零拷贝「两次 DMA 拷贝」的硬件基础。
二、五种 IO 模型 ⭐
统一用同一场景:线程要读一个 socket 的数据。
Q3. 阻塞 IO(Blocking IO)?
答案
调用 read 后,线程一直阻塞(睡眠),直到①数据就绪、②内核把数据拷到用户态、③返回。两个阶段都在等。
优点:编程最简单(同步直读)。缺点:一个线程只能管一个连接,想并发就得开大量线程,线程切换 / 内存开销巨大。
Q4. 非阻塞 IO(Non-blocking IO)?
答案
fd 设为 O_NONBLOCK 后,read 立即返回:数据没就绪就返回 EAGAIN/EWOULDBLOCK,应用程序轮询(循环重试)。
注意:轮询期间 CPU 空转,效率低;而当数据真正就绪时,那次 read 的拷贝阶段仍然是阻塞的。所以「非阻塞」只是让等待阶段不阻塞,并没有摆脱「拷贝阻塞」。
Q5. IO 多路复用(IO Multiplexing)?
答案
用 select / poll / epoll 同时监听多个 fd,阻塞在复用函数上;一旦某些 fd 就绪,函数返回,应用再对就绪 fd 逐个 read(此时拷贝阶段仍可能阻塞)。
它用一个线程管 N 个连接,把「等待」这一阶段集中管理,是高并发的主流方案(Redis 单线程、Nginx、Netty 都基于它)。代价是额外一次「select + read」两次系统调用(相比直接阻塞读)。
Q6. 信号驱动 IO(Signal-driven IO)?
答案
先用 sigaction 注册 SIGIO 信号处理函数;数据就绪时内核发信号,在信号处理函数里发起 read。等待阶段不阻塞(可干别的),但拷贝阶段仍需阻塞,且信号处理编程复杂、易丢失事件,实际用得很少。
Q7. 异步 IO(Asynchronous IO, AIO)?
答案
调用 aio_read 立即返回,内核负责完成**「等待 + 拷贝」全过程**,全部完成后通过信号 / 回调 / 事件通知应用。应用线程自始至终不阻塞。
- 传统 POSIX AIO(
io_submit)实现有诸多限制;io_uring(Linux 5.1+) 是现代真正高性能的异步 IO:提交队列 + 完成队列共享内存环形缓冲,尽量减少系统调用,网络与磁盘通吃,是新一代高并发基础设施的方向。
Q8. 五种模型对比小结?
答案
| 模型 | 等待阶段 | 拷贝阶段 | 是否阻塞线程 | 典型实现 |
|---|---|---|---|---|
| 阻塞 IO | 阻塞 | 阻塞 | 是 | read |
| 非阻塞 IO | 轮询(不阻塞) | 阻塞 | 否(但空转) | O_NONBLOCK + loop |
| IO 多路复用 | 阻塞在复用函数 | 阻塞 | 是(管多 fd) | select/poll/epoll |
| 信号驱动 IO | 不阻塞(等信号) | 阻塞 | 否 | SIGIO |
| 异步 IO | 不阻塞 | 不阻塞 | 否 | io_uring / IOCP |
前四种在拷贝阶段都要用户线程参与,只有异步 IO 全程不参与。
三、概念澄清:阻塞/非阻塞 vs 同步/异步
Q9. 「同步 IO」和「异步 IO」怎么界定?非阻塞就是异步吗?
答案
区分两个不同维度:
- 阻塞 / 非阻塞:描述调用是否立即返回(调用方视角)。
read阻塞 = 阻塞;立即返回EAGAIN= 非阻塞。 - 同步 / 异步:描述**「数据从内核到用户」这件事是否由调用方等待最后结果**。POSIX 的界定:同步 IO 指真正的 IO 操作(拷贝)由用户线程发起并等待完成;异步 IO 指内核完成一切后通知。
结论:阻塞、非阻塞、IO 多路复用、信号驱动,都属于「同步 IO」(因为拷贝阶段都要用户线程参与,要么阻塞要么轮询);只有 AIO 是真正的「异步 IO」。
所以「非阻塞 IO」≠「异步 IO」,非阻塞只是让等待阶段不阻塞,拷贝还得自己来。
四、select / poll / epoll ⭐
Q10. select 的原理与缺陷?
答案
- 用
fd_set位图表示关心的 fd,调用select(nfds, &r, &w, &e, &timeout)阻塞等待。 - 限制与缺陷:
- fd 上限:
FD_SETSIZE通常 1024,无法支撑 C10K+; - 每次调用都要把整个 fd 集合从用户态拷贝到内核态,fd 多了拷贝开销大;
- 内核对所有 fd 线性遍历 判断就绪,O(n);
- 返回后只告诉你「有几个就绪」,要全量再遍历一遍找出是谁,仍是 O(n);
fd_set会被内核修改,下次调用前要重新设置;- 超时精度较低(微秒),且是水平触发。
- fd 上限:
Q11. poll 相对 select 的改进?
答案
- 用
struct pollfd数组({fd, events, revents})替代位图,没有 1024 上限(受限于系统 fd 总数)。 - 用
revents单独记录「返回的事件」,不需要每次重置整个集合。 - 但每次调用仍要拷贝整个数组、内核仍线性遍历 O(n)、返回后仍要全量扫描找就绪者——本质没变,只是接口更好用、无硬上限。
Q12. epoll 为什么高效?底层结构是什么? ⭐
答案
epoll 三个调用:epoll_create(建 eventpoll 对象)、epoll_ctl(增删改监听 fd)、epoll_wait(等待就绪)。
eventpoll 内部三个关键结构:
- 红黑树:管理所有被监听的 fd(epitem),增删改
epoll_ctl为 O(log n); - 就绪链表(rdllist):只挂当前就绪的 epitem;
- 等待队列(wq):
epoll_wait的线程在这里睡眠。
回调机制:epoll_ctl 添加 fd 时,向该 fd 的等待队列注册一个回调 ep_poll_callback。当 fd 事件就绪(设备/协议栈触发),内核调用该回调,把对应 epitem 直接插入就绪链表,并唤醒 epoll_wait 上的线程。epoll_wait 只需检查就绪链表是否为空,非空就返回其中的 fd 列表。
高效原因:
- 监听集合只在
epoll_ctl时拷进内核一次,epoll_wait不再重复拷贝; - 不用全量轮询:每次返回的就是就绪的 fd,复杂度与就绪数量成正比,而不是与总 fd 数成正比;
- 适合连接多、活跃少的场景(C10K+ 的典型形态)。
Q13. select / poll / epoll 对比?
答案
| 维度 | select | poll | epoll |
|---|---|---|---|
| 数据结构 | fd_set 位图 | pollfd 数组 | 红黑树 + 就绪链表 |
| fd 上限 | 1024(FD_SETSIZE) | 无硬上限 | 无硬上限 |
| 是否每次拷贝全量 | 是 | 是 | 否(epoll_ctl 时一次) |
| 内核查找就绪 fd | O(n) 轮询 | O(n) 轮询 | O(1) 取就绪链表 |
| 返回后是否需全量扫描 | 是 | 是 | 否(直接给就绪列表) |
| 触发模式 | LT | LT | LT / ET |
| CPU 随连接数增长 | 是 | 是 | 否(只随活跃数) |
选型:连接数少且都活跃 → select/poll 也行;大量连接、少量活跃(Web 服务器)→ 必须 epoll。
Q14. epoll 的 LT 与 ET 模式?为什么 ET 必须配非阻塞? ⭐
答案
- LT(水平触发,level-triggered,默认):只要 fd 的读缓冲区还有数据(或可写),
epoll_wait就持续返回它。编程简单,漏读也不怕;但可能被反复唤醒,多一次系统调用开销。 - ET(边缘触发,edge-triggered):只在状态发生变化的瞬间通知一次(如缓冲区从「空」变「非空」)。必须一次把数据读完——循环
read直到返回EAGAIN,否则剩余数据不会再通知你。
ET 为什么必须非阻塞:因为要循环读到「读空」为止,而最后那次「读空」的 read 若在阻塞模式下会永久阻塞住整个线程。所以 ET 下 fd 必须设 O_NONBLOCK,靠 EAGAIN 判断读完了。
配套技巧:EPOLLONESHOT(事件只通知一次,处理完重新注册,防止同一 fd 被多线程重复处理);EPOLLEXCLUSIVE(避免多个 epoll 实例的惊群,一个事件只唤醒一个等待者)。
取舍:ET 唤醒更少、性能更高,但编程更易错;LT 更简单、更稳妥。
五、零拷贝(Zero-Copy)⭐
Q15. 传统 read + write 发文件,为什么要 4 次切换 + 4 次拷贝?
答案
服务端把磁盘文件发给网络(例如静态文件服务器),朴素写法:
cpp
while ((n = read(fd_file, buf, SIZE)) > 0)
write(fd_sock, buf, n);数据路径:
read:① DMA 拷贝 磁盘 → 内核缓冲区;② CPU 拷贝 内核缓冲区 → 用户缓冲区buf;write:③ CPU 拷贝 用户缓冲区 → socket 缓冲区;④ DMA 拷贝 socket 缓冲区 → 网卡。
合计 4 次拷贝(2 次 DMA + 2 次 CPU) 和 4 次上下文切换(read/write 各两次用户态↔内核态)。数据在用户态被白白搬进搬出一次,纯属浪费。
Q16. mmap + write 怎么优化?
答案
用 mmap 把内核缓冲区映射到用户空间,让用户与内核共享这块内存(不再「拷贝到用户缓冲区」),然后 write:
- 拷贝:DMA(磁盘→内核)+ CPU(内核→socket 缓冲)+ DMA(socket→网卡)= 3 次,切换仍 4 次。
- 省掉了「内核 → 用户」那次 CPU 拷贝。
- 缺点:
mmap大文件时缺页开销明显;若文件被其他进程截断,访问映射区会触发SIGBUS。
Q17. sendfile 呢?它和 mmap+write 的区别?
答案
sendfile(out_fd, in_fd, offset, count) 让数据完全在内核里从「文件」流向「socket」,不经过用户态:
- 拷贝:DMA(磁盘→内核)+ CPU(内核缓冲→socket 缓冲)+ DMA(socket→网卡)= 3 次,切换降到 2 次(一次系统调用)。
- 相比
mmap+write:少了一次上下文切换(mmap还得自己write),且不需要用户态映射,语义更简单。但仍有 1 次 CPU 拷贝(内核缓冲 → socket 缓冲)。
Q18. 怎样做到「真正零拷贝」(0 次 CPU 拷贝)? ⭐
答案
sendfile + SG-DMA(scatter-gather DMA,需网卡支持):
- 内核缓冲区里的数据不再拷贝到 socket 缓冲区,而是只把「内存地址 + 长度」的描述符传给网卡;
- 网卡凭描述符直接从内核缓冲区 DMA 取数据发出去。
于是数据路径只剩:
- ① DMA 拷贝 磁盘 → 内核缓冲区;
- ② DMA 拷贝 内核缓冲区 → 网卡(由网卡 DMA 完成,CPU 不参与)。
合计 2 次拷贝、全是 DMA、0 次 CPU 拷贝、2 次上下文切换——这就是常说的「零拷贝」(指零 CPU 拷贝,DMA 拷贝无法省)。
splice:借助内核**管道(pipe buffer)**在两个 fd 之间移动数据(还能做 socket → socket 转发),同样做到无 CPU 拷贝,适合代理 / 转发场景。
Q19. 零拷贝方案对比表?
答案
| 方案 | 拷贝次数 | CPU 拷贝 | 上下文切换 | 数据是否过用户态 | 典型用途 |
|---|---|---|---|---|---|
read + write | 4(2 DMA + 2 CPU) | 2 | 4 | 是 | 朴素写法 |
mmap + write | 3(2 DMA + 1 CPU) | 1 | 4 | 否(映射) | 小文件、需随机访问 |
sendfile | 3(2 DMA + 1 CPU) | 1 | 2 | 否 | 静态文件发送 |
sendfile + SG-DMA | 2(2 DMA) | 0 | 2 | 否 | 高性能文件服务 |
splice | 2(2 DMA) | 0 | 2 | 否 | 代理转发 |
现实落地:Kafka 用 sendfile 把日志段高效发给消费者;Nginx 静态文件用 sendfile;Netty 提供 FileRegion(基于 sendfile)做零拷贝传输——都是这套红利的直接受益者。
六、Reactor 与 Proactor 模式
Q20. Reactor 与 Proactor 的区别?
答案
| 维度 | Reactor | Proactor |
|---|---|---|
| 事件语义 | 就绪(readable/writable) | 完成(读/写已完成) |
| 谁来读写 | 应用自己 | 内核完成,应用只处理结果 |
| IO 类型 | 同步 IO(多路复用) | 异步 IO |
| 依赖 | select/poll/epoll | io_uring / Windows IOCP |
| 编程复杂度 | 较低,生态成熟 | 较高,但真正异步 |
Reactor 是「有数据了,你来读」;Proactor 是「我读好了,给你用」。
Q21. Reactor 的三种经典实现?
答案
- 单 Reactor 单线程:一个线程包办
accept+ 读写 + 业务。实现最简单(Redis 就接近这种单线程事件循环),但任一环节阻塞都会拖垮全局,无法利用多核。 - 单 Reactor 多线程:Reactor 线程只负责
accept与读写事件分发,业务逻辑丢给线程池。提高了并发处理能力,但单 Reactor 仍可能成为瓶颈,且多线程共享资源需同步。 - 主从 Reactor 多线程:
mainReactor只负责accept并把新连接分给subReactor;subReactor负责读写事件;业务再由线程池处理。Netty 的主流模型,能充分利用多核,是高并发服务器的通用范式。
(进一步还有多 Reactor 多进程:Nginx 的 master/worker 模型,master 管理、多个 worker 各自跑事件循环。)
七、高频 Q&A
Q22. 「一个线程能同时处理多个请求吗」?怎么做到?
答案
能。靠 IO 多路复用 + 事件驱动(Reactor):把多个连接注册到 epoll,用一个线程在 epoll_wait 上等待;哪个就绪处理哪个,处理完接着等。前提是业务不能长时间阻塞(否则卡住整个循环),耗时操作要丢给线程池或拆成异步任务。Redis 单线程却能扛高并发,正是因为它把每个命令都做得极快且不阻塞。
Q23. epoll 一定比 select 快吗?
答案
不一定。当连接数很少、而且几乎都活跃时,epoll 的红黑树 / 回调等机制反而有额外开销,select/poll 可能更简单更快。epoll 的优势场景是大量连接、少量活跃(如数万空闲长连接)。离开场景谈性能都是耍流氓。
Q24. 什么情况下 write 返回了但数据没到对端?
答案
write 成功只表示数据被放进了内核 socket 发送缓冲区(拷贝阶段),并不代表对方已收到,更不代表已落盘。真正的送达要靠 TCP 的确认;发文件持久化则要 fsync(见文件系统页)。这也是「write 不等于 send、更不等于「对方已读」」的经典澄清。
Q25. io_uring 相比 epoll 强在哪?
答案
epoll 是就绪通知(同步 IO 的等待阶段优化),数据还得应用自己 read/write。io_uring 是真正的异步 IO:通过提交队列(SQ)与完成队列(CQ)共享内存环形缓冲,应用把请求塞进 SQ、内核完成后把结果放回 CQ,批量提交、尽量少系统调用,且磁盘与网络统一。它把「等待 + 拷贝」都交给内核,是当前 Linux 高性能 IO 的前沿方向。
相关
- 操作系统专题总览 —— 考频与阅读顺序
- 文件系统与 Page Cache:
fsync与落盘语义 - 进程与线程:阻塞 / 唤醒与上下文切换
- 进程调度:IO 密集型进程的调度
- 同步原语:事件循环与线程池的协作
- C++ 并发与多线程:线程池、Reactor 的 C++ 实现
- CPU 微架构:DMA、缓存与内存带宽对拷贝成本的影响