一个线程守一万条连接
1999 年,Dan Kegel 写下了那篇《The C10K Problem》:一台服务器能不能同时伺候一万个连接?当时的答案是「很难」。而解决它的关键,是一个听起来很小的改动——把代价从「你盯着多少」变成「这一轮真的有事的有几个」。
问题从哪来
最朴素的服务器写法是一个连接一个线程:
while (1) {
int c = accept(listen_fd, ...);
pthread_create(&t, NULL, handle, &c); // 每来一个客人,雇一个人伺候
}
这个写法非常好懂,每个线程里的逻辑都是直白的顺序代码。它在几百个连接以内工作得很好。
但到一万个连接时:
| 成本项 | 一万个线程 | 说明 |
|---|---|---|
| 线程栈 | 10000 × 8 MB = 80 GB 虚拟 | 虚拟地址空间够(第 10 章),RSS 按需分页所以实际小得多 |
| 内核结构 | 10000 个 task_struct | 每个几 KB,加起来几十 MB |
| 调度 | ★ 调度器要在一万个任务里挑 | 红黑树操作变成 O(log 10000) |
| 上下文切换 | ★ 这才是真正的杀手 | 见下 |
假设一万个连接里每秒各有 10 个请求,那就是每秒 10 万次唤醒,每次至少两次上下文切换(第 9 章):
200000 × 3 μs = 600 ms/s——相当于 0.6 个核纯粹花在切换上,而且缓存被反复冲刷,实际影响更大。
这是第 5 章强调过的一点,这里必须再说一次,因为它经常被搞错:
阻塞的线程不占 CPU。它处于 S 状态,一条指令都不执行。
问题在于数量本身:一万个线程 = 一万套栈 + 一万个调度实体 + 频繁的唤醒切换。
所以解法不是「别阻塞」,而是「用少数几个线程盯住大量 fd」。这是完全不同的两个思路。
那么,怎么用一个线程盯住一万个 fd
先得有非阻塞 IO。给 fd 设上 O_NONBLOCK 之后,read 在没数据时不再睡过去,而是立刻返回 -EAGAIN。
但光有这个还不够——你总不能写个死循环挨个去问一万个 fd「你有数据吗」,那是纯粹烧 CPU 的忙等待。
你需要一个系统调用,语义是:「我把这一万个 fd 交给你,谁有动静了叫我,没动静就让我睡着」。
这类调用有三代,而它们的区别就是这一章的全部。
下面这台机器按每轮事件循环的实际操作数算账。关键操作:先看默认(一万连接、20 活跃);然后把「活跃数」拖到接近连接数——你会看到 epoll 的优势塌回去。这一步很重要,它告诉你 epoll 到底赢在哪。
三代的区别,在于「状态存在哪」
| select | poll | epoll | |
|---|---|---|---|
| fd 名单存在哪 | 用户态,每轮重传 | 用户态,每轮重传 | ★ 内核,注册一次就留着 |
| 内核怎么找就绪的 | 遍历全部 O(n) | 遍历全部 O(n) | ★ 直接取就绪链表 O(1) |
| 返回什么 | 整个名单,你自己找 | 整个名单,你自己找 | ★ 只返回就绪的那几个 |
| fd 数量上限 | 1024(编译期写死) | 无 | 无 |
| 代价正比于 | 盯着多少个 | 盯着多少个 | ★ 有事的有几个 |
而是它的代价跟什么成正比。
select/poll 每一轮都要:把一万个 fd 的名单从用户态拷进内核 → 内核挨个检查一万个 → 把结果拷回用户态 → 你再挨个检查哪个就绪了。
即使这一轮只有 20 个 fd 有数据,你也要为另外 9980 个「什么都没发生」的连接付钱。四遍 O(n)。
epoll 把名单存在内核里(一棵红黑树),只在 epoll_ctl 时改动。数据到达时,网卡中断的处理函数(第 4 章的下半部)顺手把这个 fd 挂到就绪链表上。epoll_wait 只需要把就绪链表拿走。
「谁有动静」这个信息是在事件发生时被记录的,而不是事后靠遍历找出来的。这才是那一百倍差距的来源。
所以 epoll 的优势条件很明确:连接多、活跃比例低。这恰好是长连接服务的典型形态——一万个挂着的 WebSocket,同一时刻真在说话的可能只有几十个。
反过来,如果所有连接都在高速收发(活跃比例接近 100%),epoll 并不神奇——你在上面那台机器上把活跃数拖上去就能看到这一点。它省的是「查空连接」的钱,如果没有空连接,就没什么可省的。
水平触发与边缘触发
epoll 有两种模式,这是它最容易用错的地方:
| 水平触发 LT(默认) | 边缘触发 ET | |
|---|---|---|
| 什么时候通知你 | 只要还有数据没读完,每次 epoll_wait 都告诉你 | ★ 只在状态变化的那一刻通知一次 |
| 没读完会怎样 | 下次还会通知,安全 | ★ 再也不通知了,这个连接就此卡死 |
| 怎么用 | 读一次就行 | 必须循环读到 EAGAIN 为止 |
| 系统调用次数 | 稍多 | 稍少 |
// 错误:ET 模式下只读一次 n = read(fd, buf, 1024); // 内核缓冲区里有 4096 字节,只读走了 1024 handle(buf, n); // 剩下 3072 字节还在内核缓冲区里 // 但「有数据可读」这个状态没有变化 → epoll 再也不通知你 // ★ 这个连接从此卡死,而且没有任何报错
// 正确:循环读到 EAGAIN
while ((n = read(fd, buf, 1024)) > 0) {
handle(buf, n);
}
if (n < 0 && errno != EAGAIN) { /* 真的出错了 */ }
这个 bug 特别阴险,因为它只在数据量超过单次读取缓冲区时才出现——小请求测试全过,上线遇到大 body 就有连接莫名其妙卡住。
建议:没有明确理由就用默认的 LT。Nginx 用 ET(它非常清楚自己在干什么),但大多数框架和大多数人的代码,用 LT 更安全,性能差距在实际负载下很小。
下一代:io_uring
epoll 解决了「怎么知道谁有数据」,但没解决另一个问题:你还是得为每次读写各付一次系统调用(第 3 章那 100 ns)。一万个活跃连接每秒各读写一次,就是两万次过线。
Linux 5.1 引入的 io_uring 换了个思路:在用户态和内核之间架两个共享内存的环形队列:
- 提交队列(SQ):你把要干的事写进去(读这个 fd、写那个 fd、甚至
openat/fsync)。 - 完成队列(CQ):内核把结果写回来。
关键在于这两个队列是共享内存——你往里写、内核往外读,不需要系统调用(第 3 章讲 vDSO 时的同一个思路:能用共享内存就别传消息)。
在轮询模式下(IORING_SETUP_SQPOLL,内核起一个线程主动扫队列),整个 IO 路径可以做到一次系统调用都不用。
另外它是真正的异步:epoll 只告诉你「现在可以读了」,读还是要你自己去读、还是要等;io_uring 是「你帮我读,读完通知我」。这是通知模型和完成模型的根本差别——Windows 的 IOCP 二十年前就是完成模型,Linux 到这里才补齐。
这一章讲的全是内核这一侧:红黑树、就绪链表、环形队列、代价跟什么成正比。
至于你的代码该怎么写——回调地狱、Promise、事件循环、async/await、协程、结构化并发、背压——那全是《同时》那本书的内容,这里一个字都不重复。
但有一件事值得在这里点破,因为它能把两本书接起来:所有这些编程模型,最终都要落到某个线程在某个地方调 epoll_wait 或者 io_uring_enter。
Node 的事件循环、Go 的 netpoller、Kotlin 协程底下的 Dispatchers.IO、Netty 的 EventLoop、Nginx 的 worker——剥到最里面全是这一章那个循环。它们的差别在于怎么把「就绪通知」翻译成一个让你舒服的编程模型,而不在于底下的机制。
「Android 的主线程为什么不会把 CPU 跑满?」
因为它就是一个 epoll 循环。
Looper 的核心是 MessageQueue.next(),而它底下是 nativePollOnce() → Looper::pollOnce() → epoll_wait()。
没有消息时,主线程阻塞在 epoll_wait 上,处于 S 状态(第 5 章),一条指令都不执行。有消息时(另一个线程 post 了、或者输入事件来了、或者 VSYNC 到了),往一个管道里写一个字节,epoll_wait 立刻返回。
所以「主线程死循环」这个说法容易引起误会——它是一个会睡觉的循环,不忙等,不耗电。
顺带解释了两件事:
- 为什么 ANR 的堆栈经常停在
nativePollOnce——那不是卡住了,那是正常的等待状态。真正的问题在别处(通常是前一条消息处理太久)。 - 为什么
Handler.post有唤醒成本(第 9 章)——它要写管道、唤醒线程、等调度、上下文切换。批量合并比逐条 post 划算得多。
这一章的一句话
select/poll 的代价正比于你盯着多少个 fd,epoll 的代价正比于这一轮真的有事的有几个——因为它把名单存在内核里,并让「谁有动静」在事件发生时就被记下来,而不是事后遍历找出来。而 io_uring 更进一步,用共享内存的环形队列把系统调用本身也省掉了。
卷 IV 到此结束,三重假象全部拆完。下一卷讲这些各自被骗着的进程之间怎么打交道——以及怎么把这整个骗局再演一遍。