Node 事件循环的六个阶段,和浏览器调度 API 的实际用法

同一段代码,setTimeout(fn, 0)setImmediate(fn) 谁先执行,答案在 Node 里居然不是固定的——放在模块顶层跑,两次执行顺序可能不一样;放进一个 I/O 回调里跑,结果却稳定得像铁律。这个反直觉的现象,是团队要把一个 Vue 中后台的服务端渲染层从纯前端拆出一部分逻辑放到 Node 里跑时,第一个卡住我们的问题。

宏任务、微任务、Promise 的执行顺序这些基础规则,前几年已经反复练过,这篇不再赘述。这篇要讲清楚的是另一层——Node 的事件循环并不是浏览器那套"一个宏任务接一段微任务清空"的简化模型,它内部分成好几个阶段,每个阶段处理不同种类的回调,process.nextTicksetImmediate 的执行时机也和直觉不一样。同时也把这一年在浏览器端用 requestIdleCallbackrequestAnimationFrame 做任务调度的实践一起记下来,两边放在一起看,能看出事件循环这套机制在不同宿主环境里长出了完全不同的形状。

Node 事件循环不是单一队列,是六个阶段轮转

浏览器的事件循环模型可以简化成"一个宏任务、一堆微任务、渲染"这样一个循环。Node 不一样,libuv 把一轮事件循环切成了六个阶段,每个阶段维护自己的一个 FIFO 队列,阶段之间严格按顺序轮转:

1┌───────────────────────┐
2┌─>│        timers          │  执行 setTimeout / setInterval 到期的回调
3│  └──────────┬─────────────┘
4│  ┌──────────┴─────────────┐
5│  │   pending callbacks     │  执行上一轮被延迟的系统级回调(比如某些 TCP 错误)
6│  └──────────┬─────────────┘
7│  ┌──────────┴─────────────┐
8│  │     idle, prepare       │  仅供 Node 内部使用
9│  └──────────┬─────────────┘
10│  ┌──────────┴─────────────┐
11│  │          poll           │  获取新的 I/O 事件,执行 I/O 相关回调
12│  └──────────┬─────────────┘
13│  ┌──────────┴─────────────┐
14│  │         check           │  执行 setImmediate 回调
15│  └──────────┬─────────────┘
16│  ┌──────────┴─────────────┐
17│  │    close callbacks      │  执行 socket.on('close') 这类关闭回调
18│  └──────────┬─────────────┘
19└──────────────┘

我一开始把这六个阶段死记下来,发现意义不大,真正有用的是搞清楚每个阶段"什么时候会卡住不走"。其中 poll 阶段是最关键的一个:它既要处理已经就绪的 I/O 回调,也要决定要不要停下来等新的 I/O 事件。如果 poll 队列不为空,Node 会同步依次执行队列里的回调,直到清空或者达到系统限制;如果 poll 队列为空,Node 会检查有没有 setImmediate 被安排了,有的话立刻结束 poll 阶段去 check 阶段执行它,没有的话才会在 poll 阶段等着(等新请求进来,或者等 timers 阶段的定时器到期)。

这解释了一个我们组之前争论过的现象:同一段代码,setTimeout(fn, 0)setImmediate(fn) 谁先执行,结果在不同调用位置下不一致。

1// 在模块顶层直接跑,setTimeout 和 setImmediate 谁先执行不确定
2setTimeout(() => console.log('timeout'), 0);
3setImmediate(() => console.log('immediate'));

在模块顶层,事件循环刚进入第一轮的 timers 阶段时,1 毫秒的系统时钟误差就足够让结果倒过来,两次运行顺序可能不一样。但如果把这两行放进一个 I/O 回调里,结果就变得确定:

1const fs = require('fs');
2
3fs.readFile(__filename, () => {
4  setTimeout(() => console.log('timeout'), 0);
5  setImmediate(() => console.log('immediate'));
6});
7// 稳定输出:immediate 在前,timeout 在后

原因是 fs.readFile 的回调本身是在 poll 阶段执行的。执行完这个回调之后,事件循环会先走到 check 阶段——setImmediate 排的正是这个阶段,所以立刻被消费;而 timers 阶段要等到下一轮循环才会轮到,setTimeout(fn, 0) 反而要多等一圈。这条规律我后来概括成一句话记住:脱离具体 I/O 上下文单独比较 setTimeout(0)setImmediate 是没有意义的,一旦放进 I/O 回调里,setImmediate 一定先跑。

