前沿 06-IO与设备管理 预计 25 分钟 kp-028

I/O 模型演进:阻塞、多路复用与 io_uring

应用与内核的 I/O 交互范式沿"阻塞 → 非阻塞+轮询 → I/O 多路复用(select/poll/epoll)→ 真异步(io_uring)"演进,每一代都在回答同一个问题:如何用最少的系统调用与最少的线程等待,服务最多的连接。

学习状态:

一句话定义

应用与内核的 I/O 交互范式沿"阻塞 → 非阻塞+轮询 → I/O 多路复用(select/poll/epoll)→ 真异步(io_uring)"演进,每一代都在回答同一个问题:如何用最少的系统调用与最少的线程等待,服务最多的连接。

为什么重要

C10K 问题催生了 nginx/redis 的事件驱动时代;io_uring 则代表了"把系统调用本身也批量异步化"的最新答案。服务端开发的一切 I/O 框架(Node.js、Netty、Tokio)都是这几层模型的语言级封装,读懂内核层,框架的每个"魔法"都有了出处。

前置知识

kp-009(套接字与阻塞语义);kp-026(中断与驱动的底层路径)。

核心概念

  • 阻塞 I/O:线程睡在 read 上直到数据就绪,一线程一连接,万连接即万线程。
  • 非阻塞 + 轮询:O_NONBLOCK 下 read 立即返回 EAGAIN,应用忙等——浪费 CPU。
  • I/O 多路复用:一个线程监听一批 fd 的就绪事件(select/poll/epoll),事件驱动循环。
  • epoll:内核维护就绪集合(红黑树注册 + 就绪链表回调),事件到来 O(1) 通知,支持十万级 fd。
  • 信号驱动 I/O:内核就绪时发信号,实践中因信号语义粗糙少用。
  • 异步 I/O(io_uring):提交队列/完成队列(SQ/CQ)共享内存环,批量提交、批量收割,系统调用次数骤减。
  • LT 与 ET:水平触发(就绪期间反复通知)与边缘触发(状态变化通知一次,需一次读空)。

原理与机制

epoll 事件循环骨架与 io_uring 提交流程:

epoll (就绪通知, 每次仍需一次 epoll_wait 系统调用):
  epfd = epoll_create1(0);  epoll_ctl(epfd, ADD, fd, EVENTS)
  loop: n = epoll_wait(epfd, events, MAX, timeout)
        for e in events:  handle(e.fd)     <-- 千万连接, 一次 wait 收 N 个就绪

io_uring (提交与完成解耦, 系统调用按批摊销):
  初始化: 内核与应用共享 SQ/CQ 环形缓冲区(内存映射, 无锁)
  提交:  把 read/write 请求写入 SQ -> 满批或显式 enter 一次性提交
  完成:  应用轮询 CQ 或等待事件, 收割完成结果
  效果:  原本 N 次系统调用 -> 1 次 enter + 环上批量交换; 支持 SQPOLL 内核线程代提交

代际收益的本质都是摊销:epoll 把"每连接一线程的阻塞等待"摊销成一个线程监听全部;io_uring 进一步把"每次操作一次系统调用"摊销成批量内存交换,甚至让内核线程代做提交(SQPOLL),应用热路径上一次系统调用都没有。

图示

线程模型对比 (1万连接):
阻塞+线程:  1万线程 x 8MB栈, 切换风暴 (kp-006)
epoll:      少量线程, 每线程事件循环: wait -> 逐个就绪 fd 处理
io_uring:   少量线程, 批量提交 -> 批量收割, 系统调用次数趋近于批量数

直观类比

阻塞线程像给每个客人配一名专属服务员(人多即崩溃);epoll 像一名服务员盯着一排呼叫铃(铃响才过去);io_uring 像服务员与前厨之间装了传菜传送带:下单一次送一批单子,菜好了从传送带成批取回,服务员甚至不用每次走到厨房(系统调用)。

实例或案例

strace -f -e trace=epoll_wait,epoll_ctl nginx或redis 二进制跑一次压测   # 事件循环实录
ulimit -n                          # fd 上限, 高连接服务第一道墙
cat /proc/sys/fs/file-max          # 系统级 fd 总量
ss -s                              # 连接规模总览

用 strace 观察 redis:epoll_wait 返回一批事件、随后连续 read/write——教科书级事件循环;对比一个老式阻塞代理,则可见 accept 后无 epoll、线程数随连接暴涨。

常见误区

  • "epoll 一定比多线程好":CPU 密集处理用少量线程 + epoll 的组合(SO_REUSEPORT 多事件循环),纯 epoll 单线程反而饿死计算;两者是搭配不是替代。
  • 混淆异步与非阻塞:非阻塞只是"不睡眠",应用仍需主动轮询/等待;异步(io_uring)才是内核完成后主动通知结果。
  • ET 模式不读空缓冲:边缘触发只通知一次,漏读一次事件即永久挂起,必须循环 read 到 EAGAIN。

与其他知识点的关系

事件数量与线程切换的账本在 kp-006、kp-010;与页缓存/块层的落点连接 kp-027、kp-022;异步文件 I/O 与 kp-024 的 fsync 组合是存储引擎的日常。

延伸阅读

Axboe 的 io_uring 白皮书(2019);man 7 epoll 的 LT/ET 语义;《UNIX 网络编程》卷1 的五种 I/O 模型章节。

自测题

  1. select/poll 与 epoll 的本质差别?

答:前者每次调用把全部 fd 集合从用户态拷入内核并线性扫描;epoll 在内核维护注册集合与就绪链表,wait 只返回就绪项,复杂度从 O(n) 降为 O(就绪数)。

  1. io_uring 减少的究竟是哪种开销?

答:主要是系统调用的陷入/参数拷贝开销——用共享内存环批量提交与收割,把 N 次调用摊销为 1 次甚至零次(SQPOLL)。

  1. 为什么 ET 模式必须循环读到 EAGAIN?

答:边缘触发只在状态变化时通知一次,若一次没读完,剩余数据不会再收到通知,连接将永久等待。

标签:#epoll #io_uring #C10K #事件驱动 #异步IO