图 1:本文封面,五种模型的差别全在「谁在等、等哪段」。
epoll 和 select 的区别、epoll 的 LT 与 ET,这两个问题之前的真题解析已经讲过,本文不再重复。今天往后退一步,看清 epoll 所在的整张地图:Unix 网络编程的经典划分里,IO 模型一共有五种,epoll 只是其中一种模型的一个实现。看懂这张谱系,再看 Reactor 模式怎么把多路复用落地成高性能网络服务,网络编程的底子就齐了。
先立一把尺子:一次读操作有两个阶段。
任何一次「从 socket 读数据」都要经过两段:第一阶段,等数据到达网卡、进入内核缓冲区(等待数据就绪);第二阶段,把数据从内核缓冲区拷贝到用户态的 buffer(拷贝数据)。五种 IO 模型的全部差异,就是调用线程在这两个阶段里分别等什么、怎么等。抓住这把尺子,五种模型不用背,推都推得出来。
五种模型,逐个对号入座。
阻塞 IO:调用 read,两个阶段都等。数据没来,线程挂着;数据来了,拷贝完才返回。最简单,也最贵——一个连接占住一条线程。
非阻塞 IO:read 立即返回,没数据就报 EAGAIN,应用自己轮询。第一阶段不阻塞了,但轮询本身烧 CPU;第二阶段拷贝仍然是同步的,拷贝期间线程照样被占用。
IO 多路复用:用一个系统调用(select、poll、epoll)替一批连接统一「等就绪」,哪个连接就绪了再对它发起真正的 read。本质是把「第一阶段逐连接等待」换成「一次等待覆盖所有连接」。注意:第二阶段的拷贝还是同步的——多路复用解决的是等待的规模化,不是拷贝的异步化。Linux 的 epoll 把「注册的连接集合」(interest list)和「就绪的连接集合」(ready list)分开维护,就绪事件由内核直接填充,这是它在大连接量下比 select 高效的结构性原因。
信号驱动 IO:注册 SIGIO 信号处理,数据就绪时内核发信号,应用收到信号再发起 read。第一阶段连等都不用等,但第二阶段拷贝依然是同步的。工程上很少用,信号与主流程的协作太脆。
异步 IO:调用 aio_read 之类的接口,把两阶段都交给内核,数据拷贝到用户态之后才通知应用「活儿干完了」。这是唯一一种两个阶段都不占用调用线程的模型。
谱系收束:前四种都是同步,只有第五种是真异步。
这是 Stevens 在《UNIX 网络编程》里给出的关键论断:判断同步还是异步,看第二阶段——拷贝数据这一步是否占用调用线程。阻塞、非阻塞、多路复用、信号驱动,拷贝都要调用线程自己发起,所以它们全是同步 IO;只有异步 IO 两阶段都不沾线程。所谓「同步非阻塞」并不矛盾,同步描述的是第二阶段,非阻塞描述的是第一阶段。
Reactor:多路复用的工程落地。
知道五种模型还不够,工程上没人裸写 epoll。Reactor 模式把它组装成一个可复用的结构:一个事件环(Reactor)调用 epoll_wait 收就绪事件,按事件类型分发给注册的 handler,handler 里的耗时计算丢给业务线程池。事件环永远只做轻量的等待与分发,重活在池子里并行——单条线程因此能同时看护上万条连接。Node.js 的 event loop、Netty 的 EventLoop,都是这个范式的具体实现。
用代码把两种模型的时间线跑出来。
下面用逻辑时钟模拟三条连接:数据分别在 10/20/30ms 就绪,读到后要算 50ms。阻塞模型一个线程挨个等,Reactor 统一等就绪、计算并行。用 Node.js 22.15.0 直接运行(node reactor.js):
'use strict';
// 玩具 Reactor:单线程事件环 + 业务线程池,对照纯阻塞模型(逻辑时钟,刻度固定)
function simulate(mode) {
// 三条连接:dataAt = 数据就绪时刻,workMs = 读到数据后的计算耗时
const conns = [
{ id: 'c1', dataAt: 10, workMs: 50 },
{ id: 'c2', dataAt: 20, workMs: 50 },
{ id: 'c3', dataAt: 30, workMs: 50 },
];
let now = 0;
const done = new Map();
if (mode === 'blocking') {
// 阻塞模型:一个线程挨个 read,等不到数据就一直等
for (const c of conns) {
now = Math.max(now, c.dataAt); // 等待数据就绪(线程被挂住)
now += c.workMs; // 拷贝 + 计算都在这条线程上
done.set(c.id, now);
}
} else {
// Reactor:事件环只负责等就绪与分发,计算丢给线程池并行
const pending = conns.map((c) => ({ ...c }));
while (pending.length > 0) {
const nextReady = Math.min(...pending.map((c) => c.dataAt));
now = Math.max(now, nextReady); // epoll_wait 式等待最近的就绪事件
const ready = pending.filter((c) => c.dataAt <= now);
for (const c of ready) {
done.set(c.id, now + c.workMs); // 分发后计算并行完成
pending.splice(pending.indexOf(c), 1);
}
}
}
const total = Math.max(...done.values());
return { total, done: [...done.entries()].map(([id, t]) => `${id}@${t}ms`).join(' ') };
}
const b = simulate('blocking');
const r = simulate('reactor');
console.log(`阻塞模型:总耗时 ${b.total}ms(${b.done})`);
console.log(`Reactor:总耗时 ${r.total}ms(${r.done})`);
console.log(`同样的三条连接,Reactor 省下 ${b.total - r.total}ms 的串行等待`);
实测输出:
阻塞模型:总耗时 160ms(c1@60ms c2@110ms c3@160ms)
Reactor:总耗时 80ms(c1@60ms c2@70ms c3@80ms)
同样的三条连接,Reactor 省下 80ms 的串行等待
阻塞模型的总耗时是所有等待与计算的串行累加:160ms。Reactor 里事件环只花一次等待拿到就绪事件,计算并行推进,总耗时被压到 80ms——连接越多、等待越碎片化,这个差距拉得越开。这就是「一个线程撑起一个网关」的数学基础。
图 2:两阶段尺子量五种模型,Reactor 是多路复用的工程形态。
落回工程判断。
选型时记住三件事:连接少且逻辑简单,阻塞 IO 加线程池就够用,别为了技术上档次硬上异步;连接成千上万,多路复用是唯一现实的答案;追求极致且平台支持(比如 Linux 的 io_uring),才轮到真异步登场。五种模型不是进化链,是工具箱——按连接规模与延迟要求取用。
下次再被问「epoll 为什么快」,别只答红黑树和回调——先把两阶段尺子摆出来,说明多路复用解决的是哪个阶段的等待,再讲 Reactor 怎么把它包成工程范式,这个回答才算完整。你写网络服务时踩过「伪异步」的坑吗?欢迎分享。