这里还有一个容易被忽略的细节:poll 阶段并不是无限期等待新 I/O 的。如果当前阶段没有已经到期的定时器要处理,poll 会一直阻塞等待新事件;但只要 timers 阶段里排着未到期的定时器,poll 就会计算出一个最大等待时间,到点就强制跳出,回到 timers 阶段检查有没有定时器到期。这也是为什么 Node 里 setTimeout 的到期时间"基本准"但不是"绝对准"——如果 poll 阶段这一轮正忙着处理一堆 I/O 回调,跳出的时机会往后顺延,定时器实际触发的时间会比设定值晚一点。我们做过一次简单验证,用一个高频写文件的任务占住 poll 阶段,同时挂一个 setTimeout(fn, 50),实际触发时间经常延后到 60~70 毫秒,波动幅度和当时 I/O 的繁忙程度成正比。

setInterval 在 Node 里的漂移问题

排查完 setTimeoutsetImmediate 的关系后,我们组另一个真实的坑是 setInterval 的计时漂移。setInterval(fn, 1000) 表面上是"每隔 1 秒执行一次",但它的语义其实是"上一次回调排入 timers 队列之后,至少等 1 秒才会排下一次",如果回调本身执行耗时,或者当时 poll 阶段占用了主线程,实际间隔会比 1 秒长,而且这个误差是会累积的——用 setInterval 做一个需要长期运行的轮询任务,跑上几个小时之后,实际执行时间点和理论时间点之间的偏差可能达到几秒甚至更多。

这次做服务端渲染层里的一个缓存刷新任务时,我们特意避开了这个坑,改用"自我调度的 setTimeout"代替 setInterval

1// 用 setInterval,如果任务本身耗时不稳定,间隔会累积漂移
2setInterval(() => {
3  refreshCache(); // 假设这个操作耗时会波动
4}, 60000);
5
6// 用递归 setTimeout,下一次调度的起点是"上一次任务结束之后"
7function scheduleRefresh() {
8  setTimeout(() => {
9    refreshCache();
10    scheduleRefresh();
11  }, 60000);
12}
13scheduleRefresh();

第二种写法保证了任务之间至少间隔 60 秒(而不是尝试凑够 60 秒的整点),代价是如果 refreshCache 本身很耗时,总间隔会变长,但至少不会出现"任务还没跑完,下一次又被塞进队列"这种重叠调用的情况。setInterval 遇到回调执行时间超过间隔本身的场景,是不会跳过等待中的那一次的——它会在当前回调执行完之后立刻把已经攒下的那次调用补上,短时间内密集触发好几次,这也是一个容易被忽略的行为,写周期性任务时最好用递归 setTimeout 换掉它。

process.nextTick 不属于这六个阶段中的任何一个

process.nextTick 是 Node 独有的 API,很容易被和 setImmediate 搞混,两者的语义几乎是反的。process.nextTick 注册的回调不属于上面六个阶段中的任何一个,它更像是一个"插队"机制:当前操作结束后,事件循环进入下一个阶段之前,会先把 nextTick 队列清空,优先级比 Promise 微任务还高。

1console.log('start');
2
3process.nextTick(() => console.log('nextTick'));
4Promise.resolve().then(() => console.log('promise'));
5
6setTimeout(() => console.log('timeout'), 0);
7
8console.log('end');
9// 输出顺序:start → end → nextTick → promise → timeout

在 Node 里,nextTick 队列和微任务队列是两个独立的队列,但清空顺序是先 nextTick 后 Promise 微任务,而且这个清空动作会在事件循环的每一个阶段切换点都执行一次,不只是在整个宏任务末尾执行一次。这意味着如果在 nextTick 回调里递归地继续 process.nextTick,同样会造成饿死问题,而且比 Promise 微任务递归更彻底——它会让事件循环连 poll 阶段都进不去,I/O 完全停摆:

1// 这段代码会让 Node 进程卡死,I/O 事件永远得不到处理
2function starve() {
3  process.nextTick(starve);
4}
5starve();

