跳到主要内容
预计阅读 32 分钟

07|IO系统

网络编程中最核心的问题不是”怎么写”,而是”怎么等”。阻塞、非阻塞、多路复用、异步——四种 I/O 模型代表了四种不同的等待哲学。本章将带你深入理解 select/poll/epoll 的原理,以及零拷贝技术如何消除不必要的数据搬运。

📋 开篇自测:你已经知道多少?

  1. 阻塞 I/O 和非阻塞 I/O 的核心区别是什么?
  2. select、poll、epoll 各自的优缺点是什么?为什么 epoll 在高并发场景下性能远超前两者?
  3. 什么是零拷贝?它解决了传统文件传输中的什么问题?

一、理解 I/O 的本质

1.1 一次网络读取的完整过程

当你的程序调用 read(socket_fd, buf, size) 从网络读取数据时,实际上经历了两个阶段:

阶段1: 等待数据准备好
  网卡收到数据包
      |
      v
  数据被 DMA 复制到内核缓冲区
      |
      v
  协议栈处理 (TCP解包等)
      |
      v
  数据在内核缓冲区准备就绪

阶段2: 将数据从内核复制到用户空间
  从内核缓冲区
      |
      | copy_to_user()
      v
  复制到用户空间的 buf

四种 I/O 模型的区别,就在于程序在这两个阶段中的等待方式不同。

1.2 四种 I/O 模型概览

                    阶段1(等待数据)     阶段2(复制数据)

阻塞I/O:            阻塞等待           阻塞等待
                    [====等====]      [==复制==]

非阻塞I/O:          反复轮询           阻塞等待
                    [查][查][查]      [==复制==]

I/O多路复用:        select/poll/epoll  阻塞等待
                    阻塞在监控函数上    [==复制==]

异步I/O:            不等待             不等待
                    [立即返回]         [内核完成后通知]

二、阻塞 I/O 与非阻塞 I/O

2.1 阻塞 I/O(Blocking I/O)

最简单直观的模型——调用 read() 后线程就挂起了,直到数据准备好并复制到用户缓冲区才返回。

用户线程                    内核
    |                        |
    | read(fd, buf, size)    |
    |----------------------->|
    |                        | 等待数据到达...
    | [阻塞中, 干不了别的事]   | ...
    |                        | 数据到达!
    |                        | 复制到用户缓冲区
    |<-----------------------|
    | 返回数据                |
    v                        |

阻塞 I/O 的最大问题:一个线程只能服务一个连接。如果要同时处理 1000 个连接,就需要 1000 个线程——资源浪费严重。

2.2 非阻塞 I/O(Non-blocking I/O)

将文件描述符设为非阻塞模式后,如果数据没准备好,read() 会立即返回 -1(errno=EAGAIN),而不是阻塞等待。

用户线程                    内核
    |                        |
    | read(fd, buf, size)    |
    |----------------------->|
    |<----- EAGAIN ----------| 数据未就绪
    |                        |
    | read(fd, buf, size)    |
    |----------------------->|
    |<----- EAGAIN ----------| 数据仍未就绪
    |                        |
    | read(fd, buf, size)    |
    |----------------------->|
    |                        | 数据到达!
    |                        | 复制到用户缓冲区
    |<----- 返回数据 ---------|
    v                        |
// 设置非阻塞
int flags = fcntl(fd, F_GETFL, 0);
fcntl(fd, F_SETFL, flags | O_NONBLOCK);

// 非阻塞读取
while (1) {
    ssize_t n = read(fd, buf, sizeof(buf));
    if (n > 0) {
        process(buf, n);  // 处理数据
        break;
    } else if (n == -1 && errno == EAGAIN) {
        // 数据未就绪,可以做其他事
        do_something_else();
    } else {
        // 错误或连接关闭
        break;
    }
}

非阻塞 I/O 的问题:需要不断轮询,浪费 CPU。如果轮询间隔太大,响应延迟增加;间隔太小,CPU 空转。

🤔 想一想 非阻塞 I/O 比阻塞 I/O 好在哪里?它的轮询问题有什么更好的解决方案?


三、I/O 多路复用

3.1 核心思想

I/O 多路复用的核心思想是:用一个线程同时监控多个文件描述符,哪个就绪了就处理哪个。

传统阻塞模型:              I/O 多路复用模型:
  线程1 ---> 连接1           一个线程
  线程2 ---> 连接2              |
  线程3 ---> 连接3           select/poll/epoll
  线程4 ---> 连接4              |
  ...                    +---+---+---+---+
  线程N ---> 连接N        连接1 连接2 连接3 ... 连接N

  N个线程                  1个线程监控N个连接

