AI → Full-Stack
365 天全栈成长 · PHASE 1 · WEEK 1

Day 2 - event loop 的巡场路线:六个 phase 与清场顺序

今日时长:3 小时(学习 50 min + 动手 100 min + 整理 30 min)· 主题:event loop 深入(Event Loop Phases)——服务员不是随便绕圈,他有固定路线 · 练习语言:Node.js(它只是教具,调度思想才是主角)

🗺️ 接上回:服务员巡场,有固定路线吗?

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')
│  └─────────────┬─────────────┘
└──┴─────────────┘ 下一圈
概念卡 #1

phase(阶段):event loop 每一圈按固定顺序经过的站点,每站维护一个 FIFO 回调队列。日常只需要记住三站:timers(setTimeout/setInterval)、poll(I/O 回调,巡场的中转站)、check(setImmediate)。

一句话记:服务员不是随机应变,是固定路线巡逻;你的回调排在哪一站,决定了它什么时候被处理。

poll 站:整条路线的"中转站"

poll 是六站里最特殊的一站 —— 它是唯一会"停下来等"的地方。poll 队列空了之后,服务员怎么决定下一步?

  1. setImmediate 在排队 → 不等,直接去下一站 check;
  2. 有快到期的 timer → 在 poll 等着,等到点了去 timers 站;
  3. 什么活都没有 → 打烊(进程退出)。

第 1 条是今天第 4 节面试题的关键,先有个印象,待会儿实验验证。

✂️ 两个编外窗口:nextTick 与 microtask

上面六站是"正规军"。但还有两类回调不属于任何 phase,它们走的是"编外窗口" —— 每当服务员在一个站点服务完当前这批客人、动身去下一站之前,先把这两个窗口清空:

窗口装什么面馆类比脾气
nextTick 队列
(Node 维护)
process.nextTick(fn) 店长插单:"先去把这桌的账结了再继续巡" 完全排空才放行;边排边加新单,就永远排不空
microtask 队列
(V8 维护)
Promise.then/catch/finallyqueueMicrotaskawait 后面的"打包纸条"(Day 1 笔记) 熟客的预约纸条 同样完全排空,优先级通常排在 nextTick 之后
概念卡 #2

microtask / macrotask:异步回调的两个优先级档位。每个 phase 的回调(以及 setTimeout、setImmediate、I/O 回调)是 macrotask,按巡场路线排队;Promise.thennextTick 这类是 microtask,在"当前站点服务完、动身去下一站之前"插队全部清空

一句话记:每站服务完 → 先清空 nextTick 窗口 → 再清空 microtask 窗口 → 才动身去下一站。

⚠️ 反直觉彩蛋:ESM 顶层会反序

面试标准答案"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 永远先

💡 生活翻译: I/O 回调执行时,服务员正站在 poll 站(出餐取餐口)。回调跑完,下一站固定是 check(加急窗口)—— 不管定时器到没到期,路线就这么走的。所以在 I/O 回调里,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 让出。

概念卡 #3

协作式让出(cooperative yielding):单线程调度里,长任务主动切碎、片间把执行权还给调度器的技巧。规则只有一条:让出必须交给"下一圈"(setImmediate / setTimeout),不能交给"当前清场"(nextTick) —— 后者会把清场变成无限循环。

记住分片只是止血。治本方案在 Day 1 已经说过:CPU 密集任务丢给 worker_threads(另雇一个磨粉工),那是本周后面几天的主题。

🌍 跨语言对照(调度思想 everywhere)

通用概念Node.js(今天的教具)C# / .NETJavaGo
调度器本体 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 句话,必须用自己的话):

  1. event loop 的六个 phase 是什么?日常只需记哪三站、各装什么?用保安巡逻比喻讲一遍。
  2. 为什么在 I/O 回调里 setImmediate 一定先于 setTimeout(0),顶层却不确定?

写完后回到第 2、4 节对答案,把说错的地方订正。

✅ 总结与自测

今天带走的 4 句话

  1. event loop 不是随机绕圈,是固定路线巡逻:timers → … → poll → check → close,一圈一圈永远如此。
  2. 每站服务完、动身之前,先清空两个编外窗口:nextTick → microtask(但 ESM 顶层反序,microtask 先)。
  3. setTimeout(0) vs setImmediate:顶层不确定,I/O 回调里 setImmediate 必胜 —— 因为 poll 的下一站固定是 check。
  4. 让出执行权只能用 setImmediate/setTimeout(交给下一圈);递归 nextTick 会把清场变成无限循环,饿死整个进程

自测清单(能打勾才算今天过关)

  • 能画出 event loop 六站路线图,并说出日常三站各装什么
  • 能解释"每站之间先清 nextTick 再清 microtask",以及 ESM 顶层为什么反序
  • 能不查资料说清 setTimeout(0) vs setImmediate 在顶层和 I/O 回调里的两种结果及原因
  • 能解释递归 nextTick 为什么卡死进程,以及分片让出的正确姿势
  • 能把 setImmediate 翻译成 C#/Go 的等价物(Task.Yield / runtime.Gosched)