团队里做网关中间件的同事提醒过一个真实的教训:早期 Node 版本里,有人在 HTTP 请求处理逻辑里用 process.nextTick 做"延后一点点执行",本意只是想避开当前调用栈,结果在高并发下 nextTick 队列堆积得比预期快,服务对新连接的响应明显变慢。换成 setImmediate 之后问题消失,因为 setImmediate 排的是 check 阶段,会老老实实等 poll 阶段处理完当前这一批 I/O 事件才轮到它,不会连续抢占。这条经验后来我们写进了内部约定:需要"延后执行但不阻塞 I/O"的场景一律用 setImmediateprocess.nextTick 只用于必须在当前操作立刻之后、不能让任何 I/O 插队的场景(比如构造函数里发事件之前要确保监听器已经绑定好)。

MessageChannel:比 setTimeout 更快的宏任务调度手段

在浏览器里做分片计算,我们组一开始用的都是 setTimeout(fn, 0),后来发现一个问题:如果分片任务嵌套层级较深(一个分片里又通过 setTimeout 调度下一个分片,连续超过 5 层),浏览器会按 HTML 规范把最小延迟钳制到 4 毫秒,几万条数据分成几十片处理,光是这些钳制延迟叠加起来就有明显的耗时增加。

MessageChannel 是一个更适合做"纯粹想尽快让出主线程但不想被钳制延迟"的手段。它本来是设计给两个执行上下文之间通信用的,但利用它的 port.postMessage 触发的回调是一个不受 4 毫秒钳制的宏任务,调度效率比 setTimeout(fn, 0) 更高:

1function scheduleTask(callback) {
2  const channel = new MessageChannel();
3  channel.port1.onmessage = callback;
4  channel.port2.postMessage(null);
5}
6
7function processInChunks(list, handle) {
8  let index = 0;
9  const CHUNK = 1000;
10
11  function run() {
12    const end = Math.min(index + CHUNK, list.length);
13    for (; index < end; index++) {
14      handle(list[index]);
15    }
16    if (index < list.length) {
17      scheduleTask(run);
18    }
19  }
20
21  run();
22}

这个技巧不是我们自己发明的,是社区调度库(比如 React 团队做时间切片时用的方案)里常见的手法,本质上是拿浏览器不限速的宏任务队列替换掉受限速的定时器队列。需要提醒的是它同样是一个宏任务,调度之间浏览器仍然会正常插入渲染帧,不会像前面讲的微任务递归那样饿死渲染,这也是我们后来把 setTimeout 分片全面换成 MessageChannel 分片的原因——同样是"让出主线程",MessageChannel 没有那 4 毫秒的固定损耗。

pending callbacks 和 close callbacks 阶段平时藏在哪

六个阶段里,timerspollcheck 这三个是日常调试打交道最多的,pending callbacksidle/prepareclose callbacks 平时很少被直接提起,容易被当成"内部实现细节"直接略过。实际排查一些底层网络问题时,这两个阶段偶尔会露面。

pending callbacks 阶段处理的是一小类系统级操作的回调,典型场景是某些 TCP 错误——比如 ECONNREFUSED。正常的 TCP 错误会在发生错误的那次 I/O 操作里,紧跟在 poll 阶段被处理掉,但在少数 Linux 系统的实现细节里,有一部分错误报告会被推迟到下一轮循环的 pending callbacks 阶段。我们排查过一个和这个相关的现象:某个下游服务偶尔瞬时不可用,net.Socketerror 事件监听器触发的时机比预期慢了一轮循环,一开始怀疑是自己代码里哪里加了额外的延迟,后来对照 Node 文档才确认这是 pending callbacks 阶段本身的正常调度行为,不是我们代码的问题。

close callbacks 阶段则简单得多,专门执行类似 socket.on('close', ...) 这种"资源关闭"事件的回调,如果 socket.destroy() 是被同步调用的,对应的 close 事件会被推迟到这个阶段而不是立刻触发。这个机制的价值在于把"关闭"这件事和当前正在跑的逻辑解耦开——避免在调用 destroy() 的那行代码所在的调用栈里,立刻又同步触发一堆清理逻辑,把状态搅乱。我们在一次连接池实现里就利用了这一点:连接被标记销毁后,清理引用计数的逻辑特意放在 close 事件回调里而不是紧跟在 destroy() 调用之后,图的就是这份时序上的隔离。

浏览器端:requestAnimationFrame 和 requestIdleCallback 各管一段调度

Node 的六阶段模型解决的是"I/O 密集场景下怎么公平地轮转任务",浏览器这边的调度问题不一样,核心矛盾是"怎么在不影响渲染帧率的前提下,把非紧急的 JS 计算见缝插针地做完"。这一年 Core Web Vitals(LCP、FID、CLS)从 5 月起正式成为 Google 的搜索排名信号,团队里做首页性能优化时,我们对这两个调度 API 用得比以前认真了很多。