3.2 select

select 是最古老的 I/O 多路复用机制(1983 年出现在 4.2BSD):

int select(int nfds, fd_set *readfds, fd_set *writefds,
           fd_set *exceptfds, struct timeval *timeout);
select 工作原理:

用户空间:
  1. 创建 fd_set 位图, 标记要监控的 fd
     fd_set readfds = {fd=3, fd=5, fd=8}
     位图: 00010100100... (第3、5、8位为1)

  2. 调用 select(), 将位图复制到内核

内核空间:
  3. 遍历所有标记的 fd, 检查是否就绪
     fd=3 就绪? 否
     fd=5 就绪? 是!
     fd=8 就绪? 否

  4. 修改位图, 只保留就绪的 fd
     位图: 00000100000... (只有第5位为1)

  5. 将修改后的位图复制回用户空间

用户空间:
  6. 遍历位图, 找到就绪的 fd
  7. 对就绪的 fd 执行 read/write

select 的局限

  • fd_set 有大小限制(通常 1024 个 fd)
  • 每次调用都需要把整个 fd_set 从用户空间复制到内核空间
  • 内核需要线性遍历所有 fd 检查就绪状态
  • 返回后用户程序也需要线性遍历找出就绪的 fd

3.3 poll

poll 解决了 select 的 fd 数量限制,使用动态大小的 pollfd 数组代替固定大小的位图:

struct pollfd {
    int fd;        // 文件描述符
    short events;  // 监控的事件 (POLLIN, POLLOUT等)
    short revents; // 就绪的事件 (由内核填写)
};

int poll(struct pollfd *fds, nfds_t nfds, int timeout);

poll 消除了 1024 的限制,但核心问题没变——每次调用仍然需要线性遍历所有 fd。当监控的连接数达到数万时,性能急剧下降。

3.4 epoll

epoll 是 Linux 2.6 引入的高性能 I/O 多路复用机制,专为高并发场景设计。

// 三个核心 API:
int epoll_create(int size);    // 创建 epoll 实例
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
                               // 添加/修改/删除监控的 fd
int epoll_wait(int epfd, struct epoll_event *events,
               int maxevents, int timeout);
                               // 等待就绪事件
epoll 工作原理:

1. epoll_create() 创建 epoll 实例
   在内核中创建一棵红黑树 + 就绪链表

2. epoll_ctl() 添加/删除监控的 fd
   +-- 红黑树 (存储所有监控的fd) --+
   |                              |
   |     (fd=3)                   |
   |    /      \                  |
   | (fd=1)  (fd=5)              |
   |           \                  |
   |          (fd=8)             |
   +------------------------------+

3. 当某个 fd 就绪时 (数据到达)
   内核通过回调函数将该 fd 加入就绪链表

   就绪链表: [fd=5] -> [fd=8] -> NULL

4. epoll_wait() 直接返回就绪链表
   不需要遍历所有fd!
   O(就绪fd数量) 而非 O(总fd数量)

3.5 三者对比

+---------------+-----------+-----------+-------------+
|               | select    | poll      | epoll       |
+---------------+-----------+-----------+-------------+
| 最大fd数       | 1024      | 无限制    | 无限制       |
| fd传递方式     | 每次拷贝   | 每次拷贝  | 注册一次     |
| 就绪检测       | 遍历全部   | 遍历全部  | 回调通知     |
| 时间复杂度     | O(n)      | O(n)     | O(就绪数)    |
| 触发模式       | 水平触发   | 水平触发  | LT + ET     |
| 内核实现       | 位图       | 数组     | 红黑树+链表   |
+---------------+-----------+-----------+-------------+

性能对比 (10000个连接, 100个活跃):
  select: 遍历 10000 个fd --> 找到 100 个就绪
  epoll:  直接返回 100 个就绪fd
  差距: 100倍!

3.6 水平触发 vs 边缘触发

水平触发 (Level Triggered, LT):
  只要 fd 可读/可写,epoll_wait 每次调用都会通知
  好处: 不会遗漏数据
  坏处: 如果不处理,会反复通知 (可能浪费CPU)

  事件: [====数据可读====]
  通知: ^    ^    ^    ^    (每次 epoll_wait 都通知)

