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。