requestAnimationFrame(下面简称 rAF)的回调会在浏览器下一次重绘之前执行,时机比宏任务队列里普通的 setTimeout 更贴近渲染节奏,因为它是跟着屏幕刷新率走的,通常是每秒 60 次左右,而不是像定时器那样自己定一个时间间隔。凡是要和动画、滚动、布局读写打交道的调度,我们现在基本上不用 setTimeout 卡时间戳去模拟帧率,而是直接交给 rAF:

1// 用时间戳节流模拟动画,帧率和实际刷新率对不上,容易掉帧或卡顿
2let last = 0;
3function tick(ts) {
4  if (ts - last > 16) {
5    render();
6    last = ts;
7  }
8  requestAnimationFrame(tick);
9}
10
11// 更贴近浏览器真实节奏的写法:直接信任 rAF 的回调时机
12function tickBetter() {
13  render();
14  requestAnimationFrame(tickBetter);
15}
16requestAnimationFrame(tickBetter);

requestIdleCallback(下面简称 rIC)解决的是另一头的问题:浏览器每一帧处理完样式计算、布局、绘制之后,如果还有剩余时间,会在下一帧开始之前调用 rIC 注册的回调,把这段"空闲时间"让给不紧急的工作。它的回调会拿到一个 deadline 对象,可以查询 timeRemaining() 知道这一帧还剩多少余量,做到不超时就退出:

1function processQueue(deadline) {
2  while (deadline.timeRemaining() > 0 && tasks.length > 0) {
3    const task = tasks.shift();
4    task();
5  }
6  if (tasks.length > 0) {
7    requestIdleCallback(processQueue);
8  }
9}
10requestIdleCallback(processQueue);

我们用它处理过一批埋点上报的批量序列化逻辑:这些计算不着急,用户交互和渲染的优先级明显更高,之前直接同步跑会偶尔造成掉帧,改成 rIC 之后,浏览器忙的时候这些任务会自动往后挪,浏览器闲下来才执行,感知不到的卡顿基本消失了。需要说明的是 rIC 目前 Safari 还不支持,我们只在能力检测通过之后才用,检测不到就降级成 setTimeout 分片,这条兼容路径不能省。

这两个 API 和前面讲的宏任务、微任务不是平行关系,而是浏览器在"下一次渲染"前后各自开的一个特殊时间窗:微任务在渲染之前必须清空,rAF 回调紧贴渲染之前执行,rIC 回调则要等渲染完、还有空闲才轮到,三者的先后顺序大致是"微任务清空 → rAF → 浏览器渲染 → 如果有空闲则 rIC"。把这条顺序放进脑子里,再去看那些动画卡顿、埋点阻塞主线程的问题,基本都能对上号。

一次页面失去响应的排查:微任务把主线程占满了

上个月接手一个数据看板页面的性能问题,现象是用户切换某个筛选条件后,页面会卡住一两秒钟,输入框打字都没反应,DevTools 的 Performance 面板录了一段之后,看到主线程时间轴上有一大段标着 Run Microtasks 的紫色块,中间夹杂着几十次同样的调用栈。

顺着调用栈往回查,问题出在一个数据分组的辅助函数上,写法大致是这样:

1// 想把一个大数组按类别分组,用 Promise 链递归处理每一项
2function groupByCategory(list, index, result) {
3  if (index >= list.length) return Promise.resolve(result);
4  const item = list[index];
5  (result[item.category] = result[item.category] || []).push(item);
6  return Promise.resolve().then(() => groupByCategory(list, index + 1, result));
7}

作者的本意大概是想"分批"处理几万条数据,避免一次同步循环卡死主线程,于是每处理一项就包一层 Promise.resolve().then() 递归调用下一项。这个思路的方向是对的——确实不应该用一个同步的 for 循环把几万条数据一口气处理完——但用微任务递归来"让出主线程"是个误解。微任务和宏任务不一样,它不会把执行权真正让给浏览器去处理渲染或者用户输入,因为一轮微任务队列必须清空之后浏览器才有机会喘气,而这里每次 then 都会立刻产生新的微任务,相当于队列永远不空,几万次递归全部挤在同一轮清空里跑完,跟同步循环几乎没有区别,只是多了些 Promise 调度的额外开销。

