I/O 模型与事件驱动
一个进程能撑多少连接?1999 年的答案是 1 万(C10K 问题),今天单机百万——事件驱动 + 非阻塞 I/O 是答案的一半,io_uring 是另一半。本章讲 select/poll/epoll/kqueue、io_uring、Reactor 与 Proactor,以及 async/await 的运行时映射。
阻塞 + 每连接一线程
Section titled “阻塞 + 每连接一线程”每个连接一个线程,读阻塞时线程睡眠。模型简单(每个连接一个执行流),代价是线程数 = 连接数:1 万连接 = 1 万个线程的栈内存(每个 1–8 MB)与切换成本(→ 进程、线程与上下文切换)。连接数中等(千级)时仍然是最省心的模型。
多路复用(multiplexing)
Section titled “多路复用(multiplexing)”一个线程监视成千上万个 fd,只处理就绪的:
- select/poll:每次调用传整个 fd 集合,内核线性扫描—— 每次调用,fd 多时昂贵;
- epoll/kqueue:内核维护就绪列表,只返回就绪的 fd—— 摊销,epoll 的效率来自只返回就绪的 fd。注册一次(epoll_ctl),反复等待(epoll_wait)。
int ep = epoll_create1(0);epoll_ctl(ep, EPOLL_CTL_ADD, listen_fd, &ev); // 注册一次struct epoll_event events[64];for (;;) { int n = epoll_wait(ep, events, 64, -1); // 只返回就绪的 for (int i = 0; i < n; i++) handle(events[i].data.fd); // O(就绪数)}Reactor 模式:fd 就绪 → 分发到回调。事件循环 + 非阻塞 I/O 是高并发服务器的标准结构——Nginx、Redis、Node.js 的内核。
异步 I/O(io_uring)
Section titled “异步 I/O(io_uring)”多路复用仍是"你调内核问就绪";io_uring 更进一步:用户态与内核共享提交/完成两个环形队列——提交请求与收割完成都不进系统调用(批量时),io_uring 用共享环形队列减少系统调用。Reactor(就绪通知,自己做 I/O)与 Proactor(完成通知,内核做 I/O)由此统一;对 NVMe 与高吞吐存储,io_uring 是当前最快路径。
| 模型 | 每连接成本 | 适用 |
|---|---|---|
| 线程 per 连接 | 栈 + 切换 | 连接少、逻辑简单 |
| epoll + 事件循环 | 一个 fd 表项 | 万级以上连接、I/O 密集 |
| io_uring | 同上 + 批量提交 | 极高吞吐、NVMe |
| 协程 + 非阻塞 | 栈可缩至 KB | 海量连接 + 同步写法 |
async/await 的运行时映射
Section titled “async/await 的运行时映射”async/await 是语法糖:脱糖成状态机(→ 语法与语义)——每个 await 点保存状态、注册回调到运行时的事件循环(epoll/io_uring 之上),就绪后恢复状态继续执行。async/await 在运行时层面映射到这些系统调用:Rust 的 tokio、Python 的 asyncio、JS 的事件循环,底下都是 epoll/kqueue/io_uring + 事件循环。
协程 + 事件循环的组合给了"同步的写法 + 事件驱动的效率":await 处让出(不烧 CPU),事件循环继续跑别的任务。代价是传染性(async 沿调用链向上传染)与阻塞即灾难(事件循环里的一个阻塞调用卡住所有任务,→ 进程、线程与上下文切换)。
- epoll 的效率来自只返回就绪的 fd(O(就绪数) vs select 的 O(总 fd))
- io_uring 用共享环形队列减少系统调用:Reactor 与 Proactor 统一
- 事件循环 + 非阻塞 I/O 是高并发服务器的标准结构(Nginx、Redis、Node)
- async/await 脱糖成状态机 + 事件循环;传染性与阻塞是两个工程代价
- 模型选择看连接数:千级用线程,万级用事件循环,极高吞吐用 io_uring