边缘触发 (Edge Triggered, ET):
  只在状态变化时通知一次 (从不可读变为可读)
  好处: 效率高,通知次数少
  坏处: 必须一次性读完所有数据,否则可能遗漏

  事件: [====数据可读====]
  通知: ^                   (只通知一次!)

  ET 模式的要求:
  - fd 必须是非阻塞的
  - 必须循环 read() 直到返回 EAGAIN

四、异步 I/O

4.1 真正的异步

前面三种模型(阻塞、非阻塞、多路复用)在”数据从内核复制到用户空间”这一步都是同步的——用户线程都必须等待复制完成。

异步 I/O(AIO) 是唯一真正异步的模型:发起 I/O 请求后立即返回,连数据复制这一步都由内核完成,完成后通过回调或信号通知用户程序。

用户线程                    内核
    |                        |
    | aio_read(fd, buf)      |
    |----------------------->|
    |<-- 立即返回 ------------|
    |                        | 等待数据...
    | 做其他事情...           | 数据到达
    |                        | 复制到用户buf
    |                        | (用户线程不参与!)
    |<-- 信号/回调通知 -------|
    | 处理数据                |
    v                        |

4.2 Linux 的 io_uring

传统 Linux AIO(libaio)有很多限制(只支持直接 I/O、不支持 buffered I/O、不支持网络 I/O)。Linux 5.1 引入了 io_uring,彻底解决了这些问题:

io_uring 的核心设计:

用户空间和内核空间共享两个环形队列:

  提交队列 (SQ - Submission Queue):
  用户程序 --> [请求1] [请求2] [请求3] --> 内核处理

  完成队列 (CQ - Completion Queue):
  内核 --> [结果1] [结果2] [结果3] --> 用户程序读取

  +--------+                    +--------+
  |        |  SQ (共享内存)      |        |
  | 用户   | ==================> | 内核   |
  | 程序   |                    |        |
  |        | <================== |        |
  |        |  CQ (共享内存)      |        |
  +--------+                    +--------+

  关键优势:
  - 提交和获取结果都无需系统调用 (零拷贝)
  - 支持批量提交
  - 支持所有类型的I/O (文件、网络、定时器等)

五、零拷贝(Zero Copy)

5.1 传统文件传输的问题

发送一个文件到网络(如 Web 服务器发送静态文件)时,传统方式需要多次数据复制:

传统方式: read() + write()

  用户调用 read(file_fd, buf, size):
    1. DMA 拷贝: 磁盘 --> 内核页面缓存     (第1次拷贝)
    2. CPU 拷贝: 内核页面缓存 --> 用户缓冲区  (第2次拷贝)

  用户调用 write(socket_fd, buf, size):
    3. CPU 拷贝: 用户缓冲区 --> socket内核缓冲区 (第3次拷贝)
    4. DMA 拷贝: socket缓冲区 --> 网卡          (第4次拷贝)

  总计: 4次拷贝 + 4次上下文切换

  磁盘 --DMA--> 内核缓存 --CPU--> 用户buf --CPU--> Socket缓存 --DMA--> 网卡
           1              2               3                4

数据从内核复制到用户空间,又立刻原封不动地复制回内核空间——这两步完全是浪费。

5.2 sendfile 零拷贝

ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);
sendfile 方式:

  1. DMA 拷贝: 磁盘 --> 内核页面缓存        (第1次拷贝)
  2. CPU 拷贝: 页面缓存 --> Socket缓冲区     (第2次拷贝)
  3. DMA 拷贝: Socket缓冲区 --> 网卡         (第3次拷贝)

  总计: 3次拷贝 + 2次上下文切换
  数据完全不需要经过用户空间!

  磁盘 --DMA--> 内核缓存 --CPU--> Socket缓存 --DMA--> 网卡
           1              2                3

5.3 带 DMA 收集的 sendfile

如果网卡支持 scatter-gather DMA,还可以进一步优化:

带 scatter-gather 的 sendfile:

  1. DMA 拷贝: 磁盘 --> 内核页面缓存           (第1次拷贝)
  2. 只传递描述符: 页面缓存位置和长度 --> Socket  (无数据拷贝!)
  3. DMA 收集: 直接从页面缓存 --> 网卡           (第2次拷贝)

  总计: 2次 DMA 拷贝 + 0次 CPU 拷贝!

  磁盘 --DMA--> 内核缓存 ----DMA收集----> 网卡
           1                        2
         (真正的零CPU拷贝!)

5.4 mmap + write

另一种零拷贝方案,使用 mmap 将文件映射到用户地址空间:

