| 本篇位置 | 并发部分的最后一篇。前面在讲"多个执行流怎么共处",这一篇讲"一个执行流怎么伺候一万个连接" |
| 运行环境 | 容器里的 Linux,gcc。演示要放开文件描述符上限:--ulimit nofile=200000:200000 |
一、从一个撑不住的服务器说起
如果你上过计算机网络那一支,写过这样的服务器:
while (1) {
conn = accept(listen_fd);
pthread_create(&t, NULL, handle, conn); /* 来一个连接,开一个线程 */
}
这个写法非常好懂——每个线程里的代码是彻底顺序的:读请求、处理、写回复、关闭。没有回调,没有状态机。
它能撑住几百个连接。一万个就不行了。
为什么不行?很多人的第一反应是"线程切换太贵"。这确实是一部分,但不是主要的。主要的是更朴素的东西:每个线程都要一个栈。
打个比方
一家餐厅。
一连接一线程,就是每张桌子配一个专属服务员。这个服务员从客人进门到结账,全程只伺候这一桌。
好处是他脑子里的流程特别清晰——“我现在在给这桌上前菜”,不需要记别的。
坏处是:客人在看菜单的十分钟里,这个服务员就站在那儿。 一百张桌子你就得雇一百个服务员,而他们大部分时间都在站着。
⭐ 餐厅真实的做法当然是:一个服务员管十几桌,谁举手去谁那儿。 这就是事件驱动。
二、跑一遍看看:账有多离谱
模拟"一万个连接都开着,但此刻都在等数据"这个状态。两种做法,量它们各自要多少资源:
- 做法一:创建 N 个线程,每个阻塞在条件变量上。
- 做法二:一个线程,创建 N 个 socket,全部注册进一个 epoll。
--- 1000 个连接 ---
一连接一线程 建了 1000 个 用时 0.01 秒 RSS + 8480 kB 地址空间 + 8256264 kB
一线程 + epoll 盯着 1000 个连接 用时 0.00 秒 RSS + 256 kB 地址空间 + 0 kB
--- 10000 个连接 ---
一连接一线程 建了 10000 个 用时 0.11 秒 RSS + 83088 kB 地址空间 +82562772 kB
一线程 + epoll 盯着 10000 个连接 用时 0.02 秒 RSS + 256 kB 地址空间 + 0 kB
--- 50000 个连接 ---
一连接一线程 建了 50000 个 用时 0.57 秒 RSS + 414652 kB 地址空间 +412814384 kB
一线程 + epoll 盯着 50000 个连接 用时 0.07 秒 RSS + 256 kB 地址空间 + 0 kB
五万个连接:线程方案要 414 MB 物理内存,epoll 方案要 256 KB。差了一千六百倍。
再看地址空间那一列:五万个线程占了 412 GB 的地址空间。
每个线程默认 8 MB 栈,5 万 × 8 MB = 400 GB。这 400 GB 绝大部分从来没被碰过,所以物理内存只用了 414 MB——这正是第 12 篇"按需分页"的直接后果。地址空间是虚的,物理页是真的。
⚠️ 但地址空间也是有限的。48 位地址空间理论上 256 TB,实际可用少得多,而且第 14 篇讲过:地址空间用得散,页表本身就会膨胀。这条路走不了太远。
⭐ 所以"C10K 问题"的核心不是切换开销,是每个执行流的固定成本。而事件驱动的答案是:别为每个连接开一个执行流。
三、epoll:一个线程盯住所有人
事件驱动的接口只干一件事:
“这一万个文件描述符里,现在哪些可以读了/可以写了?告诉我。”
历史上有三代:
| 每次调用要传什么 | 复杂度 | |
|---|---|---|
select |
每次都要传全部 fd,还有 1024 个的硬上限 | O(n),每次都从头扫 |
poll |
每次都要传全部 fd,去掉了上限 | O(n) |
epoll |
fd 集合注册一次,之后只取"就绪的那些" | O(就绪数) |
⭐ epoll 快在它把状态留在内核里。你不用每次都把一万个 fd 拷进内核让它扫一遍——内核自己维护着这个集合,哪个 fd 就绪了它就挂到一个就绪链表上。你要的时候,它直接把链表给你。
这个思路值得单独记:把重复传递的状态挪到内核里存一次,把 O(n) 变成 O(就绪数)。 BSD 的 kqueue、Windows 的 IOCP,走的是同一条路。
主循环长这样:
while (1) {
n = epoll_wait(ep, events, MAX, -1); /* 只有真的有事才返回 */
for (i = 0; i < n; i++)
处理(events[i]); /* 每个都必须很快返回 */
}
四、⭐ 代价:那个 for 循环里不能阻塞
上面那段代码有个致命的隐含要求:处理() 必须立刻返回。
因为只有一个线程。你在处理第 3 个事件时做了一次阻塞的数据库查询,另外 9999 个连接全部卡在那儿等你。
这带来两个具体的痛:
一、所有 I/O 都得改成非阻塞的。 于是一个本来顺序的流程:
读请求 → 查数据库 → 读文件 → 写回复
变成了四段互不相连的代码,每段结束时登记"下一步在这儿",然后返回。函数之间的状态得你自己存起来。
这就是回调地狱。程序的控制流从"从上往下读"变成了"在一堆回调之间跳",而堆栈里再也看不出完整的调用链了——出错时你拿到的调用栈是从事件循环开始的,跟你的业务逻辑没什么关系。
二、一个 CPU 密集的处理会卡死所有人。 事件驱动只解决 I/O 等待,不解决计算。所以真实的服务器几乎都是混合的:N 个线程(N ≈ 核数),每个线程跑一个事件循环。 nginx 就是这样。
⭐ 这句话值得单独拎出来:事件驱动和多线程不是二选一。它们解决的是不同的问题——线程用来吃满多核,事件循环用来省掉"每个连接一个栈"的成本。
回到餐厅:一个服务员管十几桌,但他不能在某一桌那里等着厨房做菜——那样别的桌就没人管了。他得下了单就走。而餐厅当然也不止一个服务员——是"几个服务员,每人管十几桌",不是"一百个服务员一人一桌",也不是"只有一个服务员"。
五、协程:把顺序的样子还回来
回调地狱的根源是:函数返回时,栈就没了,所以你得手动把状态存到别处。
协程(coroutine)的解法:让这个执行流能被挂起,而且挂起时它的栈还留着。
async def handle(conn):
req = await conn.read() # 挂起。栈留着。
row = await db.query(req) # 挂起。栈还留着。
await conn.write(render(row))
看起来完全是顺序代码,但每个 await 都是一个可以让出去的点。运行时在底层还是那个 epoll 循环。
⭐ 协程是"用户态的线程":它有自己的栈,但切换不需要陷入内核(第 4 篇那笔钱省了),栈也可以做得很小(几 KB 而不是 8 MB,第二节那 412 GB 就省下来了)。
代价有两条,都很实在:
- ⚠️ 函数染色。 一个
async函数只能被async函数调用。这个性质会顺着调用链一路传染上去,最后往往是整个代码库分裂成"异步的"和"同步的"两半。 - ⚠️ 一个阻塞调用就毁掉一切。 在协程里不小心调了一个同步的库函数(比如一个没改造过的数据库驱动),整个事件循环就停在那儿了。而这件事没有任何编译期检查能拦住你。
Go 的做法激进一些:goroutine 是有栈协程,而且运行时把所有阻塞的系统调用都拦截改写了,所以没有函数染色,你写的就是普通的同步代码。代价是运行时更重、更难嵌入别的系统。
六、io_uring:连系统调用本身也要批量
epoll 解决了"一个线程盯住很多连接"。但真正读写数据时,每一次 read/write 还是一次系统调用。
第 4 篇算过:一次过门几百纳秒。一秒一百万次 I/O,就是几百毫秒纯粹花在过门上。(2018 年之后这笔钱更贵了——Meltdown 的补丁让每次进出内核都要多做一次页表切换。)
io_uring(Linux 5.1,2019)的答案是共享内存队列:
- 用户态和内核态共享两个环形队列:提交队列(SQ)和完成队列(CQ)。
- 你把请求写进 SQ,内核从 CQ 给你结果。
- 一次系统调用可以提交几百个请求;开了轮询模式甚至一次系统调用都不用。
⭐ 这一招和 epoll 是同一个思路再走一步:epoll 把"fd 集合"留在内核里存一次,io_uring 把"请求和结果"放进双方都能直接读写的共享内存。 两次都是在削减"过门"这个动作本身。
回到餐厅:io_uring 相当于在厨房和大堂之间放一块共享的点单板。服务员不用跑到厨房去喊,写在板上就行;厨师做完了也把号码写在板上。两边都不用为了传一句话专门跑一趟。
代价是接口复杂得多,而且它是 Linux 独有的。目前主要是数据库、存储引擎、高性能网络库在用。
七、代价与取舍
事件驱动换来了什么: 一台机器伺候几十万连接的能力。实测:五万连接,256 KB 对 414 MB。
它花了什么:
- 控制流被撕碎(回调地狱),调用栈失去意义,调试变难。
- 一处阻塞,全线卡死。而且这个错误极容易犯、没有编译期检查。
- 协程补回了可读性,但引入了函数染色,以及"整个代码库分裂成两半"的现实问题。
它放弃了什么: “每个请求有一个独立执行流"这个心智模型。 这个模型极其好用——它让你能顺序地思考一个请求的一生。放弃它,换来的是资源效率。
⭐ 所以这依然是个取舍,不是进步:连接数不大的时候,一连接一线程仍然是更好的选择——好懂、好调、好维护。先确认你真的有 C10K 问题,再付这个代价。 这和第 18 篇那句"先用一把大锁写对,测出来真的慢再拆"是同一句话。
八、小结
- 一连接一线程撑不住一万连接,主要瓶颈不是切换开销,是每个执行流的固定成本——每个线程一个栈。
- ⭐ 实测五万连接:线程方案 RSS 414 MB、地址空间 412 GB;epoll 方案 RSS 256 KB。差一千六百倍。(412 GB 里绝大部分从没被碰过——第 12 篇按需分页的直接后果。)
- epoll 快在把 fd 集合留在内核里存一次,只返回就绪的那些,把 O(n) 变成 O(就绪数)。
- ⚠️ 代价是事件循环里不能阻塞,于是控制流被撕成回调,调用栈失去意义。而且它不解决 CPU 密集——⭐ 事件驱动和多线程不是二选一,真实服务器是"每核一个事件循环”。
- 协程把顺序的样子还回来:用户态的线程,切换不进内核,栈可以很小。⚠️ 代价是函数染色,以及一个同步调用就能卡死整个事件循环、而且没有编译期检查。
- io_uring 用共享环形队列,把"过门"这个动作本身也批量掉。⭐ 它和 epoll 是同一思路再走一步:削减跨越用户态和内核态的次数。
- ⭐ 这是取舍不是进步。 连接数不大时,一连接一线程仍然更好。先确认你真有这个问题,再付这个代价。
思考题
- 第二节里五万个线程占了 412 GB 地址空间。如果把线程栈大小从 8 MB 调到 64 KB(
pthread_attr_setstacksize),能撑住多少个线程?还有什么会先成为瓶颈? epoll有两种触发模式:水平触发(还有数据就一直报)和边缘触发(只在状态变化时报一次)。边缘触发更高效,但用错了会丢事件。什么情况下会丢?- 第五节说"一个同步调用就能卡死整个事件循环"。有什么办法能在运行时发现这种情况?(提示:想想怎么量"事件循环两次迭代之间隔了多久"。)
- 餐厅那个比方里,协程对应什么?(提示:服务员离开一桌时,他脑子里关于这桌的信息去哪了?)