07|IO系统
网络编程中最核心的问题不是”怎么写”,而是”怎么等”。阻塞、非阻塞、多路复用、异步——四种 I/O 模型代表了四种不同的等待哲学。本章将带你深入理解 select/poll/epoll 的原理,以及零拷贝技术如何消除不必要的数据搬运。
📋 开篇自测:你已经知道多少?
- 阻塞 I/O 和非阻塞 I/O 的核心区别是什么?
- select、poll、epoll 各自的优缺点是什么?为什么 epoll 在高并发场景下性能远超前两者?
- 什么是零拷贝?它解决了传统文件传输中的什么问题?
一、理解 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) |
| |
+----------------------------------------------------+
📝 结尾自测:检验你的收获
- 画出四种 I/O 模型的时间线对比图,标注每种模型的阻塞点。
- epoll 相比 select 的三个核心改进是什么?
- 水平触发和边缘触发的区别是什么?边缘触发模式有什么使用要求?
- 传统 read+write 发送文件需要几次拷贝和几次上下文切换?sendfile 如何优化?
- Reactor 模式和 Proactor 模式的核心区别是什么?各自适合什么场景?
购买课程解锁全部内容
系统底层入门:10 章掌握操作系统核心
¥29.90