这篇文章面向"想把 I/O 讲透的人":不止会用 epoll,还要能解释为什么它在内核里更省 、就绪到底是什么意思 、为什么会出现 EAGAIN/短读写 、以及 io_uring 到底比 epoll 多做了什么。
我会按时间线讲:每一代技术都源自一个非常具体的性能/工程痛点。
0. 开场:I/O 的本质矛盾(内核到底在帮你等什么)
在 Linux 里,I/O 大体有两步:
- 等待条件成立(比如 socket 接收队列有数据、发送缓冲区有空间、监听 socket 有新连接、文件页缓存就绪等)
- 数据搬运/完成(copy_to_user / copy_from_user、或 DMA 完成、更新状态,返回结果)
很多所谓 I/O"模型"的区别,其实就在于:
- 谁来等待(用户线程忙等?睡眠等?内核等?)
- 等待的是"就绪"还是"完成"
- 谁来驱动下一步(用户态循环?内核回调?完成队列?)
把这三个问题抓住,你就能看清全局。
1. 史前时代:阻塞 I/O(Blocking)------"最省 CPU,但最容易把线程用光"
1.1 发生了什么
线程调用 read(fd):
- 如果条件不满足(比如 socket 接收队列为空),线程进入内核,挂到对应的 wait queue 上睡眠(可中断/不可中断视情况)
- 等到网络包到来、协议栈把数据放进 socket 接收队列,内核
wake_up相关 waiters - 线程被唤醒,继续执行,拷贝数据到用户缓冲区,返回
1.2 性能画像
优点(内核视角):
- 没有空转,CPU 利用率很"干净"
- 逻辑简单、局部性好
瓶颈(系统视角):
- 高并发下如果采用"每连接一线程",调度成本、内存(线程栈)、TLB/Cache 抖动迅速变成主要瓶颈
- tail latency 也会被调度/争用放大
1.3 现实痛点 → 下一代
痛点:我想服务 10k
100k 连接,但我不想要 10k100k 线程。于是人们想:能不能少线程、甚至单线程扛住?
2. 第二阶段:非阻塞 I/O(Nonblocking)------"线程不睡了,但 CPU 开始空转"
2.1 语义改变
把 fd 设为 O_NONBLOCK 后:
read()若无数据:立刻返回-1,errno=EAGAIN/EWOULDBLOCKaccept()无连接:同样 EAGAINwrite()写不进去(发送缓冲区满):EAGAIN 或短写
关键点:非阻塞只定义了"单个系统调用"在条件不满足时不睡眠。它并不告诉你"什么时候再来试"。
2.2 性能画像
如果你没有更好的等待机制,就只能:
- 轮询所有连接:
read()-> EAGAIN -> 下一个 - 这会造成大量无效 syscalls + 遍历开销
- CPU 利用率上升,但吞吐不一定上升(更像在"烧 CPU 买延迟")
2.3 现实痛点 → 下一代
痛点:我不想阻塞,但我也不想忙等。我要一种"可睡眠的等待",还能一次等很多 fd。
这就引出了"多路复用"(select/poll/epoll)。
3. 第三阶段:I/O 多路复用(select/poll)------"一次等很多,但每次都要全量搬名单、全量点名"
多路复用解决的是"等待"问题:把"等很多 fd"这件事交给内核。
3.1 select/poll 内核视角的成本来自哪
它们的共同点(简化说):
- 每次调用都要把关注集合从用户态拷贝到内核态(select 是位图、poll 是数组)
- 内核要遍历集合,检查每个 fd 是否就绪
- 返回后用户态还要再遍历一次找就绪项(select/poll API 形态决定的)
于是成本天然接近 O(N) (N 是监听 fd 数量),并且每轮都有拷贝成本。
3.2 现实痛点 → epoll
痛点:当 N 很大(比如 50k 连接),即便每次只有几十个活跃连接,也要每轮扫描/拷贝 50k。
这会让"空闲连接的规模"成为系统成本。
所以需要一种机制:别每次都全量传名单;别每次都全量扫描;只把就绪的返回给我。
4. 第四阶段:epoll ------"把'名单'变成注册表,把'点名'变成就绪队列"
epoll 的核心突破在于:关注集合被持久化在内核里,而不是每次调用临时传入。
4.1 两段式 API 反映的内核设计
epoll_ctl(ADD/MOD/DEL):维护一个"关注集合"(interest set)epoll_wait():等待事件发生,返回就绪列表(ready list)
这意味着:
- fd 列表的维护成本从"每轮"移到了"变更时"
- 返回的是"就绪项",而不是让你扫描全部
4.2 就绪到底是什么(非常关键,别把它当"信号保证")
"就绪(readable/writable)"不是"保证 read/write 一定成功"的绝对承诺,它更接近:
- readable:读操作"不会无限期阻塞"(可能读到数据、读到 EOF、或读到错误)
- writable:写操作"可能写入一些数据而不阻塞"(但能写多少不保证)
因此你会看到这些看似反直觉但完全正常的现象:
- epoll 报告可读,你
read()仍然可能得到EAGAIN
原因:竞态(多线程/多进程)、边沿触发没读干净、状态变化等 - epoll 报告可写,你
write()可能短写
原因:发送缓冲区空间有限、拥塞控制、MSS/TSO 等因素
结论:
在性能正确的事件循环里,你要接受"短读/短写/EAGAIN 是常态",并用循环与状态机处理它们。
4.3 LT vs ET:性能与正确性的分水岭
-
LT(电平触发) :只要条件仍成立(比如接收队列非空),就持续通知
更不容易写错,因为"没读完下次还会提醒你"
-
ET(边沿触发) :只在状态从"不就绪→就绪"时通知一次
性能潜力更高(减少重复通知),但要求:
- fd 必须 nonblocking(几乎是事实上的要求)
- 读:必须循环读到
EAGAIN(把内核缓冲区"榨干") - 写:通常也要循环写到
EAGAIN,并在写不动时再关注 EPOLLOUT
ET 写错的典型症状:
- 连接"卡住":缓冲区里还有数据,但你没读干净;之后因为没有新边沿,不再通知,业务以为没数据了
4.4 epoll 不是银弹:几个内核/性能坑位
-
惊群(thundering herd)
多线程/多进程等待同一监听 socket 或同一 epoll 事件源会导致大量无效唤醒。
- accept 惊群历史上很典型;现代内核与
EPOLLEXCLUSIVE等机制缓解,但设计上仍要谨慎。
- accept 惊群历史上很典型;现代内核与
-
事件循环里的"阻塞点"
一旦你在回调里做了阻塞操作(磁盘 IO、DNS、锁争用、同步日志),整个 Reactor 延迟会被放大。
-
连接数很大但活跃度很低
epoll 擅长这种"巨量 idle + 少量 active";select/poll 则会被 O(N) 扫描拖死。
-
用户态缓冲与内核缓冲的配合
读写策略(一次读多少、合并写、零拷贝 sendfile/splice)会显著影响吞吐和 cache 行为。
5. 第五阶段:从"就绪"到"完成"------异步 I/O 与 io_uring 的出场
到 epoll 为止,我们大多数时候仍在做"就绪驱动":
- 内核:告诉你"现在可以读/写了"
- 用户态:自己发起 read/write、自己搬数据、自己处理短读写
在极限性能下,人们想进一步减少:
- 系统调用次数
- 上下文切换
- 用户态/内核态来回(尤其是"等一下、试一下"的往返)
5.1 io_uring 的核心记忆点:完成队列
io_uring 更像:
- 用户态把"要做的 I/O 操作"放进 提交队列 SQ
- 内核异步执行(或更高效地批处理/提交)
- 完成后把结果放进 完成队列 CQ
- 用户态一次性收割完成事件
这是一种"完成驱动(completion-based)"的思路:
epoll 是 readiness(就绪);io_uring 更接近 completion(完成)。
5.2 什么时候它可能赢(性能直觉)
- 需要大量小 I/O、频繁 syscalls 的场景
- 希望批量提交/批量完成,减少进出内核的次数
- 文件 I/O、网络 I/O、以及组合型工作流(取决于内核版本与使用方式)
5.3 代价
- 心智模型更复杂:请求生命周期、取消、超时、缓冲管理
- 调试难度更高
- 并非所有场景都必然比 epoll 更快(取决于负载形态、内核版本、实现细节)
6. 一张"内核/性能向"的对照表:它们到底差在哪
| 方式 | 等待对象 | 通知语义 | 用户态是否发起真正 read/write | 典型成本瓶颈 | 适用场景直觉 |
|---|---|---|---|---|---|
| 阻塞 I/O | 单个 fd 条件 | 完成后返回(对调用者而言) | 是 | 线程/调度成本 | 连接少、逻辑简单 |
| 非阻塞 I/O | 单个 fd 条件 | 立即返回 EAGAIN | 是 | 忙轮询空转 | 必须配合事件机制才高效 |
| select/poll | 多 fd 就绪 | readiness | 是 | 每轮拷贝+O(N) 扫描 | fd 少或兼容性需求 |
| epoll | 多 fd 就绪 | readiness(返回就绪列表) | 是 | 回调耗时、竞态处理、ET 读写策略 | 海量连接、事件驱动 |
| io_uring | I/O 请求完成 | completion(CQE) | 请求提交后内核执行 | 缓冲/生命周期管理、实现复杂度 | 极限吞吐/降低 syscall 往返 |
Pomelo_刘金。转载请注明原文链接。感谢