排查到这一步基本可以确定:症状虽然是"卡顿",根子和 2018 年那次死循环、以及前面提到的递归 nextTick 是同一类问题——都是把本该分给宏任务和渲染的执行权,锁死在了不肯让路的那层调度里。区别只是死循环立刻能看出来,微任务递归因为代码长得"很异步",反而更容易被当成已经做了优化。

修复思路是把递归改回真正会让出主线程的宏任务调度,用 setTimeout 或者 MessageChannel 分片,让浏览器在每一批处理完之后真的有机会去做一次渲染:

1function groupByCategoryChunked(list, onDone) {
2  let index = 0;
3  const result = {};
4  const CHUNK = 1000;
5
6  function run() {
7    const end = Math.min(index + CHUNK, list.length);
8    for (; index < end; index++) {
9      const item = list[index];
10      (result[item.category] = result[item.category] || []).push(item);
11    }
12    if (index < list.length) {
13      setTimeout(run, 0);
14    } else {
15      onDone(result);
16    }
17  }
18
19  run();
20}

改完之后再录一次 Performance,Run Microtasks 的那一大段紫色块消失了,取而代之的是一串很短的任务块,中间穿插着渲染帧,输入框在处理过程中也能正常响应。这个案例让我确认了一条判断标准:看到"想分批却用了 Promise 递归"这种写法,基本可以断定作者把"微任务"和"能让浏览器渲染的间隙"当成了一回事,这两者并不等价,只有宏任务之间才有渲染的机会插进去。

在 Performance 面板里分辨"长任务"和"微任务风暴"

前面那次数据看板卡顿的排查,能这么快定位到微任务递归,靠的是先学会在 Chrome DevTools 的 Performance 面板里分清几种长得很像的卡顿现象。同样是主线程被占满,至少有三种不同的图形特征,混在一起看很容易误判。

第一种是普通的长任务:一段同步函数自己跑得久,时间轴上会看到一个单独的、宽度很大的灰色任务块,调用栈往下点开就是那一个函数自己在执行,没有嵌套的调度痕迹。这种问题的解法通常是拆分算法或者换用 Web Worker,跟事件循环调度关系不大,纯粹是计算量的问题。

第二种是宏任务密集调度:时间轴上会看到一串很短的任务块首尾相连,中间偶尔能看到渲染帧(LayoutPaint 这些紫色和绿色的小块),点开每个任务块,调用栈里能看到 setTimeout 或者 MessageChannel 的回调入口。这种情况说明分片是生效的,浏览器仍然有机会做渲染,只是分片数量比较多导致总耗时长,一般不需要紧急处理,除非分片太碎导致调度开销本身变得可观。

第三种就是微任务风暴:时间轴上会看到一段异常长的 Run Microtasks 标记,这段时间里完全看不到任何渲染帧穿插进来,点开这个标记展开调用栈,会看到同一个函数被反复调用几十次甚至上千次。这是最容易被忽略的一种,因为初看上去和第二种"任务块首尾相连"有点像,区别就在于中间有没有渲染帧的痕迹,以及外层标记是不是统一的 Run Microtasks 而不是一个个独立的 Task。数据看板那次问题最初我也差点把它当成"分片计算,只是分得不够细",后来注意到整段时间里一次渲染帧都没有,才反应过来这是微任务在自己跟自己排队,浏览器根本没有被放行的机会。

分清这三种之后,对应的修复方式也完全不同:长任务要拆算法或挪去 Worker,宏任务密集调度可以调大分片粒度或者换用 MessageChannel 减少调度损耗,微任务风暴则必须先把递归的微任务链路改造成宏任务链路,光调整分片大小是没用的,因为问题的根子不在"片有多大",而在"用错了不会真正让出主线程的调度方式"。

SSR 场景下 Node 事件循环和请求并发的关系

回到最初拆分服务端渲染层这件事本身。团队用 webpack 5 把一部分中后台页面的首屏渲染挪到 Node 里做,好处是首屏内容能直接吐给浏览器,减少白屏时间;但这也意味着一次页面请求要在 Node 里跑一段渲染逻辑,如果这段逻辑写得不小心,会直接影响其他并发请求的响应速度,这一点和纯前端场景很不一样——纯前端卡住的是一个用户自己的页面,Node 服务端卡住的是同一个进程里所有正在处理的请求。

