这是《实验驱动学 Node》系列的第四篇。前三篇分别讲了 child_process、cluster、worker_threads,那条线解决的是"活太多,分给别人干"。本篇回到一切的中心:事件循环。Node 里每个异步回调都排在这一个循环上,搞懂它,前面三篇里很多"就是这样"的结论会变成"原来如此"。
本文所有代码在本机实测通过(macOS 10 核,Node v22.22.2),所有机制结论都有 libuv 源码佐证。前端读者注意:Node 的事件循环和浏览器那套长得像,脾气不一样,第 9 章专门对账。
全文地图
| 章 | 内容 | 回答的问题 |
|---|---|---|
| 0 | 先纠正一个说法 | "Node 是单线程"错在哪 |
| 1 | 六阶段与 uv_run 源码 | 循环一圈到底干了什么 |
| 2 | timers 挪位公案 | "Node 20 后 timer 在最后"对吗 |
| 3 | 微任务 | nextTick、Promise、queueMicrotask 谁先谁后 |
| 4 | 四大经典时序题 | 面试题的实测答案 |
| 5 | 快照与清到空 | setImmediate 递归为什么不卡死 |
| 6 | 线程池 | fs 的回调从哪来,池子满了会怎样 |
| 7 | 生命周期 | 进程什么时候退出,ref/unref 是什么 |
| 8 | 观测 | 怎么给循环装仪表 |
| 9 | Node vs 浏览器 | 两套事件循环对账 |
第 0 章:先纠正一个说法
"Node 是单线程的",这句话每个教程都写,它是错的,或者说只对了一半。
准确的版本:你的 JS 代码只跑在一个线程上(主线程),但 Node 进程里还有别的线程在暗处干活,默认 4 个,libuv 线程池。第 6 章会让它们现形。
另一半误解是把事件循环想成一个神秘的调度器。它的真身是 libuv 库里的一个 while 循环,C 代码,全文不到 60 行,第 1 章直接读。
📖 libuv:Node 的底层 C 库,负责事件循环、线程池、跨平台的异步 I/O 封装。V8 管执行 JS,libuv 管"什么时候执行哪段 JS"。Node 的异步能力一大半是这个库给的。
第 1 章:循环的结构
1.1 uv_run 全文
事件循环的入口是 libuv 的 uv_run 函数。下面是当前版本(libuv 1.51,Node 22 搭载)的真实源码,加了行号注释:
c
int uv_run(uv_loop_t* loop, uv_run_mode mode) {
int timeout;
int r;
int can_sleep;
r = uv__loop_alive(loop);
if (!r)
uv__update_time(loop);
/* 兼容逻辑:UV_RUN_DEFAULT 模式下,进入 while 前先跑一次到期的 timers */
if (mode == UV_RUN_DEFAULT && r != 0 && loop->stop_flag == 0) {
uv__update_time(loop);
uv__run_timers(loop);
}
while (r != 0 && loop->stop_flag == 0) {
can_sleep = ...;
uv__run_pending(loop); // pending 阶段
uv__run_idle(loop); // idle(内部用)
uv__run_prepare(loop); // prepare(内部用)
timeout = 0;
if ((mode == UV_RUN_ONCE && can_sleep) || mode == UV_RUN_DEFAULT)
timeout = uv__backend_timeout(loop);
uv__io_poll(loop, timeout); // poll 阶段:睡觉等 I/O + 执行 I/O 回调
/* poll 后补跑 pending,最多 8 轮,防止饿死 */
for (r = 0; r < 8 && !uv__queue_empty(&loop->pending_queue); r++)
uv__run_pending(loop);
uv__run_check(loop); // check 阶段:setImmediate 在这
uv__run_closing_handles(loop);// close 阶段:close 事件在这
uv__update_time(loop);
uv__run_timers(loop); // timers 阶段:setTimeout/setInterval 在这
r = uv__loop_alive(loop); // 点票:还要继续转吗
if (mode == UV_RUN_ONCE || mode == UV_RUN_NOWAIT)
break;
}
...
return r;
}
市面上所有"事件循环六阶段图"画的就是这个 while 循环体。与其背图,不如认这几个函数名,它们就是阶段本名。
1.2 六个阶段各自干什么
| 阶段 | 职责 | 你写的什么代码会在这执行 |
|---|---|---|
| pending | 执行上一轮推迟的系统回调 | TCP 连接被拒的错误报告等 |
| idle / prepare | Node 内部专用 | 你碰不到 |
| poll | 等 I/O 事件,执行 I/O 回调 | fs 完成回调、网络数据到达 |
| check | 执行 setImmediate 回调 | setImmediate |
| close | 执行关闭回调 | socket.destroy() 的 'close' 事件 |
| timers | 执行到期定时器 | setTimeout、setInterval |
1.3 poll 阶段的双重人格
poll 是整个循环里唯一会"睡觉"的地方,它有两副面孔:
- 干活:poll 队列里有回调,就同步执行,直到清空或达到系统上限
- 睡觉:队列空了,就阻塞在这等新的 I/O 事件。等多久?算出来的:到最近的定时器到期还有多久,或者有 setImmediate 排队就干脆不等
"睡觉"时的让路规则值得单独记住:
css
poll 队列空了之后:
有 setImmediate 排队 → 立刻结束 poll,去 check 阶段
没有 → 继续睡,睡到有 I/O 事件或定时器到期
这个规则决定了第 4 章经典题 2 的答案。
1.4 一个文档不会写的细节
uv__io_poll 返回后有一段补跑循环,最多 8 次:
c
for (r = 0; r < 8 && !uv__queue_empty(&loop->pending_queue); r++)
uv__run_pending(loop);
poll 阶段执行 I/O 回调时,回调可能又往 pending 队列塞了新回调(比如写完成后的后续处理)。不补跑的话,这些新回调要白等一整轮。补跑让它们本轮就执行,但封顶 8 轮,防止回调互相生产把循环卡死在这。概念模型图上看不到这种补丁,它只在源码里。
第 2 章:timers 挪位公案
2.1 一个流传的说法
不少文章写着"Node 20 之后,timer 队列排到最后了"。这个说法对了一半,错的那半会害人答错题,值得花一节说清楚。
2.2 真实变更(libuv 1.45,2023 年 3 月,随 Node 20 发布)
libuv commit #3927 把 uv__run_timers 从每轮迭代的开头挪到了末尾:
sql
旧(Node < 20):timers → pending → idle/prepare → poll → check → close
新(Node 20+): pending → idle/prepare → poll → check → close → timers
同时保留了兼容逻辑:进入事件循环时(还没进 while),先跑一次到期的 timers。这就是 1.1 节源码里 while 之前那个 if 块,注释原文写着 "Maintain backwards compatibility"。
2.3 为什么这个挪动几乎无感
事件循环是一个环,不是一条线。timers 的旧位置是"pending 之前",新位置是"close 之后",而 close 之后本来就是 pending 之前,环上同一个缝隙。对 Node 主循环用的 UV_RUN_DEFAULT 模式,可观测行为基本不变,commit message 也明说了真正的行为变化只发生在 UV_RUN_ONCE / UV_RUN_NOWAIT 模式。
2.4 兼容逻辑的实测证据
主模块里这对冤家的执行顺序:
js
setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));
本机采样 20 次,每次只记第一行输出:
bash
19 immediate
1 timeout
顺序不确定,原因:setTimeout(0) 被规约为 1ms,进入循环时先跑一次到期 timers,这 1ms 到没到期取决于主模块执行了多久。启动慢(超过 1ms),timeout 先;启动快,poll 空转遇 setImmediate 直奔 check,immediate 先。
注意这个实验同时说明:"timer 挪到最后"的理解如果推导出"immediate 永远先",是错的,20 次里就有 1 次反例。
第 3 章:微任务
3.1 两条队列,优先级分明
微任务不属于事件循环的任何阶段,它们插在阶段的缝隙里执行。Node 里有两条微任务队列:
- nextTick 队列:
process.nextTick()专用,优先级最高 - Promise 队列:
Promise.then/catch/finally、queueMicrotask()、await的续体
规则一句话:同步代码收尾后,以及每个宏任务回调执行后,先清空 nextTick 队列,再清空 Promise 队列。
3.2 清空时机:Node 11 的分水岭
老行为(Node 11 之前):每个阶段结束后才清一次微任务。 新行为(Node 11 起,至今):==每个宏任务回调执行完就清一次,与浏览器对齐。==
很多面试资料还在教老行为。用代码分辨:
js
setTimeout(() => {
console.log('timer1');
Promise.resolve().then(() => console.log(' promise-in-timer1'));
}, 0);
setTimeout(() => console.log('timer2'), 0);
本机 v22 实测(新行为):
arduino
timer1
promise-in-timer1
timer2
老行为(Node 11 之前)的输出对照:
arduino
timer1
timer2
promise-in-timer1
差异只有一处:promise 的落点。新行为下它插在两个 timer 之间,因为 timer1 回调一结束就清了微任务;老行为下攒到阶段结束才清,它只能垫底。注意这个示例故意在 timers 阶段放了两个回调:如果只有一个回调,"回调结束"和"阶段结束"是同一时刻,两种行为输出相同,错误答案就是这样混了很多年都没露馅。
源码侧也能对上。Node 处理 immediate 回调的 processImmediate(lib/internal/timers.js)里有这么一段:
js
while (immediate !== null) {
if (ranAtLeastOneImmediate)
runNextTicks(); // 每执行完一个回调就清一次微任务
3.3 queueMicrotask 的归属
一次"全家福"实验(同时注册所有异步 API,完整代码见文末仓库清单)的前几行输出:
javascript
【同步】主模块开始
【同步】主模块结束
【微任务】nextTick
【微任务】Promise.then
【微任务】queueMicrotask
queueMicrotask 排在 Promise.then 后面,按注册顺序:它和 .then 同住 Promise 队列,优先级低于 nextTick。
3.4 nextTick 饥饿:能杀死循环的递归
js
const mode = process.argv[2];
setTimeout(() => console.log('【timers】setTimeout 执行了!'), 0);
let n = 0;
function loop() {
if (++n % 200000 === 0) console.log(` ${mode} 递归进行中... 已 ${n} 次`);
if (mode === 'nexttick') process.nextTick(loop);
else setImmediate(loop);
}
loop();
两种模式各跑 2 秒后强杀:
| 模式 | 2 秒内行为 | setTimeout 结局 |
|---|---|---|
| 递归 nextTick | 空转 4.5 亿次 | 永远打不出来 |
| 递归 setImmediate | 不到 20 万次迭代 | 第一行就执行 |
nextTick 队列的清空是"清到空为止",回调里续杯的新任务在同一个清空循环里继续被消费,事件循环永远走不到下一阶段。setImmediate 则每轮迭代只执行一次(为什么,第 5 章讲),其余阶段照常轮转。
这也是为什么官方文档建议:日常用 setImmediate,nextTick 只留给"必须在事件循环继续之前执行"的场景(比如构造函数里 emit 事件,保证订阅者来得及挂上)。
📖 名字乌龙:官方文档承认这两个名字起反了。nextTick 是"立即执行",setImmediate 是"下一轮 tick 执行",含义和名字正好相反。历史包袱,改不动了,npm 上一半的包都得跟着改。
第 4 章:四大经典时序题
四道题,全部本机实测过。答案背后的机制都在前几章。
题 1:主模块里 setTimeout(0) vs setImmediate
答案:不确定。机制:主模块耗时与 1ms 到期时间的赛跑(2.4 节实测,20 次里 19:1)。
题 2:I/O 回调里 setTimeout(0) vs setImmediate
js
fs.readFile(__filename, () => {
setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));
});
答案:immediate 永远先。机制:poll 回调执行完,队列空,有 immediate 排队,直奔 check(1.3 节的让路规则);timer 要等迭代末尾。
题 3:nextTick / Promise / 宏任务混战
答案:同步 → nextTick → promise → 宏任务。机制:3.1 节的队列优先级(3.3 节实测输出)。
题 4:同阶段多回调各自产微任务
答案(Node ≥ 11):timer1 → promise in timer1 → timer2。机制:3.2 节,逐回调清空(实测输出见该节)。
变体再多,考的也是这四条的排列组合。
第 5 章:快照与清到空
5.1 问题:递归 setImmediate 为什么不卡死循环
3.4 节的实验里,递归 setImmediate 两秒跑了不到 20 万次迭代,setTimeout 照常执行。直觉上 setImmediate 回调里又注册 setImmediate,check 队列应该永远清不完,为什么没卡死?
因为 check 阶段执行的不是"那个队列",是进阶段时拍的一张快照。libuv 源码(loop-watcher.c,由宏生成):
c
void uv__run_check(uv_loop_t* loop) {
struct uv__queue queue; // 局部队列,即快照
uv__queue_move(&loop->check_handles, &queue); // 整个队列搬进局部变量,原队列清空
while (!uv__queue_empty(&queue)) { // 只遍历快照
...
h->check_cb(h); // 回调里新注册的去新队列,本轮快照里没有它
}
}
Node 的 JS 层是同样的思路(lib/internal/timers.js 的 processImmediate):
js
// 注释原文:提前清空链表,以防回调执行期间出现新的 setImmediate
if (queue !== outstandingQueue) {
queue.head = queue.tail = null;
}
双层快照保证:本轮 check 只执行进阶段时已存在的回调,新注册的下轮见。所以 check 阶段必然结束,循环必然前进。
5.2 全部队列的消费语义总表
逐个翻过源码后的结论:
| 阶段/队列 | 消费方式 | 快照? |
|---|---|---|
| pending | queue_move 搬局部队列 | ✓ |
| idle / prepare | 与 check 同一个宏生成 | ✓ |
| poll | 遍历内核返回的固定事件数组 | ✓ |
| check | queue_move + JS 层链表置空 | ✓ |
| close | 链表头置空,预取 next 再遍历 | ✓ |
| timers | 两段式:先把到期 timer 摘进 ready 队列,再执行 | ✓ |
| nextTick 队列 | do-while,清到空 | ✗ |
| Promise 微任务队列 | V8 检查点,清到空 | ✗ |
timers 的两段式值得多看一眼(timer.c):
c
// 第一段:把堆里所有 timeout <= 缓存时间的 timer 摘进 ready_queue
for (;;) {
heap_node = heap_min(timer_heap(loop));
if (handle->timeout > loop->time) break; // 未到期就停
uv_timer_stop(handle);
uv__queue_insert_tail(&ready_queue, ...);
}
// 第二段:只执行 ready_queue 里的
while (!uv__queue_empty(&ready_queue)) {
uv_timer_again(handle); // interval 重新排期进堆,不回 ready_queue
handle->timer_cb(handle); // 回调里新注册的也进堆,不回 ready_queue
}
回调里注册的 setTimeout(0) 进的是堆,ready_queue 早已圈定,所以下轮才执行。
5.3 设计哲学:分界线不是巧合
注意快照派和清到空派的分界线,恰好是宏任务世界与微任务世界的分界线:
- libuv 的宏任务世界选快照,首要目标是循环必然前进。任何回调疯狂注册新任务,最坏结果只是下一轮多干点活,机制上不可能饿死循环
- JS 的微任务世界选清到空,首要目标是时效。await 链、Promise 链的后续必须紧跟当前任务,代价是给你一根绳子:递归续杯,循环窒息
一条普适判断:在 Node 里,只有微任务能饿死事件循环。
第 6 章:线程池
6.1 两条异步路径
官方文档对事件循环的定义里有个关键短语:"by offloading operations to the system kernel whenever possible"。只要有可能就卸给内核,那不可能的时候呢?
- 网络 I/O(TCP/UDP):内核提供真异步(epoll/kqueue/IOCP),注册 fd 后睡觉,事件来了内核叫。这条路上没有任何线程在干活
- 文件 I/O、dns.lookup、crypto、zlib:内核没有好用的跨平台异步文件接口,read() 本质是阻塞调用。libuv 的办法:派工作线程替主线程去阻塞,完了把结果喂回事件循环
默认 4 个工作线程,环境变量 UV_THREADPOOL_SIZE 可调(启动时设置才生效)。
6.2 完工通知怎么进事件循环
工作线程完工后,事件循环怎么知道的?答案是一根管道。threadpool.c 里工作线程的核心逻辑:
c
w->work(w); // 工作线程执行阻塞调用,比如 read()
uv_mutex_lock(&w->loop->wq_mutex);
uv__queue_insert_tail(&w->loop->wq, &w->wq); // 挂到完工队列
uv_async_send(&w->loop->wq_async); // 往主线程的 async 管道写一个字节
uv_mutex_unlock(&w->loop->wq_mutex);
主线程那边,wq_async 的读端 fd 像 socket 一样注册在事件循环里。管道可读,poll 阶段被唤醒,执行完工回调,进 JS。
所以"fs.readFile 回调在 poll 阶段执行"的准确原因:poll 等待的 fd 集合里,有一个是线程池的完工管道。从事件循环的视角看,网卡收包和线程池完工是同一类东西,都是 fd 就绪。
6.3 实验:并发阶梯
100 万次迭代的 pbkdf2(密码哈希,标准重活),不同并发数:
| 场景 | 完成时刻分布 | 总耗时 |
|---|---|---|
| 4 并发,默认池 4 | 181 / 190 / 191 / 206ms,一波 | 206ms |
| 8 并发,默认池 4 | 前 4 个 184 |
395ms |
| 8 并发,池调到 8 | 236~300ms,一波 | 300ms |
断层清晰可见:第 5 个任务起排队等线程腾空。还有个细节:池调到 8 后单任务从约 200ms 涨到约 260ms,8 个重线程抢 CPU,算力开始互相踩踏。池大小解决排队,解决不了算力,核数才是上限。
6.4 实验:抢池事故
js
// 先空池读一次文件拿基线,然后 4 个 pbkdf2 占满池子,再读同一个文件
fs.readFile(__filename, () => {
console.log(`[基线] 无竞争时 readFile:${Date.now() - start}ms`);
const t2 = Date.now();
for (let i = 0; i < 4; i++)
crypto.pbkdf2('password', 'salt', 1000000, 64, 'sha512', () => {...});
fs.readFile(__filename, () => {
console.log(`[事故] 池满时 readFile:${Date.now() - t2}ms`);
});
});
实测输出:
css
[基线] 无竞争时 readFile:1ms
pbkdf2 0 完成于 188ms
pbkdf2 2 完成于 189ms
[事故] 池满时 readFile:189ms
同一个小文件,池满时慢了 189 倍,readFile 排到第 5 号位,等第一个 pbkdf2 完工才上工。这个事故在监控上极具伪装性:CPU 不满,事件循环不堵,唯一症状是文件操作莫名变慢。生产上踩这个坑的常客:pbkdf2/scrypt/bcrypt 这类密码哈希、zlib 压缩、dns.lookup。
6.5 dns 双兄弟
dns 模块两个解析函数,底层路径完全不同:
- dns.lookup:调系统的 getaddrinfo(),阻塞调用,走线程池
- dns.resolve 系列:走 c-ares 库,纯网络异步,不占池
池满时同时发起两者,实测:
css
[池满] dns.resolve4:24ms(照常)
[池满] dns.lookup:183ms(排队等线程)
还有个工程坑:lookup 尊重 /etc/hosts,resolve 绕过它直查 DNS 服务器。改 hosts 调试时,http 模块(内部走 lookup)生效,自己写的 resolve 调用不生效。日常用 lookup 就好,http 模块的默认选择是对的。
第 7 章:生命周期
7.1 进程什么时候退出
直觉答案"代码跑完就退"是错的。真实规则:事件循环每轮末尾点一次票,票箱空了才退出。libuv 源码:
c
static int uv__loop_alive(const uv_loop_t* loop) {
return uv__has_active_handles(loop) || // 还有活跃且 ref 的句柄?
uv__has_active_reqs(loop) || // 还有进行中的请求?
!uv__queue_empty(&loop->pending_queue) ||
loop->closing_handles != NULL; // 还有没关完的句柄?
}
句柄(timer、server、socket、MessagePort)默认是 ref 状态,激活时往票箱投一票。unref() 就是退票:句柄照常在、照触发,只是不再挽留进程。
7.2 实验:四种模式
js
const mode = process.argv[2];
process.on('exit', () => console.log(`→ 进程退出,存活 ${Date.now() - start}ms`));
// ref:普通 setTimeout(2000)
// unref:setTimeout(2000) 后调 t.unref()
// mix:一个 unref 的 1 秒 timer + 一个 ref 的 2 秒 timer
// server:http server 监听后 server.unref()
实测:
| 模式 | 存活 | 解读 |
|---|---|---|
| ref | 2000ms | timer 拖着进程陪它到期 |
| unref | 2ms | 弃权后进程当场走,timer 打不出来 |
| mix | 2001ms | ref timer 拖住进程,unref timer 蹭车执行 |
| server | 4ms | 监听中的 server 弃权后也拖不住 |
7.3 句柄与请求的区分
点票条件里请求(fs 读、线程池任务)也算票,但请求没有 unref。区别在语义:句柄是长期事件源,可以"顺便";请求是一次性操作,完成回调必须有人接住,半途而废没有意义。
排查口诀:进程跑完不退,就是票箱里还有票。process._getActiveHandles() 打印出来,看谁赖着不走。后台心跳、监控上报这类 timer 应该主动 unref,它没资格挽留进程。
第 8 章:观测
8.1 两个仪表
perf_hooks 模块里有两个专门观测事件循环的 API,先各用一句话定位:
performance.eventLoopUtilization()(简称 ELU):回答"循环有多忙"monitorEventLoopDelay():回答"循环卡不卡"
ELU:忙闲表
它测什么。 事件循环只有两种状态:在干活(执行回调、跑 JS)和在睡觉(poll 阶段等 I/O)。Node 在循环每次切换状态时顺手记账,累计出两段时间,这个 API 就是把账本读出来:
js
const { performance } = require('node:perf_hooks');
const start = performance.eventLoopUtilization();
// ... 过一秒 ...
const delta = performance.eventLoopUtilization(start); // 传入上次的快照,算出这一段的差值
// delta = { idle: 996, active: 4, utilization: 0.004 }
返回值三个字段(单位毫秒):
active:这段时间里循环在干活的时间idle:循环在睡觉等活的时间utilization:active 除以(active + idle),0 到 1 之间
读数口径:0.004 表示 99.6% 的时间在摸鱼,1.000 表示一秒没停。类比收银员的忙碌率:一天 8 小时,6 小时在结账、2 小时没顾客站着,utilization 就是 0.75。
它是记账式测量:不往循环里塞任何东西,只在状态切换时记一笔,所以几乎零开销,也不怕循环忙,账随转随记。生产用法:ELU 持续超过 0.8,说明循环余量不多,该扩容或卸载任务了。
monitorEventLoopDelay:卡顿表
它测什么。 事件循环每转一圈(pending → poll → check → close → timers 一轮)花多久。健康时一圈一两毫秒;某圈被同步代码卡住,那一圈就变成几百上千毫秒。不用管卡在哪、谁引起的,只要循环被堵,那一圈的耗时就现形。
它怎么测。 它在循环里安插了一个自己的定时器当节拍器,每 resolution 毫秒醒一次,每次醒来记一笔账:实际醒来时刻减去本该醒来时刻,差值就是这一拍的延迟。注意它是采样式测量,靠循环转动才能工作,这个特点的后果在 8.3 节会看到。
它给你什么。 不是一个数,是一段时间内所有拍的统计分布(直方图),用分位数查阅:
js
const { monitorEventLoopDelay } = require('node:perf_hooks');
const h = monitorEventLoopDelay({ resolution: 10 }); // 节拍器每 10ms 一拍
h.enable(); // 开始记录
// 过一段时间后读数(单位纳秒,除以 1e6 得毫秒)
h.percentile(50); // 把所有拍从小到大排,排 50% 位置的值:普通一拍的耗时
h.percentile(99); // 排 99% 位置的值:最倒霉的 1% 拍有多惨
h.percentile(100); // 最大值:最慢那一拍
h.reset(); // 清零,下个统计窗口重新攒
h.disable(); // 停止记录
为什么看分位数不看平均值?算笔账:1000 拍里 999 拍各 1ms、1 拍 600ms,平均值 1.6ms,岁月静好;p99 是 600ms,事故现形。平均值会稀释尖刺,而尖刺正是线上"怎么突然卡了一下"的真凶。所以监控圈有句话:看分位,不看均值。
两个仪表要合起来读。ELU 高加 delay 低:忙而健康,活多而已。ELU 高加 delay p99 飙升:有重活堵循环,就是第 0 章系列里那个病灶,该查代码了。
8.2 实战:给病灶服务装仪表
起一个服务,/light 秒回,/heavy 同步忙等 1.5 秒,每秒打印两个仪表读数:
arduino
空载: ELU 0.004 | 循环延迟 p50 11.04ms p99 11.1ms
忙等中:ELU 1.000 | 循环延迟 p50 1503ms p99 1503ms max 1503ms
恢复: ELU 0.004 | 循环延迟 p50 11.04ms p99 11.1ms
忙等那一秒被量化得清清楚楚。同时用 curl 从进程外打 /light:耗时 1010ms,正好等于忙等的剩余时长。一个请求堵循环,所有请求陪绑。
8.3 三个使用要点
空载 p50 恒为 11ms 是工具的呼吸声。monitorEventLoopDelay 靠每 10ms 插一个定时器当节拍器,空载时测到的就是自己的节拍。盯 p99/max 的尖刺,别看基线。
卡死期间测不到,恢复后补记。delay 的采样器自己也依赖事件循环,忙等期间一下都触发不了;但它每次算的是"实际醒来时刻减去本该醒来时刻",恢复后第一拍把整段延迟一次记清。上面那行 1503ms 就是这么来的。
循环死了,仪表陪葬。如果忙等变成永不退出的死循环,这两个值永远读不到,因为读取动作(你的 setInterval 打印)也依赖事件循环。所以生产监控是两层:内部指标管"卡不卡",外部探活(负载均衡健康检查)管"死没死"。指望内部指标发现进程死亡,就像指望一个人亲手写下自己的死亡时间。
第 9 章:Node vs 浏览器
前端最容易犯的错,是把浏览器那套直接搬到 Node。对账:
| Node | Chrome | |
|---|---|---|
| 微任务队列 | nextTick + Promise 两条,都清到空 | 一条,清到空 |
| 宏任务回调后清微任务 | ✓(v11 起对齐浏览器) | ✓(原生行为) |
| 宏任务组织 | 六阶段,阶段内快照批量消费 | 多队列按任务源分组,一轮只执行一个 task |
| 调度策略 | 固定阶段序 | Blink 优先级调度,用户输入优先于 timer |
| 谁能饿死一切 | 只有微任务 | 只有微任务,且连渲染一起饿死 |
| setTimeout 最小延迟 | 0 规约为 1ms | 嵌套 5 层后钳到 4ms |
| setImmediate / rAF | 有 / 无 | 无 / 有(rAF 每帧快照) |
本质差异在宏任务侧。Node 一轮迭代把一个阶段的快照批量执行完,因为要榨干吞吐;Chrome 一轮只执行一个 task,执行完就回到调度点,清微任务、渲染、响应输入,因为用户体验优先。同是事件循环,服务对象不同,调度哲学就不同。
微任务侧两家一致,因为用的是同一个 V8、同一份规范。递归 Promise.then 在 Chrome 控制台里同样卡死页面,而且更惨,连渲染和点击响应一起饿死。
附录:一页速查
循环结构(Node 20+,UV_RUN_DEFAULT)
sql
进入循环:先跑一次到期 timers(兼容逻辑)
每轮迭代:pending → idle/prepare → poll → check → close → timers
poll 空 + 有 setImmediate → 直奔 check
优先级:同步代码 → nextTick 队列 → Promise 队列 → 宏任务各阶段。每个宏任务回调后立即清微任务(v11+)。
消费语义:libuv 各阶段全部快照(进阶段圈定,新注册的下轮见);两条微任务队列清到空。只有微任务能饿死循环。
线程池:默认 4,UV_THREADPOOL_SIZE 启动时可调。走池的:fs 全家、crypto 重活、zlib、dns.lookup。不走池的:网络 I/O、dns.resolve。池满症状:fs/解析莫名变慢,CPU 和循环都不堵。
生命周期:句柄默认 ref,激活即投票;unref 退票不挽留。请求没有 unref,必须善终。排查:process._getActiveHandles()。
观测:ELU 看忙不忙(performance.eventLoopUtilization()),delay 看卡不卡(monitorEventLoopDelay,盯 p99/max)。内部指标 + 外部探活,缺一不可。
经典题答案:主模块 setTimeout vs setImmediate 不确定;I/O 回调里 immediate 必先;nextTick > promise > 宏任务;同阶段多回调,微任务逐个回调清。
实验代码清单
本文全部实验脚本(可直接运行):
| 实验 | 对应章节 |
|---|---|
| 01-全家福阶段顺序.js | 1、3 |
| 02-微任务清空时机.js | 3 |
| 03-timers位置实测.js | 2 |
| 04-饥饿对比实验.js | 3、5 |
| 05-线程池阶梯.js | 6 |
| 06-抢池事故复现.js | 6 |
| 08-dns双兄弟对比.js | 6 |
| 09-句柄ref与unref.js | 7 |
| 10-ELU观测实战.js | 8 |