Node.js 事件循环详解(实验驱动)

这是《实验驱动学 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 是整个循环里唯一会"睡觉"的地方,它有两副面孔:

  1. 干活:poll 队列里有回调,就同步执行,直到清空或达到系统上限
  2. 睡觉:队列空了,就阻塞在这等新的 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 里有两条微任务队列:

  1. nextTick 队列:process.nextTick() 专用,优先级最高
  2. Promise 队列:Promise.then/catch/finallyqueueMicrotask()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 个 184220ms,后 4 个 358395ms,两波 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
相关推荐
deli0070071 小时前
汉诺塔益智小游戏:浏览器里说句话,码道 WebUI 一键生成+部署上线
前端·ai编程
用户2181697049301 小时前
Flutter (二十四) 音频
前端
愚公搬代码1 小时前
【愚公系列】《Web应用安全》012-Behinder工具的使用
前端·安全
aixingpan1 小时前
aixingpan.cn API开发文档:api_docs_errors接口指南
前端·php
李剑一1 小时前
前端转AI要了解的技术,其他人不用看。前端架构基础之:让Js的计算运行在GPU上
前端·aigc·ai编程
IT_陈寒1 小时前
Python的列表拷贝坑得我原地打转
前端·人工智能·后端
恋猫de小郭2 小时前
Dart 3.13 的到底改了什么?为什么很重要?有什么坑?
android·前端·flutter
云浪2 小时前
给大模型装上"手":三分钟看懂 AI Agent 的 Tool 工具调用
javascript·人工智能·node.js
风月说与山鬼2 小时前
六、React事件
前端·javascript·react.js