我们排查过一次 QA 反馈的问题:压测时并发量一高,部分请求的响应时间会离谱地变长,单独发请求测却完全正常。查下来是渲染函数里有一段同步的字符串拼接和正则替换,处理的模板本身不大,单次执行也就几毫秒,但在压测的并发量下,这几毫秒的同步计算会在 poll 阶段之外一直占着主线程——Node 的 HTTP 处理回调本身运行在事件循环里,某个请求的渲染逻辑在跑同步代码时,其他请求的回调只能排队等着,谁都插不进来。

1// 同步渲染函数,计算量看似不大,但会阻塞同一进程里的其他请求
2function renderPage(data) {
3  let html = template;
4  for (const key in data) {
5    html = html.replace(new RegExp(`{{${key}}}`, 'g'), data[key]);
6  }
7  return html;
8}

这类问题在单机测试时很难暴露,因为单个请求的耗时确实很短,只有在真实并发下,多个请求的同步耗时才会叠加成可观的排队延迟。最终的处理方式是把模板换成预编译的渲染函数(避免每次请求都重新构造正则),同时把渲染逻辑里可以后置的部分改成异步执行,减少单次占用主线程的时间片。这件事让我对"Node 是单线程"这句话有了新的理解维度:在浏览器里,单线程影响的是一个用户的体验;在 Node 服务端,单线程意味着一次请求的同步计算是在直接消耗所有并发请求共享的那一份执行时间,这也是为什么 Node 官方文档反复强调,CPU 密集型任务不适合直接放在主事件循环里跑,该用 worker_threads 或者拆到独立服务里做。

Promise.any 和事件循环调度结合起来看

ES2021 新增的 Promise.any 这一年也进了我们的工具箱,它和事件循环调度放在一起看,能解释清楚一类"多个异步源竞速,谁先完成就用谁"的场景。它和早已熟悉的 Promise.race 不一样:race 是谁先落定(无论成功还是失败)就用谁的结果,any 是等到第一个成功的结果,只有全部都失败才会走进 catch,抛出一个 AggregateError

我们在做一个多 CDN 容灾的图片加载策略时用上了它:同一张图有两三个不同 CDN 的地址,只要有一个先加载成功就用它,不必等其他几个:

1function loadFromAnyCdn(urls) {
2  return Promise.any(
3    urls.map(
4      (url) =>
5        new Promise((resolve, reject) => {
6          const img = new Image();
7          img.onload = () => resolve(url);
8          img.onerror = reject;
9          img.src = url;
10        })
11    )
12  );
13}

这里面事件循环层面值得注意的一点是:每个 Imageonload/onerror 都是通过浏览器的图片加载线程完成后,把对应回调作为宏任务塞回主线程队列的,Promise.any 内部维护的计数器和状态判断则是在这些宏任务执行完之后,通过微任务去做的。也就是说即使三个地址几乎同时加载完成,最终决定"用哪一个"的判断逻辑仍然是严格串行的——先到先得,不存在竞态条件下两个回调同时修改 any 内部状态的问题,这也是 Promise 系列 API 一直能保持"状态只能被决议一次"这个特性的原因,任何决议逻辑都被封装在微任务这个单线程的执行序列里完成。

Worker Threads 和主线程之间的消息是宏任务

Node 端这一年我们还评估过 worker_threads,用来处理一些真正意义上的 CPU 密集任务(比如给大批量商品数据做本地的相似度计算),彻底避开事件循环的排队问题。worker_threads 让你在同一个进程里开出真正的操作系统线程,各自维护自己独立的一套事件循环,主线程和 worker 线程之间通过 postMessage 通信。

1const { Worker } = require('worker_threads');
2
3const worker = new Worker('./similarity-worker.js', {
4  workerData: { batchSize: 5000 },
5});
6
7worker.on('message', (result) => {
8  console.log('worker 算完一批,结果长度', result.length);
9});
10
11worker.postMessage({ items: bigList });

这里有个第一次接触时容易理解错的地方:worker.on('message', ...) 这个回调,是在主线程的事件循环里作为宏任务执行的,触发时机取决于主线程当前是不是空闲,即使 worker 线程早就算完了结果,主线程如果正被别的任务占着,消息回调也得排队等。换句话说,引入 worker_threads 解决的是"重计算不占用主线程的执行时间"这个问题,但主线程本身仍然只有一份事件循环,仍然遵守"取一个宏任务、清空微任务"这套规则,worker_threads 不会让主线程绕开这套规则,它只是把重计算挪到了另一个完全独立的线程和另一套事件循环里去跑,两边通过消息传递(本质上是序列化之后的宏任务投递)保持联系。这条边界感很重要:以为用了 worker_threads 就能让主线程"并行"处理很多事情,是个常见的过度期待,它解决的只是"别把耗时计算挤占主线程",而不是让主线程本身变成多线程。

