Node.js 事件循环深入:阶段、优先级与实战陷阱
本文目录
Node.js 的并发模型一句话可以说完:JS 执行是单线程的,靠事件循环驱动异步 I/O。但这句话里的两个细节决定生产的生死:所有请求共享那一个 JS 线程,任何一个同步阻塞都会挂死全部请求;异步 I/O 的"并发"有一部分来自 libuv 线程池,池子有大小、会排队。这篇文章把事件循环拆到阶段与队列级别,讲清微任务时机、setTimeout/setImmediate 的顺序问题、poll 阻塞机制、阻塞检测、让步策略、Worker Threads 与线程池调参。示例基于 Node.js 20/22 LTS,行为以官方文档为准。
六个阶段:一轮循环的完整走法
┌───────────────────────────────┐
│ timers │ ← setTimeout / setInterval 到期回调
├───────────────────────────────┤
│ pending callbacks │ ← 部分系统操作的延迟回调(如 TCP 连接错误)
├───────────────────────────────┤
│ idle, prepare │ ← 内部使用,业务代码不感知
├───────────────────────────────┤
│ poll │ ← 取出并执行 I/O 事件回调;可能阻塞等待
├───────────────────────────────┤
│ check │ ← setImmediate 回调
├───────────────────────────────┤
│ close callbacks │ ← close 事件(socket 关闭等)
└───────────────────────────────┘
每执行完一个阶段、每处理完一个宏任务,控制权回到微任务队列——先清空整个 process.nextTick 队列,再清空整个 Promise 微任务队列,然后才进入下一阶段。这就是"微任务优先于所有宏任务"的准确含义。
| 阶段 | 执行什么 | 实际关联的坑 |
|---|---|---|
| timers | 到期的 setTimeout/setInterval | 定时器只有 1ms 粒度且有嵌套层级惩罚,不能当精确延时用 |
| pending callbacks | 少数延迟到下一轮的系统回调 | 名字容易和 Promise 混淆,和微任务无关 |
| poll | I/O 事件回调;计算本轮阻塞时长 | 事件循环空闲时阻塞在这里等事件,不是空转 |
| check | setImmediate | I/O 回调后立刻执行的确定性通道 |
| close callbacks | close 事件 | 忘记销毁句柄时,资源要等到这里才释放 |
一个高频误区先纠正:事件循环空闲时CPU 并不是 100%——poll 阶段会以计算出的超时阻塞等待新事件(类似条件变量),空闲的 Node 进程 CPU 接近零。如果你观察到空闲高 CPU,那是某处存在忙循环或泄漏的轮询,本身就是问题信号。
微任务时机:nextTick 先于 Promise
Promise.resolve().then(() => console.log('promise 1'));
process.nextTick(() => console.log('nextTick 1'));
process.nextTick(() => {
console.log('nextTick 2');
Promise.resolve().then(() => console.log('promise 2 (在 nextTick 内排队)'));
});
queueMicrotask(() => console.log('queueMicrotask'));
setTimeout(() => console.log('setTimeout'), 0);
// 输出:
// nextTick 1
// nextTick 2
// promise 1
// promise 2 (在 nextTick 内排队) ← nextTick 清空后,轮到整个 Promise 队列
// queueMicrotask
// setTimeout ← 宏任务最后
两条规则值得背下来:process.nextTick 队列整体优先于 Promise 微任务队列(queueMicrotask 与 then 同队列);微任务在"阶段之间"清空,不是等一轮循环结束——一个 I/O 回调执行完,微任务立刻全部执行,然后才轮到 poll 的下一个回调。
饿死风险来自这条优先级链的滥用:递归 process.nextTick 会在同一次阶段切换里无限自我续队,I/O 永远轮不到。旧版本 Node 有 maxTickDepth 警告,现代版本(Node 12+)移除了这个限制——递归 nextTick 不再报错,只会安静地饿死整个进程。同场景用 setImmediate 续队则没有这个问题:它排到 check 队列,每轮循环 I/O 都有机会执行。"循环处理 + 继续下一批"的续接点永远用 setImmediate,不要用 nextTick。
setTimeout(0) 与 setImmediate:顺序不定性
主模块里直接跑这两个,先后顺序不定:
setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));
// 输出顺序不定:取决于进入 timers 阶段时 1ms 定时器是否已到期。
// 这受进程启动时刻的时钟精度影响,是环境敏感行为。
原因在第一轮循环前的准备工作耗时有波动:如果进入 timers 阶段时定时器已到期,先输出 timeout;否则 poll 空转进入 check,先输出 immediate。但在 I/O 回调内部,顺序是确定的:
const fs = require('node:fs');
fs.readFile(__filename, () => {
setTimeout(() => console.log('timeout'), 0); // 下一轮 timers
setImmediate(() => console.log('immediate')); // 本轮 check:poll 后紧跟 check
});
// I/O 回调里永远先 immediate 后 timeout
工程结论:任何依赖"setTimeout(0) 先于/后于 setImmediate"的代码都是 bug,主模块的不定性会被运行环境放大成偶现故障;需要"I/O 之后立刻、且确定在后续 timers 之前"执行的动作,用 setImmediate(这正是 stream 内部用它的原因)。
poll 阶段:阻塞时长的三条规则
poll 是事件循环的"主战场",它决定本轮等多久:
- 有 timer 已到期或即将到期:阻塞时长取最近的 timer 到期时间,到点回 timers。
- check 队列有
setImmediate待执行:不阻塞,立即结束 poll 进 check。 - 两者都没有:按事件循环的内部超时阻塞等待新 I/O 事件,空闲即挂起。
规则 2 有个实用的推论:只要进程里持续有 setImmediate 被调度,poll 就不会被长时间阻塞——这正是下一节"分片让步"让请求延迟下降的机制基础:分片循环之间插入 setImmediate,等于强制事件循环把 poll 里的积压 I/O 回调先处理掉。
事件循环阻塞:后果与检测
阻塞的后果链要完整理解:一个 300ms 的同步 CPU 任务(大 JSON 解析、深递归序列化、密码哈希),加到的不是"那个请求"上,而是整个进程的全部在途请求——所有请求的响应时间一起 +300ms。更隐蔽的次生灾害:健康检查超时被负载均衡摘除(但流量仍在打过来)、keepalive 心跳失败、上游的客户端超时重试叠加成流量放大。
检测第一工具是内置的 perf_hooks.monitorEventLoopDelay,它是直方图(纳秒粒度桶),开销很低可以常开:
import { monitorEventLoopDelay, IntervalHistogram } from 'node:perf_hooks';
const h = monitorEventLoopDelay({ resolution: 20 }); // 20ns 粒度
h.enable();
setInterval(() => {
console.log({
meanMs: (h.mean() / 1e6).toFixed(2),
p99Ms: (h.percentile(99) / 1e6).toFixed(2),
maxMs: (h.max / 1e6).toFixed(2),
});
h.reset(); // 周期重置,看的是滚动窗口
}, 30_000).unref();
告警看 P99 与 max 而不是均值(一次 500ms 的尖刺在均值里会消失)。定位阻塞源的第二种做法是"看门狗"思路:一个高频 tick 计时器,一旦两次 tick 间隔超过阈值就抓当时的执行栈:
function installBlockDetector(thresholdMs = 100): void {
let last = performance.now();
const timer = setInterval(() => { // 不会被阻塞影响的是"到点后排队",间隔能反映延迟
const delta = performance.now() - last;
last = performance.now();
if (delta > thresholdMs) {
console.warn(`event loop blocked ${delta.toFixed(0)}ms`); // 生产环境接告警与抓栈
}
}, 20);
timer.unref();
}
社区同类工具(blocked-at 类)在此思路上加了异步抓栈,能直接打印阻塞期间的调用栈。找到"阻塞了"之后,热点定位用官方内置 profiler,不需要引第三方依赖:
# CPU 火焰图:--cpu-prof 直接产出 .cpuprofile,用 DevTools/Inspector 加载分析
node --cpu-prof --cpu-prof-dir=./profiles server.mjs
# 压测期间收集,结束后在 profile 里按"Self Time"排序,前几个栈就是阻塞元凶
上线建议:监控先常开、告警阈值从 100ms 起调,等定位到热点再深入。
让步:分片处理与 setImmediate
批量 CPU 任务(导出报表、聚合计算、全量比对)放主线程的办法是拆片 + 让步:
async function processBatched<T>(items: T[], batchSize: number, handler: (batch: T[]) => Promise<void>) {
for (let i = 0; i < items.length; i += batchSize) {
await handler(items.slice(i, i + batchSize));
await new Promise<void>((resolve) => setImmediate(resolve)); // 让出:poll 有机会处理 I/O
}
}
让步的真实收益是延迟分布:不做让步时,一个 5 万条的处理会把请求 P99 抬高几百毫秒;让步后处理期间的新请求延迟接近正常。代价是总时长变长(让步点有调度开销),所以批大小要权衡:太小让步开销占比高,太大让步间隔不够密,按"单批 5–20ms"起步调整。注意 await 只对 I/O 让出有效——async 函数里写同步循环,await 不会把 CPU 还回去,这是最常见的自欺欺人写法。
大文件与大集合的第一选择仍是流(背压自动调节数据速率),让步是流的补充而不是替代,两者的分工见本站《Node.js Streams 实战:背压、pipeline 与大文件处理》。
Worker Threads:CPU 密集的正解
单批计算压到几百毫秒以上、无法拆小(哈希、压缩、图像处理、加密)时,分片让步治标不治本,用 Worker Threads 把计算挪出主线程:
// worker.mjs — 跑在独立线程,有自己的事件循环
import { parentPort, workerData } from 'node:worker_threads';
const { payload } = workerData;
const result = cpuHeavyComputation(payload); // 想跑多久跑多久,不影响主线程
parentPort.postMessage(result);
// main.mjs — 主线程发起任务
import { Worker } from 'node:worker_threads';
function runWorker<T, R>(payload: T): Promise<R> {
return new Promise((resolve, reject) => {
const worker = new Worker(new URL('./worker.mjs', import.meta.url), { workerData: payload });
worker.on('message', resolve);
worker.on('error', reject);
worker.on('exit', (code) => { if (code !== 0) reject(new Error(`worker exited ${code}`)); });
});
}
适用边界要卡准,两种误用都很常见:I/O 密集任务不要上 Worker——主线程事件循环本来就能并发成千上万 I/O,开 worker 只是平添内存与序列化成本;小任务不要上 Worker——postMessage 的结构化克隆(大对象可用 transferList 转移 ArrayBuffer 免拷贝)有开销,单次几毫秒的计算,worker 化反而更慢。生产上做成 worker 池(按 CPU 核数建、任务排队、异常后重建),并且任务必须能超时终止,防止 worker 泄漏变成内存泄漏。
四种执行方式的分工一次说清:
| 任务形态 | 首选执行位置 | 理由 |
|---|---|---|
| 大量小任务、以 I/O 等待为主 | 主线程事件循环 | 异步 I/O 天然并发,额外结构反而添乱 |
| 批量计算、单批几十毫秒 | 主线程 + 分片让步 | 拆片成本低,免序列化,延迟可控 |
| 单次计算数百毫秒以上 | Worker Threads | 让步救不了总时长,必须离开主线程 |
| 大文件、流式数据 | Streams(必要时配 Worker) | 背压自动调节数据速率与内存 |
libuv 线程池:看不见的第二种并发
异步 I/O 里有一部分(多数文件系统操作、dns.lookup、crypto.pbkdf2/scrypt、zlib 部分 API)并不占主线程,而是提交给 libuv 线程池,默认 4 个线程。并发任务超过 4 个就排队——这不是理论,可以直接观测:
import { pbkdf2 } from 'node:crypto';
const start = Date.now();
const jobs = Array.from({ length: 8 }, (_, i) =>
new Promise<void>((resolve) =>
pbkdf2(`pw-${i}`, 'salt', 100_000, 32, 'sha256', () => resolve()),
),
);
await Promise.all(jobs);
console.log(`8 个 pbkdf2 总耗时: ${Date.now() - start}ms`);
// 默认线程池 4:8 个任务两批执行,耗时约等于"单任务耗时 × 2"
# 提高线程池并重跑,总耗时应下降到接近单批
UV_THREADPOOL_SIZE=8 node bench.mjs
三个生产要点:
- DNS 的分叉:
dns.lookup(名称解析走系统getaddrinfo,占线程池)和dns.resolve(直连 DNS 服务器,不占线程池)语义不同。高并发外部请求场景下,大量dns.lookup可能占满线程池,进而让同池的 fs/scrypt 排队——表现为"没有慢查询但磁盘读变慢"这类迷惑事故。 - crypto 密码哈希别放主线程:同步版
pbkdf2Sync/scryptSync会直接阻塞事件循环几百毫秒;异步版占线程池是正确姿势,但登录风暴时要把线程池余量算进容量。 - 线程数不是越大越好:每线程一个栈(默认约数 MB 级别),调大要和容器内存上限一起评估。另外注意容器环境:低内存场景下较新版本的 libuv 默认线程池会更小,容器里跑 Node 不要假设默认值,显式设置并压测。
注意事项与边界
async函数不等于并行,更不等于让出 CPU:await一个同步计算的结果,阻塞照旧发生在计算处。Promise.all包一万个异步任务:I/O 任务可以,CPU 任务照样串行占满主线程;并发度控制要区分任务类型。- 健康检查路径要保持极简且独立(或独立进程):事件循环阻塞时探活失败会被负载均衡摘除,但客户端流量仍在重试,形成双重故障;探活被杀不能作为发现阻塞的第一手段。
monitorEventLoopDelay看趋势要用滚动窗口与分位数(P99/P99.9/max),均值会掩盖尖刺;直方图对象记得reset()复用,别每分钟 new 一个。- 定时器不能当精确调度用:1ms 粒度、嵌套五层后最小 4ms(不同版本略有差异)、以及回调顺序受阶段影响;要"尽可能快且在 I/O 之后"用 setImmediate。
- Worker 的
postMessage是结构化克隆:传大对象优先转移(transferList)或传路径/索引而不是传数据,序列化开销经常反超计算本身。
总结
事件循环问题的排查框架就三句话:先看微任务优先级(nextTick 队列先于 Promise 队列,阶段间清空),再宏任务看阶段(I/O 回调内 setImmediate 确定先于 setTimeout;poll 决定本轮等多久),最后看执行者是谁(主线程怕阻塞,libuv 线程池怕占满,CPU 密集上 Worker)。把 monitorEventLoopDelay 常开、按 P99 报警,让阻塞问题在用户投诉之前先出现在监控上——这是这篇文章所有知识的落点。
参考
Live Notes
留言提交后会先进入审核,审核通过后对其他读者公开。