跳到主要内容
返回

深入理解 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