Linux I/O 方式进化史(内核/性能视角):从“睡死”到“就绪队列”再到“完成队列”

这篇文章面向"想把 I/O 讲透的人":不止会用 epoll,还要能解释为什么它在内核里更省就绪到底是什么意思为什么会出现 EAGAIN/短读写 、以及 io_uring 到底比 epoll 多做了什么

我会按时间线讲:每一代技术都源自一个非常具体的性能/工程痛点。


0. 开场:I/O 的本质矛盾(内核到底在帮你等什么)

在 Linux 里,I/O 大体有两步:

  1. 等待条件成立(比如 socket 接收队列有数据、发送缓冲区有空间、监听 socket 有新连接、文件页缓存就绪等)
  2. 数据搬运/完成(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 现实痛点 → 下一代

痛点:我想服务 10k100k 连接,但我不想要 10k100k 线程。

于是人们想:能不能少线程、甚至单线程扛住?


2. 第二阶段:非阻塞 I/O(Nonblocking)------"线程不睡了,但 CPU 开始空转"

2.1 语义改变

把 fd 设为 O_NONBLOCK 后:

  • read() 若无数据:立刻返回 -1errno=EAGAIN/EWOULDBLOCK
  • accept() 无连接:同样 EAGAIN
  • write() 写不进去(发送缓冲区满):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 不是银弹:几个内核/性能坑位

  1. 惊群(thundering herd)

    多线程/多进程等待同一监听 socket 或同一 epoll 事件源会导致大量无效唤醒。

    • accept 惊群历史上很典型;现代内核与 EPOLLEXCLUSIVE 等机制缓解,但设计上仍要谨慎。
  2. 事件循环里的"阻塞点"

    一旦你在回调里做了阻塞操作(磁盘 IO、DNS、锁争用、同步日志),整个 Reactor 延迟会被放大。

  3. 连接数很大但活跃度很低

    epoll 擅长这种"巨量 idle + 少量 active";select/poll 则会被 O(N) 扫描拖死。

  4. 用户态缓冲与内核缓冲的配合

    读写策略(一次读多少、合并写、零拷贝 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_刘金。转载请注明原文链接。感谢

相关推荐
提伯斯6462 小时前
解决 PX4 + ROS px4ctrl 「No odom!」自动起飞失败问题
linux·ros·px4·fastlio·mid360·egoplanner
牛奶咖啡132 小时前
shell脚本编程(八)
linux·shell脚本编程·while循环语句·计数器控制的while循环·标志控制的while循环·until循环·select循环菜单
Q16849645152 小时前
知识点-创建、查看和编辑文本文件
linux·运维
小宇的天下2 小时前
Calibre 3Dstack --每日一个命令days11【dangling_ports】(3-11)
linux·运维·服务器
HIT_Weston3 小时前
97、【Ubuntu】【Hugo】搭建私人博客:搜索功能(二)
linux·运维·ubuntu
chen_mangoo3 小时前
HDMI简介
android·linux·驱动开发·单片机·嵌入式硬件
何达维3 小时前
`kubectl top nodes` 或 `kubectl top pods` 返回 `metrics not available yet` 的排查、解决
linux
东皇太星4 小时前
linux 内存管理详解
linux·运维·服务器
JY.yuyu4 小时前
Linux计划任务进程
linux·运维·服务器