动态 import 和微任务队列的隐藏关联

团队这一年在评估 webpack 5 的 Module Federation,想把几个中后台系统里公共的组件库和工具函数做成远程模块,各个子系统按需加载,不用每次都打进自己的 bundle。落地过程中我们踩到一个和事件循环相关的细节:动态 import() 返回的 Promise,它的决议时机比想象的更晚一点,因为浏览器要先完成模块的网络请求(或者从缓存读取),再对模块内容做解析和求值,这几步都不是微任务能完成的,中间必然会经过至少一次宏任务级别的调度。

这在写"预加载 + 立即使用"的逻辑时容易踩坑:

1// 想在空闲时预加载远程模块,用户点击时立刻拿到已经加载好的模块
2let modulePromise = null;
3
4function preloadRemoteModule() {
5  modulePromise = import('remoteApp/Button');
6}
7
8function useRemoteButton() {
9  // 如果这里比 preload 调用晚不了几毫秒,modulePromise 可能还没决议
10  return modulePromise.then((mod) => mod.default);
11}

import() 背后既有网络请求(如果模块还没被浏览器缓存)也有脚本求值这类只能通过宏任务(load 事件、脚本执行完成回调)串起来的步骤,不能简单地把它当成一个"和 Promise.resolve() 一样快"的微任务源头去做时序假设。我们测过,即便远程模块体积很小、缓存命中,import() 的决议时机通常也会比同一批发出的 Promise.resolve().then() 慢至少一轮宏任务,因为浏览器内部处理模块图(module graph)的逻辑本身是排在脚本执行的宏任务队列里的,不会插进当前这一轮微任务清空过程。这条经验直接影响了我们做"路由切换时预加载下一屏远程组件"这类优化时的预期管理:预加载能明显减少用户等待感,但不能假设它一定能在用户点击的那个事件循环轮次里就绪,该有的 loading 兜底还是要留着。

请求取消和已经排队的回调之间的时间差

评估 Module Federation 的同时,我们也顺带清理了一批老代码里对 fetch 请求取消的处理,这里同样能看到事件循环调度带来的一个反直觉现象。用 AbortController 取消一个尚未完成的请求,调用 abort() 之后,浏览器会尽快中止底层的网络传输,但已经排进微任务队列或者宏任务队列、等着被执行的那部分回调,并不会因为调用了 abort() 就立刻消失——它们仍然会按原来的顺序被事件循环取出来执行,只是执行的时候 fetch 返回的 Promise 会以 AbortError 的状态被拒绝。

1const controller = new AbortController();
2
3fetch('/api/list', { signal: controller.signal })
4  .then((res) => res.json())
5  .then((data) => {
6    // 如果 abort 发生在这一步之前,这里不会执行,
7    // 会转而走进下面的 catch
8    render(data);
9  })
10  .catch((err) => {
11    if (err.name === 'AbortError') {
12      console.log('请求被取消,忽略后续处理');
13    }
14  });
15
16// 触发取消
17controller.abort();

我们组之前有一处列表搜索的逻辑,每次输入变化都会取消上一次请求、发起新请求,但取消和重新渲染之间偶尔会出现短暂的"闪一下旧数据",追查后发现问题不在 abort 本身,而在别处——某次请求已经在网络层完成、结果的 .then 回调已经被排进了微任务队列,紧接着用户又输入了一个字符触发了 abort 调用,但 abort() 影响的是"进行中"的请求,对已经完成、只是回调还没被事件循环取出来执行的这次请求毫无作用。真正的修复方式不是指望 abort 能拦截已经排队的回调,而是在发起新请求时同时记录一个自增的请求序号,回调执行时先检查这个序号是否还是最新,不是最新就直接丢弃结果:

1let requestSeq = 0;
2
3function search(keyword) {
4  const seq = ++requestSeq;
5  fetch(`/api/list?q=${keyword}`)
6    .then((res) => res.json())
7    .then((data) => {
8      if (seq !== requestSeq) return; // 已经有更新的请求了,这次结果作废
9      render(data);
10    });
11}

