深蓝黑背景封面。左侧上下两张对比卡:上卡「一连接一线程」画出八行灰色小方块,每个方块代表一个线程各拖着一个连接,注「1 万连接 = 1 万线程,内存与上下文切换先撑不住」;下卡以青色描边高亮「事件循环」,一个圆形循环箭头套着 epoll 字样,左侧一排连接小方块汇入,注「一个循环监听所有 fd,就绪才处理,单机万级连接」。右侧大标题「事件驱动架构为什么赢了」,副标题「一图看懂 Reactor 与 epoll」,并附注「从一连接一线程到多路复用 + 事件分发」。左上标注「知识延伸 · 事件驱动」,右上圆角胶囊标注「Day 09 · 11-26」。 图 1:本文封面,一连接一线程与事件循环两种模型的对比。

同批的 Nginx 真题讲了它和 Apache 两款产品在进程模型上的逐项对比。这篇退一步,不看具体产品,专门讲背后那个架构模式本身:事件驱动到底凭什么把单机连接数从几千推到万级以上,Reactor 的角色怎么分工,epoll 的两种触发模式又该怎么选。

先看清对手是怎么输的。

「一连接一进程/线程」模型的账很好算:每个连接独占一个线程,线程要有自己的栈(默认 MB 量级),一万条连接就是一万个线程,内存先被栈吃光;更要命的是调度——绝大多数连接在任一时刻都是空闲的,但操作系统照样要在这一万个线程间做上下文切换,CPU 时间大片消耗在切换和锁竞争上,真正处理 IO 的时间反而没剩多少。瓶颈不是「处理不过来」,而是「等待的代价太大」。

事件驱动的核心反转:不为连接配线程,只为事件醒一次。

反转的思路是:连接的状态由内核盯着,应用只起一个(或少量)事件循环,循环每轮问内核一句「哪些 fd 就绪了」,拿到就绪列表后逐个处理,处理完立刻回到等待。等待期间线程是睡在内核里的,不占 CPU。于是线程数与连接数彻底解耦——这就是单机万级甚至更高连接数的来源。Node.js、Nginx、Redis 的网络层都是这个范式。

Reactor 模式:把事件循环拆成三个角色。

Reactor 是事件驱动的经典落地结构,一次请求的生命周期由三个角色接力完成:

  • Reactor(事件分发器):事件循环本体,阻塞在多路复用调用上,拿到就绪事件后按类型分发,自己不做业务。
  • Acceptor(连接器):专门处理监听 fd 上的新连接事件,accept 出新连接、设为非阻塞、注册回多路复用器,然后交还控制权。
  • Handler(处理器):处理连接 fd 上的读写事件——读数据、解析、计算、回写,处理完把 fd 重新挂回等待队列。

角色拆开的意义在于:每一类事件的处理逻辑都是短小、无阻塞的,事件循环永远不会被某个慢连接拖住。需要重计算时再把业务丢给线程池,那就是「Reactor + 线程池」的变体,主循环依然只碰 IO。

epoll:这个模式在 Linux 上的引擎。

事件循环要问内核「谁就绪了」,Linux 上的答案就是 epoll。它维护两张表:interest list(你注册关注的 fd 集合)和 ready list(内核动态维护的就绪 fd 集合)。epoll_wait 一次调用返回全部就绪 fd,内核通过回调把就绪事件挂进 ready list,不用像 select/poll 那样每轮把全部 fd 拷进内核再线性扫描——fd 总数上万时,这个差别就是生死线。

LT 与 ET:同一个 epoll 的两种脾气。

epoll 默认是 LT(水平触发):只要 fd 还处于就绪状态,每次 epoll_wait 都会再通知你一遍。编程容错高,读一半没读完下次还提醒你,适合快速开发。

ET(边缘触发)只在 fd 状态发生变化的那一刻通知一次。Linux man 手册里的经典例子:写方写入 2KB,你收到一次通知但只读了 1KB,下一次 epoll_wait 可能就一直阻塞——剩下的 1KB 还在缓冲区里,但不会有新事件。所以 ET 模式有两条铁律:必须配非阻塞 fd,且收到通知后要循环 read/write 直到返回 EAGAIN 为止。换来的好处是通知次数最少、吞吐量上限最高。选 LT 求稳,选 ET 求极限,这是工程取舍,没有高下。

用代码感受单线程事件循环的吞吐量。

下面这段 Node.js 22.15.0 程序(node reactor.js)起了一个单线程 TCP 回声服务,同时打进 500 条并发连接:

const net = require('net');
const N = 500;
const t0 = Date.now();
const server = net.createServer(sock => {
  sock.on('data', d => { sock.write('echo:' + d); });   // 就绪才处理
});
server.listen(19091, () => {
  let done = 0;
  for (let i = 0; i < N; i++) {
    const c = net.connect(19091, () => c.write(`req-${i}`));
    c.on('data', d => {
      if (++done === N) {
        console.log(`单进程(pid=${process.pid})单线程事件循环处理 ${N} 条并发连接`);
        console.log(`全部回声正确,耗时 ${Date.now() - t0} ms,未创建任何额外线程`);
        server.close(); process.exit(0);
      }
    });
  }
});

实测输出:

单进程(pid=6512)单线程事件循环处理 500 条并发连接
全部回声正确,耗时 61 ms,未创建任何额外线程

500 条连接在同一个线程上几十毫秒内全部应答——没有线程切换、没有锁,CPU 时间几乎全花在真正的 IO 处理上。这就是「事件驱动为什么赢了」的可测量答案。

深蓝黑背景概念图。上半部分是 Reactor 架构图:最左一列五个「连接 fd」小方块,箭头汇入内核态的「epoll 事件多路复用」框(注「一次调用返回所有就绪 fd」);epoll 向上把就绪事件交给圆形的「Reactor 事件分发器」;分发器再分两支:新连接事件走「Acceptor」建立连接并注册新 fd 回 epoll(紫色回环箭头),读写事件走「Handler」执行读、计算、写,处理完回到 epoll 等待下一轮。下半部分左右两张对比卡:左卡「LT 水平触发」——只要 fd 仍就绪就反复通知,编程容错高,是默认模式;右卡「ET 边缘触发」——状态变化那一刻只通知一次,必须配非阻塞 IO 一次性把数据读完,通知次数最少。底部总结条:「为什么快:没有线程切换与锁竞争,CPU 时间花在真正的 IO 处理上」。 图 2:Reactor 三角色接力与 epoll 两种触发模式。

顺便一提 Proactor。

Reactor 是「IO 就绪了,通知你来读写」;Proactor 是「你把读写请求交出去,操作系统替你做完再通知结果」,依赖真正的异步 IO(如 Windows 的 IOCP)。Linux 上原生异步 IO 长期不成熟,所以主流选择一直是 Reactor + epoll。理解这两条路线的差别,再看各家网络框架的选型就心里有数了。你写的网络服务用的是哪种模式?有没有在 ET 模式下被「读不干净」坑过?欢迎聊聊。