跳至主要內容
返回

深入理解 JavaScript Event Loop

前端

Call stack、Web APIs、task queue 與 microtask queue 實際上如何合作——以及為什麼 0ms 從來不是立刻執行

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 在跑。


ts
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:


ts
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 時:

  1. 這次呼叫在 stack 上跑,幾乎立刻 return。
  2. Host 開始真正的工作——timer、network request、DOM listener。
  3. 工作完成時,host 不會把 callback 推進 stack。它把它放到 queue。

ts
console.log("A")

setTimeout(() => {
  console.log("C")
}, 1000)

console.log("B")

// A, B, then C after the timer and after the stack is empty

  • setTimeout 本身是 synchronous。它只是 schedule。
  • Delay 是下限。Callback 在 1000ms 還是更晚跑,取決於 stack 與 queues,不只是 timer。
  • 同樣的拆分適用於 addEventListener、XMLHttpRequest、MessageChannel,以及較舊的 Node fs.readFile(path, cb)。
  • Promise-based APIs 的 I/O 仍然走 host。差別在於 continuation 進哪條 queue:fetch(url).then(onResponse) 等 network,然後把 onResponse queue 成 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 跑到完成。

ts
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-2

Delay 為 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 之後的 async function continuations
  • queueMicrotask(fn)
  • MutationObserver callbacks

  • 一個 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。

ts
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 跑完 之後發生什麼。


ts
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,再把 .then queue 成 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 的挑戰是最短的一段程式,卻能逼出模型的每一部分:


ts
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 期間:

  1. Promise.resolve().then 立刻把它的 handler queue 到 microtask queue。還沒有東西 log。
  2. setTimeout 把 callback 交給 host。它還不在任何 JS queue 上。
  3. queueMicrotask queue 第二個 microtask。
  4. console.log(5) 是 synchronous。它 log 5。
  5. Timer 到期時,host 把 () => console.log(2) enqueue 到 task queue。

Script task 結束。Stack 為空。

Microtask checkpoint:

  1. Promise handler log 1。
  2. queueMicrotask callback log 3,再 queue 另一個 microtask。
  3. Nested callback log 4。Checkpoint 完成。

下一個 task:

  1. 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」。


MechanismBrowserNode.js
Timer callbackTask (timer source)timers phase
Promise .then / queueMicrotaskMicrotask queueMicrotask queue, after nextTick
process.nextTickDoes not existDrains before Promises, after the current operation
setImmediateDoes not existcheck phase
setTimeout(fn, 0) vs setImmediateN/AOrder depends on which phase you scheduled from
Rendering"Update the rendering"None; this is a server

  • process.nextTick 不是 HTML microtask,而且優先級 高於 Promise jobs。
  • nextTick loop 會餓死 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 合併。
  • 把 while offload 會釋放頁面的 stack。它不會讓 worker 的 stack 變得可搶佔。

Failure: Worker 仍然能用長 task 或 microtask 鏈餓死 它自己的 timers 與 message handlers。


9. 什麼會進哪一條 Queue

APIQueueNotes
Sync function callCall stack, immediatelyBlocks everything else
setTimeout / setIntervalTaskDelay is a lower bound
addEventListener callbackTaskUser-interaction or other DOM source
postMessage / MessageChannelTaskUseful for yielding to rendering
fetch completion then .thenHost I/O, then microtaskThe network is not a microtask
Promise.then / .catch / .finallyMicrotaskEven on an already-settled Promise
queueMicrotaskMicrotaskSame queue as Promise jobs
async function after awaitMicrotaskawait always yields
MutationObserverMicrotaskRuns in the same checkpoint
requestAnimationFrameRenderingAfter microtasks, before paint
requestIdleCallbackIdle taskNot a microtask; may never run under load
process.nextTickNode nextTick queueBefore Promise microtasks, each phase
setImmediateNode check phaseAfter poll

當 log 順序讓你意外時:

  1. 現在 stack 上是什麼? 那段程式碼永遠先跑完。
  2. 什麼被 queue 成 microtask? 它們全部會在下一個 task 與 rendering 之前跑完。
  3. 什麼必須等下一個 task? Timers、input、messages,以及下一段 script。

Event loop 不是魔法,也不是 parallelism。它是一條政策:單一 thread 何時被允許跑下一個 callback。


Recap Q&A