这个教训的核心是:事件循环只保证回调按顺序被处理,不保证"取消"这个动作能追溯性地清空已经在队列里等着的回调,凡是有时序竞争的地方,业务层自己维护一个序号或者版本号,比指望底层 API 帮你拦截更可靠。

Node HTTP keep-alive 连接和 poll 阶段的资源占用

补全 SSR 层的过程中,还有一处和 poll 阶段直接相关的实践值得记一下。Node 默认的 http.Agent 会对每个目标主机维持一定数量的 keep-alive 连接,这些连接在没有请求在传输的时候,仍然会在底层注册一个 socket 的可读事件监听,等着服务端可能发来的数据或者连接关闭通知——这意味着即使业务上没有任何请求在进行,poll 阶段仍然要在每一轮循环里检查这些空闲连接的状态。

这本身不是问题,socket 监听的开销很小,但我们在压测时发现一个关联现象:如果对某个下游服务的并发请求量很大,又没有显式设置 maxSockets 上限,Node 会不断创建新连接而不是复用已有连接,poll 阶段需要检查的 socket 数量线性增长,虽然单次检查的耗时依然很短,但在极高并发下这部分开销会变得可以观察到。调整的方式是显式配置连接池上限,让请求排队复用有限的几条连接,而不是无节制地新建:

1const http = require('http');
2
3const agent = new http.Agent({
4  keepAlive: true,
5  maxSockets: 50,
6  maxFreeSockets: 10,
7});
8
9http.get('http://downstream-service/api/data', { agent }, (res) => {
10  // ...
11});

这条经验和前面讲的所有内容其实都指向同一个方向:事件循环模型看着是纯粹的调度理论,但无论在浏览器还是 Node 里,它最终都要落到具体资源(socket、定时器、渲染帧)的分配上,理解调度规则的意义,不只是为了推对输出题,而是为了在做架构决策时,能预判某个选择会给这套调度机制增加什么样的负担。

落地时的几个补充判断

Node 16 上个月刚发布,团队里还在用 Node 14 这条 LTS 线,暂时没有升级计划,但 Node 16 对 Timer 阶段的一些边界行为做了微调,等对应版本进入 LTS 我们再评估要不要跟。眼下用得上的结论主要是这几条。

判断一段延迟逻辑该用 setTimeout(0) 还是 setImmediate,先看它是不是发生在 I/O 回调之后:在 fsnethttp 这类回调内部,setImmediate 的语义更贴近"这批 I/O 处理完之后立刻做",行为比 setTimeout(0) 稳定,不用担心系统时钟误差。判断该不该用 process.nextTick,先问这段逻辑是否必须抢在任何后续 I/O 之前完成——如果只是想"延后一点点又不太紧急",setImmediate 更安全,不容易在高并发下把 nextTick 队列堆起来。判断动画和调度该用 rAF 还是 rIC,看这段逻辑是不是直接影响画面:直接影响就用 rAF,跟渲染没有强关联、能等的用 rIC,两者都不合适、又要兼顾兼容性的场景,分片 setTimeout 依然是最保底的方案。

判断该不该把一段计算挪到 worker_threads 里,标准也可以拆成一句话:这段计算单次耗时是不是已经接近或者超过了业务能容忍的单个宏任务时长(一般几毫秒到十几毫秒是个警戒线,具体数字要看页面或者接口本身的响应预期)。低于这个线,分片 setTimeout 或者 MessageChannel 通常够用;高于这个线还想留在主线程里靠分片硬撑,分片本身的调度开销反而会成为新的负担,这时候换独立线程或者独立进程才是对的方向,而不是把分片粒度切得更细来自欺欺人。

回到最开始那个 setTimeout(fn, 0)setImmediate(fn) 谁先谁后的问题,现在已经不需要靠记忆去背结论——只要想清楚当前代码是不是运行在某个 I/O 回调内部,poll 阶段和 check 阶段谁先谁后自然就能推出答案,不需要死记一张对照表。这也是这次把 Node 六阶段模型和浏览器的调度 API 放在一起过一遍最大的收获:宏任务、微任务这层规则决定了"谁先跑",但决定"跑不跑得顺畅"的,其实是这套规则之外的那些资源约束——socket 数量、定时器精度、渲染帧的时间预算。把这两层放在一起看,遇到新的调度类问题时,才知道该往哪个方向去拆解。