JavaScript 是 single-threaded:engine 一次只跑一段 JavaScript,在 call stack 上。Host——browser、Node.js、Deno——在后台做其他工作,稍后再请 engine 跑 callbacks。
Event loop 负责这次交接。它不是 JavaScript 语言功能。ECMAScript 规定 calls 与 Promise jobs。HTML Standard 规定重复的一轮:一个 task、排空 所有 microtasks、也许 update the rendering,然后再取下一个 task。
这篇笔记依 Lydia Hallie 的可视化:stack、host APIs、task queue、microtask queue,然后是 5 1 3 4 2。概览版是 JavaScript 核心概念。
- 同步代码在 stack 上跑。
- Host APIs 在 stack 外做完工作并 enqueue 一个 callback。
- Stack 为空时,loop 先排空 microtask queue,再把 一个 task 推上 stack。
1. Call Stack
Call stack 是 engine 目前正在执行什么的 last-in, first-out 记录。调用 function 会 push 一个 execution context。return 或 throw 会把它 pop 掉。只有最顶端的 frame 在跑。
function add(a: number, b: number): number {
return a + b
}
function printSum(): void {
const result = add(2, 3)
console.log(result)
}
printSum()- Stack 会成长为
printSum→add,再随着每个 function return 而缩小。 - 在这些 frames 还在的时候,没有别的东西能跑——没有 timer、click,或
.then。 - JavaScript 不会抢占目前这个 function。
紧密 loop 不会分享 thread。它占有 thread:
const start = Date.now()
while (Date.now() - start < 3000) {
// The page cannot paint, handle clicks, or fire timers.
}有用的问题不是「这是不是 asynchronous?」而是:这会把 frame 留在 stack 上,还是会 return、让 host 把工作做完?
2. Web APIs 是 Host,不是 Engine
V8、JavaScriptCore 与 SpiderMonkey 负责 evaluate JavaScript。它们不实现 setTimeout、document.querySelector 或 fetch。那些是 Web APIs:host 暴露给 script 的接口。
当 script 调用 callback-based host API 时:
- 这次调用在 stack 上跑,几乎立刻 return。
- Host 开始真正的工作——timer、network request、DOM listener。
- 工作完成时,host 不会把 callback 推进 stack。它把它放到 queue。
console.log("A")
setTimeout(() => {
console.log("C")
}, 1000)
console.log("B")
// A, B, then C after the timer and after the stack is emptysetTimeout本身是 synchronous。它只是 schedule。- Delay 是下限。Callback 在 1000ms 还是更晚跑,取决于 stack 与 queues,不只是 timer。
- 同样的拆分适用于
addEventListener、XMLHttpRequest、MessageChannel,以及较旧的 Nodefs.readFile(path, cb)。 - Promise-based APIs 的 I/O 仍然走 host。差别在于 continuation 进哪条 queue:
fetch(url).then(onResponse)等 network,然后把onResponsequeue 成 microtask。
3. Task Queue
HTML Standard 称这些为 tasks。非正式写法说 macrotasks。Timers、user input、postMessage、经典 <script> evaluation,以及许多 networking callbacks,都会成为 tasks。
- Loop 跑 一个 被选中的 task,然后做一次 microtask checkpoint,再取下一个 task。
- Nested
setTimeout不能在 parent 的这一轮继续——它是 未来 的工作。 - 一条全局 FIFO queue 是漫画。Host 有 task sources,可能在多条 queues 之间挑选。
- Script evaluation 本身就是一个 task。Top-level module code 就是那个 task 跑到完成。
const order: string[] = []
setTimeout(() => {
order.push("timeout-1")
setTimeout(() => {
order.push("timeout-2")
}, 0)
}, 0)
setTimeout(() => {
order.push("timeout-3")
}, 0)
// Typical order: timeout-1 → timeout-3 → timeout-2Delay 为 0 的意思是「一旦被允许跑 timer task」,不是「下一行之前」。Nested-timer clamping(HTML 在 nesting level 5 之后的 4ms)是次要的。主要延迟来自 event loop。
零延迟 callback 要等:
- 当前 task 结束、stack 清空。
- 目前已 queue 的每一个 microtask,以及那些 microtasks 再 enqueue 的每一个 microtask。
- 任何已经在等的更早 tasks。
Failure: 零延迟 timer 仍然输给 Promise.resolve().then(...)。Timer 已经就绪。它在 错误的 queue。
4. Microtask Queue
Task 结束且 stack 为空之后,host 做一次 microtask checkpoint:跑最旧的 microtask,重复直到 queue 为空。
会 enqueue microtasks 的来源:
- Promise reaction jobs:
.then、.catch、.finally await之后的asyncfunction continuationsqueueMicrotask(fn)MutationObservercallbacks
- 一个 microtask 若调用
queueMicrotask或 resolve 另一个 Promise,会把工作加进 同一轮 checkpoint。 - 那些工作会在下一个 timer、click 或 paint 之前跑。
queueMicrotask与Promise.resolve().then共用一条 queue。顺序就是 schedule 顺序。- 它们不是更快的 thread。它们是 同一条 thread 上优先级更高的 queue。
await是 Promises 上的语法。到达await会 suspend function;剩余部分被 queue 成 microtask。即使await Promise.resolve()也会 yield。
const order: string[] = []
setTimeout(() => {
order.push("task")
}, 0)
queueMicrotask(() => {
order.push("micro-1")
queueMicrotask(() => {
order.push("micro-2")
})
})
Promise.resolve().then(() => {
order.push("promise")
})
// After the timeout finally runs:
// micro-1 → promise → micro-2 → task把 callback-based API wrap 成 Promise,并不会把 host 工作变成 microtask。它改变的是 host task 跑完 之后发生什么。
function delay(ms: number): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, ms)
})
}
Promise.resolve().then(() => console.log("microtask first"))
setTimeout(() => console.log("task second"), 0)
delay(0).then(() => console.log("microtask after its timer task"))setTimeout仍然 schedule 一个 task。那个 task 跑时调用resolve,再把.thenqueue 成 microtask。- 第一个
.then在当前 task 期间被 queue,所以它赢得第一轮 checkpoint。 delay(0).then必须等它的 timer task 跑过才能跑。
Failure: 无界的 microtask 链永远回不到 task queue 或 rendering。一个总是再 schedule 另一个 then 的 then,可以在没有 while 的情况下冻住页面。
5. Walkthrough:为什么 log 是 5 1 3 4 2
Hallie 的挑战是最短的一段程序,却能逼出模型的每一部分:
Promise.resolve().then(() => console.log(1))
setTimeout(() => console.log(2), 10)
queueMicrotask(() => {
console.log(3)
queueMicrotask(() => console.log(4))
})
console.log(5)
// 5, 1, 3, 4, 2在 script task 期间:
Promise.resolve().then立刻把它的 handler queue 到 microtask queue。还没有东西 log。setTimeout把 callback 交给 host。它还不在任何 JS queue 上。queueMicrotaskqueue 第二个 microtask。console.log(5)是 synchronous。它 log 5。- Timer 到期时,host 把
() => console.log(2)enqueue 到 task queue。
Script task 结束。Stack 为空。
Microtask checkpoint:
- Promise handler log 1。
queueMicrotaskcallback log 3,再 queue 另一个 microtask。- Nested callback log 4。Checkpoint 完成。
下一个 task:
- Timer callback log 2。
- 即使 10ms timer 当时还没到期,log 仍然会是
5 1 3 4 2——timer 稍后才会加入 task queue。 - Microtasks 不等 timer。Timer 等 microtasks。
- 谜题在于 stack 何时清空,以及 每个 callback 进了哪条 queue。
6. HTML Event-Loop Algorithm
一轮更接近 spec 的 browser event-loop turn:
- Checkpoint 可以在 JS stack 为空时随时跑,不只是某个命名 "turn" 的结尾。Promise reactions 永远不会打断 resolve 它们的那个 function。
- Rendering 是可选的。 Browser 对齐的是屏幕刷新,不是「每个 task 之后」。因为 main thread 忙碌而跳过一帧,就是 jank。
requestAnimationFrame不是 microtask。 它在 "update the rendering" 期间跑,在 checkpoint 之后、style 与 layout 之前。- Browser 中的 Critical Rendering Path 就是那个 rendering 分支里发生的事。Event loop 决定这个分支 何时被允许跑。
Failure: 紧密 loop 里的 await yield 的是 microtasks,不是 tasks。要给 browser 机会 paint,工作必须回到一个 task——setTimeout、scheduler.yield()、MessageChannel,或 Worker。
7. Node.js 是另一个 Host
语言相同。Loop 不同。Node.js 由 libuv phases 驱动,不是 HTML 的「task、microtasks、也许 render」。
| Mechanism | Browser | Node.js |
|---|---|---|
| Timer callback | Task (timer source) | timers phase |
Promise .then / queueMicrotask | Microtask queue | Microtask queue, after nextTick |
process.nextTick | Does not exist | Drains before Promises, after the current operation |
setImmediate | Does not exist | check phase |
setTimeout(fn, 0) vs setImmediate | N/A | Order depends on which phase you scheduled from |
| Rendering | "Update the rendering" | None; this is a server |
process.nextTick不是 HTML microtask,而且优先级 高于 Promise jobs。nextTickloop 会饿死 I/O,就像 microtask loop 会饿死 rendering。- 不要把 browser 的排序谜题搬到 Node。从 main module schedule 的
setImmediate与setTimeout(fn, 0)出名地不保证顺序。 - Deno 与 Bun 在 timers 与 microtasks 上更接近 browser,然后加上各自的 I/O。
可携规则:要认识 host 的 queues,而不只是语言的 Promises。
8. Workers 有自己的 Loops
Web Worker、Shared Worker 或 Service Worker 是另一个 agent:自己的 heap、stack、task 与 microtask queues,以及 event loop。
postMessage是 host API。消息被 serialize,并在接收端 agent 上被 queue 成 task。- Worker 可以在页面 paint 时计算。它不能碰页面的 DOM。
- 共享内存需要
SharedArrayBuffer。它不会把两条 loops 合并。 - 把
whileoffload 会释放页面的 stack。它不会让 worker 的 stack 变得可抢占。
Failure: Worker 仍然能用长 task 或 microtask 链饿死 它自己的 timers 与 message handlers。
9. 什么会进哪一条 Queue
| API | Queue | Notes |
|---|---|---|
| Sync function call | Call stack, immediately | Blocks everything else |
setTimeout / setInterval | Task | Delay is a lower bound |
addEventListener callback | Task | User-interaction or other DOM source |
postMessage / MessageChannel | Task | Useful for yielding to rendering |
fetch completion then .then | Host I/O, then microtask | The network is not a microtask |
Promise.then / .catch / .finally | Microtask | Even on an already-settled Promise |
queueMicrotask | Microtask | Same queue as Promise jobs |
async function after await | Microtask | await always yields |
MutationObserver | Microtask | Runs in the same checkpoint |
requestAnimationFrame | Rendering | After microtasks, before paint |
requestIdleCallback | Idle task | Not a microtask; may never run under load |
process.nextTick | Node nextTick queue | Before Promise microtasks, each phase |
setImmediate | Node check phase | After poll |
当 log 顺序让你意外时:
- 现在 stack 上是什么? 那段代码永远先跑完。
- 什么被 queue 成 microtask? 它们全部会在下一个 task 与 rendering 之前跑完。
- 什么必须等下一个 task? Timers、input、messages,以及下一段 script。
Event loop 不是魔法,也不是 parallelism。它是一条政策:单一 thread 何时被允许跑下一个 callback。