2.2 事件循环六阶段解剖与执行顺序推演


2.2 事件循环六阶段解剖与执行顺序推演

本节摘要:事件循环是 libuv 里的一个无限循环,每轮依次经过 timers、pending、idle/prepare、poll、check、close 六个阶段,微任务队列与 nextTick 队列在阶段之间插队执行。本节拆解每个阶段的职责,澄清「setTimeout(fn,0) 为 0 却不是立刻」等常见误解,最后用一组经典实验完整推演输出顺序。

动手目标

  1. 能按顺序说出六个阶段及各自的回调来源
  2. 能解释微任务、nextTick、宏任务的优先级差异
  3. 能推演 setTimeout、setImmediate、Promise 混合代码的输出
  4. 能用实验方法验证自己的推演而不是背结论

一、一轮循环的旅程

调用栈清空后,事件循环开始转动。每一轮按固定顺序访问六个阶段,每个阶段是一个先进先出的回调队列,跑完(或达到上限)才进入下一阶段:

// 用伪代码表达事件循环的骨架 while (true) { // 1 timers:到期的 setTimeout / setInterval 回调 // 2 pending callbacks:上一轮延迟执行的系统级回调(如 TCP 错误) // 3 idle, prepare:内部使用,业务代码碰不到 // 4 poll:取回 I/O 完成事件并执行其回调;必要时阻塞等待 // 5 check:执行 setImmediate 回调 // 6 close callbacks:关闭事件的回调,如 socket.on('close') }

两个容易误解的点先澄清:

  • timers 的时间是下限不是承诺setTimeout(fn, 100) 的意思是「至少 100ms 后才可能执行」。如果前面某个回调跑了 200ms,fn 只能等它跑完。官方文档的原话是延迟「近似」。
  • poll 是双面角色。它是循环的正文:既执行绝大多数 I/O 回调,又负责「要不要在这儿等等新事件」。如果 poll 队列空了、又有 setImmediate 待执行,循环会直接停止等待进入 check 阶段——这个细节直接决定了下一节实验三的结果。

事件循环六阶段透视

事件循环六阶段透视

二、插队者:nextTick 与微任务

阶段与阶段之间,Node 会先清空两个特殊队列:nextTick 队列(process.nextTick 注册)然后是微任务队列(Promise then、queueMicrotask、await 续体)。优先级:nextTick > 微任务 > 任何阶段回调。

setImmediate(() => console.log('immediate')); Promise.resolve().then(() => console.log('微任务')); process.nextTick(() => console.log('nextTick')); console.log('同步'); // 输出:同步 → nextTick → 微任务 → immediate

nextTick 权限太高,官方文档专门警告:递归 nextTick 会把事件循环饿死——队列永远清不空,timers 永远到不了。需要「尽快执行」又不想冒险时,用 queueMicrotask 或 setImmediate 更安全。

💡 关键直觉:同步代码是一等公民,nextTick 是贵宾,微任务是随从,阶段回调是普通观众——入场顺序永远是这个次序。

三、三个经典实验

实验一:nextTick 与微任务同场

setTimeout(() => { Promise.resolve().then(() => console.log('A 微任务')); process.nextTick(() => console.log('B nextTick')); setTimeout(() => console.log('C 定时器'), 0); setImmediate(() => console.log('D immediate')); }, 0); // 输出:B → A → D → C

推演:定时器回调执行中注册了四个任务,回调结束后进入阶段切换 → 先清 nextTick(B),再清微任务(A),本轮 timers 阶段结束 → 下一轮 poll 空转并发现 check 有任务 → D,最后才是下一轮 timers 的 C。D 在 C 前是定式(同一轮内 check 先于下一轮 timers),除非 timers 恰好在这之前到期——这引出实验二。

实验二:主模块里的 setImmediate 与 setTimeout(0)

setTimeout(() => console.log('timeout'), 0); setImmediate(() => console.log('immediate')); // 输出顺序不确定!

原因:主模块同步代码执行完时,一轮循环可能已经开始。如果此刻定时器恰好已到期(受进程启动耗时影响),先跑 timers 输出 timeout;否则 poll 阶段会优先切到 check 输出 immediate。顺序取决于机器当时的负载,多跑几次会看到两种结果——这不是 bug,是阶段边界的真实写照。但同样的代码放进 I/O 回调里,顺序就恒定了:

const fs = require('fs'); fs.readFile('某文件', () => { setTimeout(() => console.log('timeout'), 0); setImmediate(() => console.log('immediate')); }); // 恒定输出:immediate → timeout

因为 poll 阶段执行完 readFile 回调后,按规则优先进入 check。

实验三:验证你的推演能力

先自己推演,再运行:

console.log('1'); setTimeout(() => console.log('2'), 0); process.nextTick(() => console.log('3')); Promise.resolve().then(() => console.log('4')); setImmediate(() => console.log('5')); (async () => { console.log('6'); await null; console.log('7'); })(); console.log('8'); // 参考答案:1 6 8 3 4 7 5 2

要点:async 立即执行函数体到 await 前都是同步(所以 6 在 8 前);await 后的 7 是微任务但排在 4 之后(注册晚于 4);5 与 2 的次序同实验二的主模块情形——此处取 check 先行。

把推演变成排查工具

顺序推演不只是面试题,它是线上事故的第一反应工具。典型场景:某接口日志顺序错乱,先打了「请求完成」再打「开始处理」。别急着怀疑框架,画出涉及的任务类型——多半是「完成」日志挂在 setImmediate 或定时器上,而「开始」日志在微任务里。掌握了本章的分类法,这类诡异现象五分钟内就能定位;反之则会陷入「加了 console.log 就好、删掉又坏」的玄学调试。建议把三个实验代码留成一个自检脚本,换 Node 大版本时跑一遍,验证行为是否有变化。

本节要点回顾

  • 六个阶段:timers、pending、idle、poll、check、close,poll 是正文,check 管 setImmediate。
  • 插队规则:阶段之间先清 nextTick 再清微任务,优先级高于一切阶段回调。
  • setTimeout(0) 不是 0ms:延迟是下限,且主模块里与 setImmediate 的先后不确定。
  • 饿死风险:递归 nextTick 能让事件循环停摆,慎用这个特权。
  • 推演方法:先标同步,再标 nextTick/微任务(按注册顺序),最后按阶段排宏任务——不要背答案,要会推。

作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U