mmap + write:

  1. mmap() 映射: 磁盘文件映射到用户空间
     (用户空间和内核空间共享同一份物理页面)
  2. write() 时: 直接从映射区域发送

  磁盘 --DMA--> 内核缓存(=用户映射) --CPU--> Socket缓存 --DMA--> 网卡
           1                              2                3

  总计: 3次拷贝,但减少了一次CPU拷贝

5.5 零拷贝技术对比

+-------------------+----------+----------+-----------+
|                   | CPU拷贝  | DMA拷贝  | 上下文切换 |
+-------------------+----------+----------+-----------+
| read + write      |    2     |    2     |    4      |
| mmap + write      |    1     |    2     |    4      |
| sendfile          |    1     |    2     |    2      |
| sendfile + SG-DMA |    0     |    2     |    2      |
| splice            |    0     |    2     |    2      |
+-------------------+----------+----------+-----------+

使用场景:
  Nginx: sendfile on;
  Kafka: 使用 sendfile 发送消息
  RocketMQ: 使用 mmap 映射消息文件

🤔 想一想 为什么 Kafka 能够以极高的吞吐量发送消息?零拷贝在其中扮演了什么角色?


六、Reactor 与 Proactor 模式

实际的高性能服务器通常基于 I/O 多路复用构建事件驱动架构:

6.1 Reactor 模式

单 Reactor 单线程 (Redis 6.0 之前的经典模式):

  +---------------------------------------------------+
  |                    Reactor                         |
  |  epoll_wait()                                     |
  |       |                                           |
  |  +----+----+----+----+                            |
  |  | accept | read |write| timer |                  |
  |  +--------+------+-----+-------+                  |
  |       |                                           |
  |  事件分发 --> 处理函数                              |
  +---------------------------------------------------+

主从 Reactor 多线程 (Nginx/Netty 模式):

  主 Reactor (一个线程):
    只负责 accept 新连接
        |
        | 将新连接分配给
        v
  从 Reactor 1        从 Reactor 2       从 Reactor 3
  (Worker线程1)       (Worker线程2)      (Worker线程3)
  负责该连接的        负责该连接的       负责该连接的
  所有读写事件        所有读写事件       所有读写事件

注意:Redis 6.0+ 引入了 I/O 多线程——网络读写(解析请求和发送响应)可以在多个线程中并行执行,但命令执行仍然是单线程的。该特性默认关闭,需要在配置中设置 io-threads 参数手动开启,同时还需设置 io-threads-do-reads yes 才能开启读操作的多线程。因此严格来说,开启后的 Redis 6.0+ 不再是纯粹的”单 Reactor 单线程”模式。

6.2 Proactor 模式

Proactor (基于异步I/O):

  1. 应用程序发起异步读操作
  2. 操作系统完成I/O后通知应用
  3. 应用直接处理已经准备好的数据

  适用: Windows IOCP, Linux io_uring
  区别: Reactor 是"通知你可以读了"
       Proactor 是"已经帮你读好了"

七、本章总结

+----------------------------------------------------+
|                  IO 系统核心知识                      |
+----------------------------------------------------+
|                                                    |
|  四种 I/O 模型:                                     |
|    阻塞:     简单但浪费线程                          |
|    非阻塞:   需要轮询, 浪费CPU                       |
|    多路复用: 一个线程监控多个fd                       |
|    异步:     真正不等待, 最高效                       |
|                                                    |
|  I/O 多路复用演进:                                   |
|    select (O(n), 1024限制)                          |
|    -> poll (O(n), 无限制)                           |
|    -> epoll (O(就绪数), 红黑树+回调)                 |
|                                                    |
|  零拷贝:                                            |
|    read+write: 4次拷贝                              |
|    sendfile:   2-3次拷贝                            |
|    sendfile+SG: 2次DMA, 0次CPU拷贝                  |
|                                                    |
|  服务器模式:                                         |
|    Reactor: 事件通知 (epoll)                        |
|    Proactor: 完成通知 (io_uring)                    |
|                                                    |
+----------------------------------------------------+

📝 结尾自测:检验你的收获

  1. 画出四种 I/O 模型的时间线对比图,标注每种模型的阻塞点。
  2. epoll 相比 select 的三个核心改进是什么?
  3. 水平触发和边缘触发的区别是什么?边缘触发模式有什么使用要求?
  4. 传统 read+write 发送文件需要几次拷贝和几次上下文切换?sendfile 如何优化?
  5. Reactor 模式和 Proactor 模式的核心区别是什么?各自适合什么场景?

购买课程解锁全部内容

系统底层入门:10 章掌握操作系统核心

¥29.90