Skip to content

IO 模型:多路复用、epoll 与零拷贝 ​

一句话总览:一次「读数据」都要经历 ① 等待数据就绪 和 ② 从内核拷贝到用户态 两个阶段。五种 IO 模型的差别,只在于这两阶段谁来等、怎么等;epoll 优化的是「等待」,零拷贝优化的是「拷贝」。这两条主线撑起了高并发服务器(Nginx / Redis / Kafka / Netty)的全部性能故事。

一、IO 的本质 ​

Q1. 一次 read 到底经历了什么? ​

答案

以读磁盘/网卡为例,read(fd, buf, n) 分两个阶段:

  1. 等待数据就绪:数据先从设备到达内核缓冲区(内核要把数据准备好)。这一步线程是否阻塞,是区分 IO 模型的关键。
  2. 数据拷贝:把内核缓冲区里的数据拷贝到用户缓冲区 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) 阻塞等待。
  • 限制与缺陷:
    1. fd 上限:FD_SETSIZE 通常 1024,无法支撑 C10K+;
    2. 每次调用都要把整个 fd 集合从用户态拷贝到内核态,fd 多了拷贝开销大;
    3. 内核对所有 fd 线性遍历 判断就绪,O(n);
    4. 返回后只告诉你「有几个就绪」,要全量再遍历一遍找出是谁,仍是 O(n);
    5. fd_set 会被内核修改,下次调用前要重新设置;
    6. 超时精度较低(微秒),且是水平触发。

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 内部三个关键结构:

  1. 红黑树:管理所有被监听的 fd(epitem),增删改 epoll_ctl 为 O(log n);
  2. 就绪链表(rdllist):只挂当前就绪的 epitem;
  3. 等待队列(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 对比? ​

答案
维度selectpollepoll
数据结构fd_set 位图pollfd 数组红黑树 + 就绪链表
fd 上限1024(FD_SETSIZE)无硬上限无硬上限
是否每次拷贝全量是是否(epoll_ctl 时一次)
内核查找就绪 fdO(n) 轮询O(n) 轮询O(1) 取就绪链表
返回后是否需全量扫描是是否(直接给就绪列表)
触发模式LTLTLT / 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 + write4(2 DMA + 2 CPU)24是朴素写法
mmap + write3(2 DMA + 1 CPU)14否(映射)小文件、需随机访问
sendfile3(2 DMA + 1 CPU)12否静态文件发送
sendfile + SG-DMA2(2 DMA)02否高性能文件服务
splice2(2 DMA)02否代理转发

现实落地:Kafka 用 sendfile 把日志段高效发给消费者;Nginx 静态文件用 sendfile;Netty 提供 FileRegion(基于 sendfile)做零拷贝传输——都是这套红利的直接受益者。

六、Reactor 与 Proactor 模式 ​

Q20. Reactor 与 Proactor 的区别? ​

答案
维度ReactorProactor
事件语义就绪(readable/writable)完成(读/写已完成)
谁来读写应用自己内核完成,应用只处理结果
IO 类型同步 IO(多路复用)异步 IO
依赖select/poll/epollio_uring / Windows IOCP
编程复杂度较低,生态成熟较高,但真正异步

Reactor 是「有数据了,你来读」;Proactor 是「我读好了,给你用」。

Q21. Reactor 的三种经典实现? ​

答案
  1. 单 Reactor 单线程:一个线程包办 accept + 读写 + 业务。实现最简单(Redis 就接近这种单线程事件循环),但任一环节阻塞都会拖垮全局,无法利用多核。
  2. 单 Reactor 多线程:Reactor 线程只负责 accept 与读写事件分发,业务逻辑丢给线程池。提高了并发处理能力,但单 Reactor 仍可能成为瓶颈,且多线程共享资源需同步。
  3. 主从 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 的前沿方向。

相关 ​

糌粑的面经笔记 · VitePress 版