本篇位置 并发部分的最后一篇。前面在讲"多个执行流怎么共处",这一篇讲"一个执行流怎么伺候一万个连接"
运行环境 容器里的 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 是同一思路再走一步:削减跨越用户态和内核态的次数。
  • 这是取舍不是进步。 连接数不大时,一连接一线程仍然更好。先确认你真有这个问题,再付这个代价。

思考题

  1. 第二节里五万个线程占了 412 GB 地址空间。如果把线程栈大小从 8 MB 调到 64 KB(pthread_attr_setstacksize),能撑住多少个线程?还有什么会先成为瓶颈?
  2. epoll 有两种触发模式:水平触发(还有数据就一直报)和边缘触发(只在状态变化时报一次)。边缘触发更高效,但用错了会丢事件。什么情况下会丢?
  3. 第五节说"一个同步调用就能卡死整个事件循环"。有什么办法能在运行时发现这种情况?(提示:想想怎么量"事件循环两次迭代之间隔了多久"。)
  4. 餐厅那个比方里,协程对应什么?(提示:服务员离开一桌时,他脑子里关于这桌的信息去哪了?)

延伸