五种 IO 模型:异步的地基是 epoll
Node.js 的事件循环、Python 的 asyncio、Go 的 netpoller、Java 的 Netty、Nginx、Redis —— 这些东西看起来风格迥异,但如果你用 strace 剥开它们,会在最底下看到同一个系统调用。这一章讲的就是那个系统调用,以及它是怎么在 1999 年前后,把「一个连接一个线程」这个持续了三十年的假设给废掉的。这是全书唯一一章会真的贴 C 代码的 —— 因为这一层没有语言,只有内核。
问题:一个线程,一万个连接
1999 年,Dan Kegel 写了一篇文章叫《The C10K Problem》,问了一个当时听起来很疯狂的问题:
一台服务器,能不能同时处理一万个并发连接?
按当时的做法(一个连接一个线程),答案是「理论上可以,实际上会死」:一万个线程 = 几十 GB 的栈 + 一个被压垮的调度器。
问题的核心是一个结构性的荒谬:
剩下 9995 个线程,全都躺在内核的等待队列里睡觉 —— 用几十 GB 的内存,睡觉。
把这个荒谬讲清楚,解法就呼之欲出了:不要给每个连接一个线程。给「有事情发生的连接」一个线程。
问题只剩下一个:你怎么知道哪些连接「有事情发生」?
三种回答,三个时代
下面这个 demo 把三种回答摆在一起。注意看「线程数」和「CPU」这两个数字怎么变。
Stevens 的五种 IO 模型
Richard Stevens 在《UNIX 网络编程》里给出了经典的五分类。它们的差别只在两个问题上:
- 阶段一:等数据到达(数据从网卡进到内核缓冲区)—— 你是站着等,还是走开?
- 阶段二:把数据从内核拷到用户空间 —— 谁来拷?
| 模型 | 阶段一:等数据 | 阶段二:拷数据 | 今天还有人用吗 |
|---|---|---|---|
| ① 阻塞 IO | 线程睡着等 | 线程自己拷(阻塞) | 有,而且很多。连接数不多时,它是最好的选择 —— 代码最简单。 |
| ② 非阻塞 IO | 忙轮询(一直问「好了吗」) | 线程自己拷(阻塞) | 基本没有。烧 CPU。它的价值是作为 ③ 的组成部分。 |
| ③ IO 多路复用 select/poll/epoll/kqueue |
线程睡在 epoll_wait 上,内核替你盯着一万个 fd |
线程自己拷(阻塞,但这时数据已经在内核缓冲区了,很快) | 这就是今天的一切。Nginx、Redis、Node、Go、asyncio、Netty,全是它。 |
| ④ 信号驱动 IO | 注册 SIGIO,数据到了内核发信号给你 |
线程自己拷 | 几乎没人用。信号处理函数里能干的事太少,而且信号会丢。 |
| ⑤ 异步 IO(真·AIO) POSIX aio / IOCP / io_uring |
走开 | 内核拷好,再通知你「已完成」 | 正在成为未来。Windows 的 IOCP 一直是这个模型;Linux 上 io_uring(5.1+)终于给了一个好用的版本。 |
这是最容易混的一点,也是「Reactor vs Proactor」这个术语的全部含义:
- ③ IO 多路复用 = Reactor 模式 = 就绪通知。
内核告诉你:「3 号 fd 可以读了。」然后你自己调read()去拷数据。 - ⑤ 异步 IO = Proactor 模式 = 完成通知。
你告诉内核:「帮我把 3 号 fd 的数据读到这个 buffer 里。」内核读完了,才通知你:「好了,数据已经在你的 buffer 里了。」
所以严格说,Linux 上的 epoll 并不是「异步 IO」 —— 它是「同步的非阻塞 IO + 就绪事件通知」。真正的阶段二(拷数据)你还是自己做的、还是阻塞的。
这个区分在学术上很重要,在工程上……大部分时候不重要,因为阶段二(内存拷贝)通常只有几微秒。真正花时间的是阶段一(等网络),而 epoll 已经把它解决了。
select → poll → epoll:三代人的账
IO 多路复用这个想法,Unix 从 1983 年就有了(select)。但它一直不好用,直到 2002 年的 epoll。
select(1983) | poll(1986) | epoll(Linux 2.5.44, 2002) | |
|---|---|---|---|
| fd 数量上限 | 1024(FD_SETSIZE,编译期写死) | 无上限 | 无上限 |
| 每次调用要传什么 | 全部 fd 集合(用户态 → 内核态整个拷一遍) | 全部 fd 数组(同样每次都拷) | 什么都不用传 —— fd 用 epoll_ctl 注册一次就长期存在内核里 |
| 内核怎么找就绪的 fd | O(n) 遍历全部 fd | O(n) 遍历 | O(1) —— 内核维护一个就绪链表,fd 就绪时由回调直接挂上去 |
| 返回后你要做什么 | O(n) 遍历所有 fd,逐个 FD_ISSET 问「是你吗」 | O(n) 遍历 | 直接拿到就绪清单,长度就是就绪数 |
| 1 万连接、3 个就绪 | 拷 1 万个 fd 进内核,扫 1 万次,回来再扫 1 万次 | 同上 | 一次系统调用,返回 3 个 |
差距在「一万连接、三个活跃」这一行上,是压倒性的。而这恰恰是真实互联网服务的常态(长连接、大部分空闲)。
/* epoll 的三个系统调用,全部的 API 就这么点 */
int ep = epoll_create1(0); /* 1. 创建一个 epoll 实例 */
struct epoll_event ev = {
.events = EPOLLIN, /* 我关心「可读」事件 */
.data.fd = conn_fd
};
epoll_ctl(ep, EPOLL_CTL_ADD, conn_fd, &ev); /* 2. 注册 —— 一次就够,不用每轮重传 */
struct epoll_event events[MAX];
while (1) {
int n = epoll_wait(ep, events, MAX, -1); /* 3. 等 —— 没事就睡,0 CPU */
for (int i = 0; i < n; i++) { /* 内核只把「好了的」给你 */
int fd = events[i].data.fd;
handle(fd); /* 这里绝对不能阻塞!(见下) */
}
}
/* 一个线程。十万个 fd。这就是全部。 */
看上面那个 for 循环。它是单线程的。
如果 handle(fd) 里有一行阻塞调用(一次同步的数据库查询、一次 File.read()、一个 CPU 密集的 for 循环)—— 整个循环就停在那里了。这一个连接的慢操作,会让另外 99999 个连接一起等。
这条铁律,在每一层都会以不同的面目再出现一次:
- Node.js:「不要在事件循环里做同步 IO」
- Python:「不要在
async def里调requests.get」(第 13 章有 demo) - Netty:「不要在
EventLoop线程上执行阻塞任务」 - Redis:「不要在生产环境跑
KEYS *」(Redis 就是个单线程事件循环)
它们是同一条规则。而它的根,就在上面这个 while(1) 循环里。
水平触发 vs 边缘触发(LT / ET)
epoll 有两种触发模式,这是面试常考、实战常错的一点:
| 水平触发 LT(默认) | 边缘触发 ET | |
|---|---|---|
| 什么时候通知你 | 只要缓冲区里还有数据,每次 epoll_wait 都通知你 | 只在「从无到有」的那一刻通知一次 |
| 你必须做什么 | 读多少算多少,没读完下次还会通知 | 必须一次性读干净(循环 read 直到返回 EAGAIN),否则剩下的数据你再也收不到通知了 |
| 要求 fd 非阻塞吗 | 不强制 | 必须(否则最后那次「读干净」的 read 会把你阻塞死) |
| 性能 | 系统调用略多 | 略少 |
| 建议 | 用它。除非你在写 Nginx 级别的东西。 | 快一点点,bug 多一万倍。「忘了读干净」是这个模式的经典事故。 |
io_uring:Linux 终于有了真的异步 IO
epoll 解决了网络 IO,但它对磁盘 IO 基本无效 —— 因为普通文件的 fd「永远是就绪的」(内核认为你随时可以 read,哪怕它要去转盘子)。所以 read() 一个冷文件,还是会把线程阻塞掉。
这就是为什么所有的异步框架,处理文件 IO 时都偷偷用了线程池:Node 的 libuv 是这么干的,Go 也是(第 16 章的「syscall 摘 P」),Python 的 aiofiles 也是。那不是真的异步,那是「把阻塞藏进另一个线程」。
io_uring(Linux 5.1, 2019)终于给了一个统一的答案:
/* io_uring 的核心:两个共享内存的环形队列。 你和内核共用这两个 ring —— 提交请求几乎不需要系统调用。 */ 提交队列 SQ (Submission Queue) ← 你往里塞「请帮我读这个 fd」 完成队列 CQ (Completion Queue) ← 内核往里塞「读好了,数据在这」 /* 关键突破: 1. 真·异步 —— 内核帮你把数据读好(Proactor,阶段二也是内核干的) 2. 对文件 IO 一样有效(epoll 做不到) 3. 批量提交 —— 一次系统调用能提交几百个 IO 请求 4. 甚至可以 SQPOLL 模式:内核线程主动轮询,系统调用数 = 0 */
现状(截至 2026):io_uring 已经在 Nginx、ScyllaDB、Rust 的 tokio-uring 里落地,性能提升在磁盘密集场景下很显著。但它也是近年内核 CVE 的重灾区(复杂度太高,Google 一度在自家产线上禁用了它)。它是未来,但还不是「随便用」的现在。
- 「一个线程照看十万个连接」这件事,是内核给的,不是语言给的。那个内核能力叫
epoll(Linux)/kqueue(BSD、macOS)/IOCP(Windows)。 - 你用的每一个异步框架,剥到底都是一个
while(1) { epoll_wait(); 处理就绪的; }。Node 的 libuv、Python 的selectors、Go 的netpoll.go、Java 的NIO Selector—— 全是这一个循环的不同包装。 - 因此,「不要阻塞事件循环」是一条从内核一路传上来的物理规则,不是某个框架的怪癖。它会在第 13、16、23 章反复咬你。
C10K(一万连接)在 2005 年前后基本被 epoll 解决了。2013 年,Robert Graham 提出了 C10M —— 一千万连接。
他的论断很激进:「问题不在于性能,问题在于操作系统。」要做到千万级,你得绕过内核:用户态 TCP 栈(mTCP)、DPDK(网卡直通用户态,完全跳过内核网络栈)、绑核(每个核一个独立的、无锁的处理循环)。
今天你在 Cloudflare、在 CDN、在高频交易系统里看到的就是这一套。而你的业务服务大概率不需要它 —— 但知道这条路存在,能帮你判断「epoll 是不是天花板」。(它不是。它只是你的天花板。)
1. 用 strace 抓一个 Python asyncio 程序。
strace -f -e trace=epoll_create1,epoll_ctl,epoll_wait python your_async_app.py
你会亲眼看到那个 epoll_wait。这一步比读十篇博客都有用。
2. 对 Go 程序做同样的事(Linux 上)。你会在 net.Dial 之后看到 epoll_ctl(EPOLL_CTL_ADD) —— 那就是 netpoller 在注册 fd(第 16 章)。
3. 把 ulimit -n 打出来。这是你的进程能打开的 fd 上限(很多机器默认 1024)。「一个线程十万连接」的第一道墙,往往就是这个数字。
幕 I 结束。六个词钉死了,地基也挖到了内核。接下来往下走一层 —— 到硅片上。因为所有的并发 bug,最终都源自那一层:缓存、重排、和一个你以为是原子的加法。