Appearance
I/O 多路复用
I/O 多路复用让一个线程等待多个文件描述符(socket、管道等)的就绪事件;有事件时再处理相应 I/O。它适合大量连接但每个连接大部分时间空闲的网络服务器。
“就绪”通常表示读/写操作可能不阻塞,不等于一定读到完整应用消息。非阻塞 I/O、缓冲区和协议解析仍不可少。
常见 Unix I/O 模型
先区分“等待方式”和“数据从内核拷贝到应用缓冲区的时机”。select/poll/epoll 通知的是就绪,不是“内核已经替你完成了 recv”。
| 模型 | 应用如何等待 | 应用何时取得数据 | 典型边界 |
|---|---|---|---|
| 阻塞 I/O | read/recv/accept 直接睡眠等待 | 调用返回时 | 代码直观;一个线程一次通常只能等待一个阻塞操作 |
| 非阻塞 I/O | 调用立即返回;无数据为 EAGAIN/EWOULDBLOCK | 某次调用实际读到数据时 | 反复轮询会空转耗 CPU,通常配合就绪通知 |
| I/O 多路复用 | select/poll/epoll 等待任一 fd 就绪 | 通知后应用再 recv/send | 一个线程管理多连接;本质仍是同步读写 |
| 信号驱动 I/O | 设置异步通知,例如 SIGIO | 收到通知后应用再读写 | 信号处理和竞争复杂,现代服务器较少直接使用 |
| 异步 I/O / 完成通知 | 提交具体 I/O 请求 | 内核用完成事件告知结果 | 应用不必为每次操作主动执行阻塞 read;语义和支持范围依 API/内核而异 |
Linux 的 io_uring 提供完成队列接口,能批量提交网络和文件 I/O;但不能把它背成“每个操作都必然由硬件真异步完成、且没有线程”。特定操作可能由内核 worker 执行或回退,必须处理完成乱序、取消竞态和缓冲区生命周期。详细机制见 网络服务器 I/O。
select、poll、epoll 对比
| 特性 | select | poll | epoll(Linux) |
|---|---|---|---|
| 监听集合 | 位图 fd_set | pollfd 数组 | 内核维护兴趣集合 |
| FD 数量 | 常有 FD_SETSIZE 上限 | 无 fd_set 固定上限 | 受系统资源限制 |
| 每次调用 | 用户态传入并扫描整个集合;结果也需扫描 | 用户态传入并扫描整个数组 | epoll_ctl 注册/修改;epoll_wait 返回就绪事件 |
| 大量空闲连接 | 扫描成本 O(n) | 扫描成本 O(n) | 通常更适合,返回活跃事件 |
| 可移植性 | POSIX,广泛支持 | POSIX,广泛支持 | Linux 特有 |
epoll 的优势不意味着所有负载下都必然更快:连接少、绝大部分连接都很活跃或事件处理成本主导时,差异可能不明显。
epoll 使用要点
- 创建
epoll实例; - 把监听 socket 和连接 socket 通过
epoll_ctl注册EPOLLIN、EPOLLOUT等兴趣事件; - 用
epoll_wait阻塞等待就绪事件; - 对返回事件执行 accept/read/write,并维护每个连接的状态机和缓冲区。
LT 与 ET
- 水平触发(LT):只要 FD 仍可读/可写就持续通知,使用简单,是默认模式。
- 边缘触发(ET):仅在状态从未就绪变为就绪时通知,减少重复通知;一般必须配合非阻塞 FD,并循环读/写直到返回
EAGAIN,否则可能遗漏后续处理机会。
epoll 是就绪通知机制,不是“自动回调框架”;程序仍需正确处理部分读写、连接关闭、错误、背压和任务调度。
Reactor:事件循环不等于业务全在一个线程
Reactor 的职责是等待并分发连接、可读、可写和定时器等事件:
text
非阻塞 socket + epoll_wait
↓
Reactor:accept / recv / 协议拆包 / 维护发送队列
├─ 极短业务:当前 I/O worker 直接处理
└─ CPU/阻塞任务:投递至有界工作队列
↓
结果回投原 I/O worker,再 send一个 Reactor 可处理多个连接;多个 Reactor/worker 则可按连接哈希固定归属,减少跨线程锁竞争。无论模型如何,TCP 半包/粘包、部分写、发送队列高水位、慢客户端、超时和背压都必须由应用状态机处理。完整服务器骨架、连接分片与过载策略见 网络服务器 I/O。
文件传输中的零拷贝
“零拷贝”不是绝对没有任何数据移动,而是尽量避免把数据从内核缓冲区复制到用户态应用缓冲区后又复制回内核,从而减少 CPU 拷贝与系统调用。
传统“读文件再发 socket”的典型路径:
text
磁盘 --DMA--> 内核 page cache --CPU copy--> 用户缓冲区
用户缓冲区 --CPU copy--> socket 内核缓冲区 --DMA--> 网卡read + write 通常需要两次系统调用(进入/退出内核共四次边界转换),并包含两次 CPU 缓冲区复制。sendfile 让内核直接把文件页交给 socket 发送路径,避免用户缓冲区往返:在支持 scatter-gather/DMA 的路径上,CPU 甚至不必复制文件载荷;在不支持的内核/设备路径上仍可能存在内核内部复制。
| 技术 | 典型用途 | 不能省略的边界 |
|---|---|---|
sendfile | 静态文件 → TCP socket | 适用于无需让应用修改文件内容的转发;仍有 DMA、协议处理和背压 |
splice | fd → pipe → fd 的内核管道转移 | 适合管道式转发;可读性和平台兼容性需评估 |
mmap | 文件映射后由应用随机读取/处理 | 减少显式 read 拷贝,但会引入缺页、映射和访问模式问题 |
io_uring SEND_ZC 等 | 高性能网络发送 | 请求完成/通知前不能复用缓冲区,且依内核和设备支持 |
零拷贝最适合大文件下载、代理转发等“读取后不修改载荷”的路径;若要解析、压缩、加密或转换内容,数据仍需要由 CPU 访问。不要承诺“性能一定翻倍”,应压测吞吐、CPU、尾延迟、文件大小、缓存命中率和网卡能力。
适用边界
I/O 多路复用解决的是等待大量 I/O 的问题。若单个请求包含 CPU 密集计算,应交给线程池、进程池或异步任务系统,避免阻塞事件循环。跨平台可选 kqueue(BSD/macOS)、IOCP(Windows)或封装库(如 libuv、Asio)。
select、poll、epoll 都属于就绪通知:内核告诉应用“现在调用 I/O 可能不阻塞”,应用再执行 accept/recv/send。Linux 的 io_uring 更接近完成通知:应用先提交具体 I/O 请求,内核完成后把结果放入完成队列。两种模型的服务器事件循环、背压、连接分片和工程取舍,见 网络服务器 I/O。