Day 2 - event loop 的巡场路线:六个 phase 与清场顺序
🗺️ 接上回:服务员巡场,有固定路线吗?
Day 1 我们知道了:Node.js 的服务员(event loop)永不等待,慢活外包给后厨,出餐按铃(回调)再回来收尾。
今天追问一步:铃可能同时响好多个 —— 定时器到点了、文件读完了、Promise 也完成了,服务员先处理哪个?他总不能看心情。答案是:他有一条雷打不动的巡场路线,每一圈都按同样的顺序经过固定的几站。这条路线,就是 event loop 的 phase(阶段)。
📇 概念卡:event loop 的六个 phase(只记三站)
event loop 每转一圈(iteration),按固定顺序经过 6 个 phase,每个 phase 是一个 FIFO 队列(该站排队等处理的铃铛):
┌───────────────────────────┐
┌─▶│ ① timers(定时器站) │ 到期的 setTimeout / setInterval
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ ② pending callbacks │ 系统级回调(如 TCP 错误),日常碰不到
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ ③ idle, prepare │ Node 内部用,忽略
│ └─────────────┴─────────────┘
│ ┌─────────────┴─────────────┐
│ │ ④ poll(出餐取餐口) │ I/O 回调:文件读完、网络响应到达
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ ⑤ check(加急窗口) │ setImmediate
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ ⑥ close callbacks │ 关闭事件,如 socket.on('close')
│ └─────────────┬─────────────┘
└──┴─────────────┘ 下一圈
phase(阶段):event loop 每一圈按固定顺序经过的站点,每站维护一个 FIFO 回调队列。日常只需要记住三站:timers(setTimeout/setInterval)、poll(I/O 回调,巡场的中转站)、check(setImmediate)。
一句话记:服务员不是随机应变,是固定路线巡逻;你的回调排在哪一站,决定了它什么时候被处理。
poll 站:整条路线的"中转站"
poll 是六站里最特殊的一站 —— 它是唯一会"停下来等"的地方。poll 队列空了之后,服务员怎么决定下一步?
- 有
setImmediate在排队 → 不等,直接去下一站 check; - 有快到期的 timer → 在 poll 等着,等到点了去 timers 站;
- 什么活都没有 → 打烊(进程退出)。
第 1 条是今天第 4 节面试题的关键,先有个印象,待会儿实验验证。
✂️ 两个编外窗口:nextTick 与 microtask
上面六站是"正规军"。但还有两类回调不属于任何 phase,它们走的是"编外窗口" —— 每当服务员在一个站点服务完当前这批客人、动身去下一站之前,先把这两个窗口清空:
| 窗口 | 装什么 | 面馆类比 | 脾气 |
|---|---|---|---|
| nextTick 队列 (Node 维护) |
process.nextTick(fn) |
店长插单:"先去把这桌的账结了再继续巡" | 完全排空才放行;边排边加新单,就永远排不空 |
| microtask 队列 (V8 维护) |
Promise.then/catch/finally、queueMicrotask、await 后面的"打包纸条"(Day 1 笔记) |
熟客的预约纸条 | 同样完全排空,优先级通常排在 nextTick 之后 |
microtask / macrotask:异步回调的两个优先级档位。每个 phase 的回调(以及 setTimeout、setImmediate、I/O 回调)是 macrotask,按巡场路线排队;Promise.then、nextTick 这类是 microtask,在"当前站点服务完、动身去下一站之前"插队全部清空。
一句话记:每站服务完 → 先清空 nextTick 窗口 → 再清空 microtask 窗口 → 才动身去下一站。
面试标准答案"nextTick 永远优先于 Promise"只在 CommonJS 里成立。本项目 "type": "module",代码跑在 ES module 里:模块顶层求值由 V8 驱动,求值一结束 V8 立刻清空自己的 microtask 队列,Node 的 nextTick 反而排在后面 —— microtask 先,nextTick 后。进了普通回调(如 I/O 回调)内部,Node 重新主导清场,恢复 nextTick 优先。习题 1 会亲眼看到这个反序。
回忆 Day 1 笔记:await 之后的代码被打包成纸条挂在 Promise 的铃铛后面 —— 现在知道了,那张纸条走的就是 microtask 窗口。
⚖️ 经典面试题:setTimeout(0) vs setImmediate
两个都是"尽快执行",谁先?答案是后端面试的经典陷阱:看在哪儿注册。
在顶层注册:不确定
两个冷知识叠加出"不确定":
setTimeout(fn, 0)的 0 会被钳成 1ms,倒计时从调用那一刻起算;- timers 站只接待已到期的铃铛。
loop 启动要花一点时间(加载模块等)。启动完巡到 timers 站时,那 1ms 过了没有?过了 → setTimeout 先;没过 → 一路空站到 check,setImmediate 先。同一台机器多跑几次,两种结果都可能出现。
在 I/O 回调里注册:确定,setImmediate 永远先
setImmediate 稳坐下一班,setTimeout(0) 最快也要等下一圈的 timers 站。
这正是第 2 节 poll 等待逻辑第 1 条的直接推论:poll 空了 + 有 setImmediate 排队 → 不等待,直奔 check。
只要两段代码的先后关系对你很重要,就绝不能依赖顶层 setTimeout(0) 与 setImmediate 的顺序。要么用 Promise/nextTick 这种语义明确的,要么把注册点放进确定的上下文(如 I/O 回调)里。
☠️ 饿死 loop:为什么让出必须用 setImmediate
第 3 节说 nextTick 窗口"完全排空才放行"。如果排空过程中每次都再塞一张新单进去呢?
function keepCuttingInLine() {
process.nextTick(keepCuttingInLine); // 店长无限插单
}
keepCuttingInLine(); // ⚠️ 别真跑:进程卡死,timer、I/O 全部饿死
nextTick 队列永远排不空 → 服务员永远动身不了 → 巡场路线停摆 → 整个进程卡死,和死循环一样致命。
正确的让出:setImmediate 分片
同样是递归,setImmediate 注册的是下一圈 check 站的活 —— 本圈照常走完,timer、I/O 都有机会在片间插入。这就是 Day 1"磨面粉"问题的止血方案:把大计算切成小块,每片结束用 setImmediate 让出。
协作式让出(cooperative yielding):单线程调度里,长任务主动切碎、片间把执行权还给调度器的技巧。规则只有一条:让出必须交给"下一圈"(setImmediate / setTimeout),不能交给"当前清场"(nextTick) —— 后者会把清场变成无限循环。
记住分片只是止血。治本方案在 Day 1 已经说过:CPU 密集任务丢给 worker_threads(另雇一个磨粉工),那是本周后面几天的主题。
🌍 跨语言对照(调度思想 everywhere)
| 通用概念 | Node.js(今天的教具) | C# / .NET | Java | Go |
|---|---|---|---|---|
| 调度器本体 | event loop(libuv),6 个 phase | 线程池调度器 + SynchronizationContext(UI 程序的消息循环几乎就是 event loop) | 线程池;Netty 的 EventLoop 和 Node 几乎一个模子(单线程事件循环 + 任务队列) | runtime 调度器(GMP 模型):把海量 goroutine 映射到少量线程 |
| microtask 等价物 | Promise.then / nextTick | await 的 continuation(尽早排队执行) | CompletableFuture 的回调链 | 没有等价物(goroutine 切换本身就是廉价调度的粒度) |
| 协作式让出 | setImmediate / setTimeout 分片 | Task.Yield() |
Thread.yield() |
runtime.Gosched();且 Go 1.14+ 有抢占式调度,死循环不太能饿死别人 |
| 定时器 | setTimeout(timers phase,不早于 ms) | Task.Delay / Timer(同样只保证"不早于") |
ScheduledExecutorService | time.AfterFunc / time.Sleep |
📌 面试翻译练习:面试官问 "Node 的 setImmediate 是什么?" 你可以答:"event loop check phase 的调度原语,语义等价于 C# 的 Task.Yield / Go 的 runtime.Gosched —— 都是'把剩下的活排到下一轮,先让调度器服务别人'的协作式让出。" 一个概念,四个生态通用。
🛠️ 今日习题(约 35 min)
习题代码在 src/02-event-loop-phases/,骨架已放好,带中文注释和预测填空。运行方式:npx tsx src/02-event-loop-phases/ex1-nexttick-vs-microtask.ts
习题 1:清场顺序 + ESM 反序彩蛋(动手,约 10 min)
文件:ex1-nexttick-vs-microtask.ts
先在文件顶部写下你预测的执行顺序,再运行对比。你会看到 microtask 跑赢了先注册的 nextTick —— 想清楚为什么(提示:这个文件是 ESM 还是 CJS?),把解释写进注释。
习题 2:顶层不确定 vs I/O 里确定(动手,约 12 min)
文件:ex2-timeout-vs-immediate.ts
场景 A(顶层 setTimeout(0) vs setImmediate)连跑 5 次,记录每次 T/I 的先后;再观察场景 B(I/O 回调里)为什么永远是 I 先。用第 2、4 节的路线知识,把"为什么"写进 notes.md。
习题 3:饿死 loop vs 安全让出(动手,约 8 min)
文件:ex3-starve-the-loop.ts
先跑 CASE 2(setImmediate 分片):观察 500ms 的铃铛在磨面粉期间照常响起。然后取消注释 CASE 1(递归 nextTick),先预测铃铛还响不响,再运行 —— 3 秒内看不到输出就 Ctrl+C 杀掉,把进程救回来。
习题 4:概念卡默写(动笔不动键盘,约 5 min)
文件:notes.md(骨架已放好)
合上本文档,用中文回答两题(每题不超过 5 句话,必须用自己的话):
- event loop 的六个 phase 是什么?日常只需记哪三站、各装什么?用保安巡逻比喻讲一遍。
- 为什么在 I/O 回调里 setImmediate 一定先于 setTimeout(0),顶层却不确定?
写完后回到第 2、4 节对答案,把说错的地方订正。
✅ 总结与自测
今天带走的 4 句话
- event loop 不是随机绕圈,是固定路线巡逻:timers → … → poll → check → close,一圈一圈永远如此。
- 每站服务完、动身之前,先清空两个编外窗口:nextTick → microtask(但 ESM 顶层反序,microtask 先)。
setTimeout(0)vssetImmediate:顶层不确定,I/O 回调里 setImmediate 必胜 —— 因为 poll 的下一站固定是 check。- 让出执行权只能用 setImmediate/setTimeout(交给下一圈);递归 nextTick 会把清场变成无限循环,饿死整个进程。
自测清单(能打勾才算今天过关)
- 能画出 event loop 六站路线图,并说出日常三站各装什么
- 能解释"每站之间先清 nextTick 再清 microtask",以及 ESM 顶层为什么反序
- 能不查资料说清 setTimeout(0) vs setImmediate 在顶层和 I/O 回调里的两种结果及原因
- 能解释递归 nextTick 为什么卡死进程,以及分片让出的正确姿势
- 能把 setImmediate 翻译成 C#/Go 的等价物(Task.Yield / runtime.Gosched)