幕 I · 正名CH 05进度 05/25

五种 IO 模型:异步的地基是 epoll

Node.js 的事件循环、Python 的 asyncio、Go 的 netpoller、Java 的 Netty、Nginx、Redis —— 这些东西看起来风格迥异,但如果你用 strace 剥开它们,会在最底下看到同一个系统调用。这一章讲的就是那个系统调用,以及它是怎么在 1999 年前后,把「一个连接一个线程」这个持续了三十年的假设给废掉的。这是全书唯一一章会真的贴 C 代码的 —— 因为这一层没有语言,只有内核。

C10Kselect / poll / epollReactorio_uring零依赖地基

问题:一个线程,一万个连接

1999 年,Dan Kegel 写了一篇文章叫《The C10K Problem》,问了一个当时听起来很疯狂的问题:

一台服务器,能不能同时处理一万个并发连接?

按当时的做法(一个连接一个线程),答案是「理论上可以,实际上会死」:一万个线程 = 几十 GB 的栈 + 一个被压垮的调度器。

问题的核心是一个结构性的荒谬

一万个连接,任一时刻只有几个真的有数据这就是全部矛盾
连接 1 空闲(用户在读页面 / 在打字 / 网络在传输)
连接 2 空闲
连接 3 空闲
… 9997 个 全都空闲
荒谬在哪 你为 10000 个连接各准备了一个线程(各 8MB 栈),而任一时刻只有三五个真的有活干。
剩下 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 数量上限1024FD_SETSIZE,编译期写死)无上限无上限
每次调用要传什么全部 fd 集合(用户态 → 内核态整个拷一遍)全部 fd 数组(同样每次都拷)什么都不用传 —— fd 用 epoll_ctl 注册一次就长期存在内核里
内核怎么找就绪的 fdO(n) 遍历全部 fdO(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。这就是全部。 */
⚠ 陷阱 · 事件循环里的那个 handle(),绝对不能阻塞

看上面那个 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 一度在自家产线上禁用了它)。它是未来,但还不是「随便用」的现在。

◆ 定论 · 这一章要带走的三句话
  1. 「一个线程照看十万个连接」这件事,是内核给的,不是语言给的。那个内核能力叫 epoll(Linux)/ kqueue(BSD、macOS)/ IOCP(Windows)。
  2. 你用的每一个异步框架,剥到底都是一个 while(1) { epoll_wait(); 处理就绪的; }Node 的 libuv、Python 的 selectors、Go 的 netpoll.go、Java 的 NIO Selector —— 全是这一个循环的不同包装。
  3. 因此,「不要阻塞事件循环」是一条从内核一路传上来的物理规则,不是某个框架的怪癖。它会在第 13、16、23 章反复咬你。
🕘 掌故 · C10K 之后,是 C10M

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,最终都源自那一层:缓存、重排、和一个你以为是原子的加法。