技术知识文章集合TECHNICAL ARCHIVE · 807 DOCUMENTS

显示模式

登录
ARCHIVE DOCUMENTJS

最后一次搞懂 Event Loop

所属分类
JavaScript
文件格式
Markdown
原始路径
JavaScript/103-最后一次搞懂 Event Loop
本文目录14 个章节
  1. 经典题镇楼
  2. 为什么不能简单说“JavaScript 是单线程语言”
  3. 同步和异步
  4. 执行上下文、调用栈和内存
  5. 任务队列与事件循环
  6. 来做几道题
  7. 当 Event Loop 遇到 async/await
  8. Node.js 与浏览器的差异
  9. 浅谈 Web Workers
  10. 以两道 Promise 题收尾
  11. 最后
  12. 图片来源与授权风险
  13. 原文归属
  14. 参考链接

最后一次搞懂 Event Loop

Category(分类): JavaScript Status: 已整理

Event Loop 是异步编程中很重要的宿主调度模型,也是很多面试题的背景。本文保留经典题和 Worker 阶乘示例,但不再把某个浏览器版本或旧 Node 版本的观察写成语言规范。

经典题镇楼

下面代码作为浏览器 classic script 执行时,在当前常见浏览器和 Node 现代版本中通常输出:

async function async1() {
  console.log('async1 start')
  await async2()
  console.log('async1 end')
}

async function async2() {
  console.log('async2')
}

console.log('script start')

setTimeout(() => {
  console.log('setTimeout')
}, 0)

async1()

new Promise((resolve) => {
  console.log('promise1')
  resolve()
}).then(() => {
  console.log('promise2')
})

console.log('script end')
script start
async1 start
async2
promise1
script end
async1 end
promise2
setTimeout

调用 async1() 时,会同步执行到 await async2();async2() 的调用和其中的 console.log 也同步发生。await 之后的恢复部分和 Promise reaction 一样异步排队,所以先完成当前 script,再运行 async1 end 和 promise2,最后才有机会运行 timer task。

2019 年早期的 Chrome、Firefox 和 Node 版本曾出现过不同的实现观察。那是历史兼容性记录,不能用来证明当前 ECMAScript 或 HTML 规范“不确定”;如果研究旧输出,应同时记录运行时版本、入口类型和脚本类型。

为什么不能简单说“JavaScript 是单线程语言”

在一个 ECMAScript agent 中,执行上下文按 run-to-completion 运行,当前 JavaScript 回调不会被同一 agent 中的另一个回调插入。这解释了为什么大量同步计算会阻塞页面交互:

setTimeout(() => console.log('timer'), 0)

for (let i = 0; i < 100_000_000; i += 1) {
  // 忙等会阻塞当前 agent
}

但浏览器可以创建多个 Window/Worker agent,Node.js 可以创建 worker_threads 线程。它们拥有独立的执行上下文栈和 event loop,可以并行执行 JavaScript;Worker 不能直接操作主窗口 DOM,通常通过消息、结构化克隆、转移对象或 SharedArrayBuffer/Atomics 通信。因此更准确的说法是:一个 agent 内的 JavaScript 回调按顺序运行,宿主可以创建多个 agent。

同步和异步

如果函数返回时调用者就能得到结果,可以把它称为同步调用;如果结果通过回调、Promise 或事件在之后交付,则通常称为异步 API。alert() 会阻塞当前页面的脚本和交互,直到用户关闭它:

alert('Yancey')
console.log('is')
console.log('the')
console.log('best')

setTimeout 也不是精确的闹钟:延迟达到后,回调只具备进入相应 task source 的资格;如果当前 JavaScript 仍在运行,回调必须等待。

执行上下文、调用栈和内存

这些是不同层次的概念

  • 执行上下文是 ECMAScript 描述一次代码执行所需状态的规范抽象。
  • 调用栈/执行上下文栈表示同步调用的嵌套关系,能帮助我们理解 run-to-completion。
  • task queue 是宿主的调度概念,不等于调用栈。
  • 堆内存是引擎实现和内存管理的概念,不是树形 heap 数据结构本身。

“基本类型一定在栈上、对象一定在堆上、堆由程序员分配”不是 ECMAScript 规则。引擎可以使用寄存器、内联值、不同对象表示、逃逸分析和垃圾回收;JavaScript 开发者也不直接管理 JS heap。理解调用栈有助于解释同步嵌套,但不能用物理栈/堆布局推导异步顺序。

执行栈与内存历史示意图

图片来源:原文栈/堆示意图,已本地化。原图来自第三方字节图床,转载授权未核实;图示是实现层历史材料,不能作为 ECMAScript 内存布局规范。

任务队列与事件循环

浏览器中不应把任务说成“严格按照完成时间进入唯一队列”。HTML 为 timer、用户交互、消息、脚本等定义不同 task source,用户代理从可运行队列中选择 task;某个异步操作何时完成只决定它何时具备入队资格。

一个常见浏览器流程可以抽象为:

  1. 用户代理选择一个 runnable task
  2. 运行 task 内的脚本;当前 task 的同步代码 run-to-completion
  3. 执行 microtask checkpoint,清空期间新增的 microtask
  4. 用户代理可能在 rendering opportunity 中执行 update-the-rendering 和 requestAnimationFrame 回调
  5. 选择下一个 task

浏览器事件循环历史示意图

图片来源:原文事件循环图,已本地化。原图来自第三方字节图床,转载授权未核实;请把它当作历史教学近似图。

task、microtask 和 rendering opportunity

常见浏览器 task 包括 classic script、timer、事件、postMessage/MessageChannel 消息;Promise 的 .then/catch/finally 是 Promise reaction jobs,通常由浏览器接入 microtask queue;queueMicrotask() 和 MutationObserver 通知也属于浏览器常见 microtask 来源。

requestAnimationFrame() 是渲染机会附近的回调,不是普通宏任务。渲染可能被合并、延迟、节流或跳过,因此不能断言每个 microtask checkpoint 后都有绘制,也不能断言 rAF 一定早于 timer。

来做几道题

第一题:Promise executor 同步,reaction 异步

setTimeout(() => {
  console.log('A')
}, 0)

const obj = {
  func() {
    setTimeout(() => {
      console.log('B')
    }, 0)

    return new Promise((resolve) => {
      console.log('C')
      resolve()
    })
  },
}

obj.func().then(() => {
  console.log('D')
})

console.log('E')

在一个干净的浏览器 classic script 中,典型顺序是:C、E、D、A、B。调用 func() 时 Promise executor 同步打印 C;.then() 的 reaction 等 script task 结束后才运行;两个 timer task 再按其 timer source 的可运行顺序执行。不要把“两个 timer 都是先注册的”扩大为所有宿主、所有 task source 的全局 FIFO 保证。

第二题:reaction job 的入队顺序

const p = new Promise((resolve) => {
  resolve(1)
  Promise.resolve().then(() => console.log(2))
  console.log(4)
}).then((value) => {
  console.log(value)
})

console.log(3)

典型输出是 4、3、2、1。executor 先同步排入打印 2 的 reaction,并同步打印 4;外层 .then() 的 reaction 在 Promise 兑现时排入,随后当前 script 打印 3。checkpoint 按 job 入队顺序执行。

当 Event Loop 遇到 async/await

async/await 不是“仅仅是生成器的语法糖”。它是 ECMAScript 的 async function 和 Await 抽象操作。生成器可以作为某些编译器的实现策略,但不能当成语言语义的逐字等价转换。

下面的代码可以帮助建立直觉:

async function foo() {
  console.log('before await')
  const value = await bar()
  console.log('after await', value)
}

async function bar() {
  console.log('bar body')
  return 42
}

foo()

调用 foo() 时,bar() 的调用和右侧表达式求值会发生在暂停前;await 之后的恢复部分通过 Promise 相关 job 异步执行。可以写一段近似的 Promise 链帮助理解,但 thenable 吸收、异常、返回值和 job 数量不能靠下面的伪转换定义:

function fooIllustrative() {
  console.log('before await')

  return Promise.resolve(bar()).then((value) => {
    console.log('after await', value)
  })
}

这只是教学近似,不是规范级的机械改写。

Node.js 与浏览器的差异

Node.js 不是 HTML event loop。Node 11 附近曾调整 timer/immediate 等 callback 之间的 microtask 处理,原文“升级后和浏览器完全一致”的说法过于简单。当前 Node 需要同时考虑:

  • Node 专用的 process.nextTick() queue
  • V8 的 Promise/queueMicrotask() queue
  • libuv 的 timers、pending callbacks、poll、check、close 等阶段
  • CommonJS 和 ESM 顶层执行入口的差异
  • Node 20 使用的 libuv 1.45.0 对正常 timers 位置的调整

下面三句在 .cjs 和 .mjs 中的常见顺序不同:

Promise.resolve().then(() => console.log('promise'))
queueMicrotask(() => console.log('microtask'))
process.nextTick(() => console.log('nextTick'))

CommonJS 顶层常见 nextTick -> promise -> microtask,ESM 顶层常见 promise -> microtask -> nextTick。这不是浏览器规则,也不应写成所有 Node 版本和所有回调入口的绝对保证。

主模块同时注册 setTimeout(..., 0) 和 setImmediate() 时顺序不保证;同一 I/O 回调中,setImmediate() 通常先于同处注册的 timer:

const fs = require('node:fs')

fs.readFile(__filename, (error) => {
  if (error) throw error

  setTimeout(() => console.log('timeout'), 0)
  setImmediate(() => console.log('immediate'))
})

浅谈 Web Workers

Worker 是浏览器宿主 API,但这不意味着 JavaScript 语言“没有多线程能力”。ECMAScript 有 agents、共享内存和原子操作的语言基础;浏览器的 Web Worker 和 Node 的 worker_threads 使用独立 agent/event loop,可以与主 agent 并行执行 JavaScript。Worker 不能直接操作主窗口 DOM,通常通过 postMessage 传递数据。

Worker 独立 agent 示意图

图片来源:原文 Worker 流程图,已本地化。原图来自第三方字节图床,转载授权未核实;图中素材的再发布许可需要另行确认。

Web Worker 实例:计算阶乘

下面的示例按 worker.js 统一命名。每次点击都会新建一个 Worker,收到结果或错误后终止它;生产应用可复用 Worker 以减少启动成本。

index.html:

<body>
  <fieldset>
    <legend>计算阶乘</legend>
    <input id="input" type="number" min="0" max="18" placeholder="请输入 0 到 18 的整数">
    <button id="btn">计算</button>
    <p>计算结果:<span id="result"></span></p>
  </fieldset>

  <script>
    const input = document.getElementById('input')
    const button = document.getElementById('btn')
    const result = document.getElementById('result')

    button.addEventListener('click', () => {
      const value = Number(input.value)
      if (!Number.isSafeInteger(value) || value < 0 || value > 18) {
        result.textContent = '请输入 0 到 18 的整数'
        return
      }

      const worker = new Worker('./worker.js')
      const cleanup = () => worker.terminate()

      worker.addEventListener('message', ({ data }) => {
        result.textContent = String(data)
        cleanup()
      }, { once: true })

      worker.addEventListener('error', (error) => {
        console.error(error)
        result.textContent = '计算失败'
        cleanup()
      }, { once: true })

      worker.postMessage(value)
    })
  </script>
</body>

同目录下的 worker.js:

function factorial(value) {
  if (value <= 1) return 1
  return value * factorial(value - 1)
}

self.addEventListener('message', ({ data }) => {
  if (!Number.isSafeInteger(data) || data < 0 || data > 18) {
    throw new RangeError('只支持 0 到 18 的整数')
  }

  self.postMessage(factorial(data))
})

主线程使用 textContent 而不是把 Worker 返回值插入 innerHTML,避免把可控数据当作 HTML 解析。这里把输入限制到 18,是因为 18! 仍能精确表示为安全整数;19! 起普通 Number 不再保证整数精度。需要处理更大且精确的阶乘时,应改用 BigInt,并确认消息传递和展示方式。

Worker 阶乘示例示意图

图片来源:原文阶乘示例配图,已本地化。原图来自第三方字节图床,转载授权未核实;请确认公开发布许可。

以两道 Promise 题收尾

第一道题

const p1 = new Promise((resolve) => {
  console.log('promise1')
  resolve()
})
  .then(() => {
    console.log('then11')

    new Promise((resolve) => {
      console.log('promise2')
      resolve()
    })
      .then(() => {
        console.log('then21')
      })
      .then(() => {
        console.log('then23')
      })
  })
  .then(() => {
    console.log('then12')
  })

const p2 = new Promise((resolve) => {
  console.log('promise3')
  resolve()
}).then(() => {
  console.log('then31')
})

当前常见实现的输出顺序是:

promise1
promise3
then11
promise2
then31
then21
then12
then23

关键在于:初始队列是 p1 的 then11 reaction 和 p2 的 then31 reaction。执行 then11 时,Promise executor 立即同步打印 promise2;内部 Promise 的 then21 reaction 随后入队。因为外层 then 回调没有返回这个内部 Promise,外层链会很快兑现并排入 then12,所以 then12 不会等待 then21/then23。

第二道题

const p1 = new Promise((resolve) => {
  console.log('promise1')
  resolve()
})
  .then(() => {
    console.log('then11')

    return new Promise((resolve) => {
      console.log('promise2')
      resolve()
    })
      .then(() => {
        console.log('then21')
      })
      .then(() => {
        console.log('then23')
      })
  })
  .then(() => {
    console.log('then12')
  })

这里返回了内部 Promise,外层链会采用它的状态,典型输出为:

promise1
then11
promise2
then21
then23
then12

Promise 链中“是否返回 Promise”会改变后续 reaction 的入队时机;executor 的同步执行和 reaction job 的异步执行仍然要分开看。

最后

Event Loop 的入门题有价值,但答案必须写明宿主和版本:浏览器看 task source、microtask checkpoint 和 rendering opportunity;Node 看 nextTick、V8 microtask 和 libuv phases。不要用单一“宏队列/微队列”图去覆盖所有环境,也不要把 async/await 说成生成器的逐字语法糖。

作者和原始文章归属沿用原文说明;本文的规范参考改为官方链接。

图片来源与授权风险

  • images/103-image-01.webp:栈/堆历史示意图,来源为原文第三方图床,授权未核实。
  • images/103-image-02.webp:事件循环历史示意图,来源为原文第三方图床,授权未核实。
  • images/103-image-03.webp:Worker 流程示意图,来源为原文第三方图床,授权未核实。
  • images/103-image-04.webp:Worker 阶乘示例图,来源为原文第三方图床,授权未核实。

本地 WebP 只用于避免正文热链失效;图片版权、图中字体和再发布许可仍需人工确认。

原文归属

作者:YanceyOfficial。历史来源:掘金原文《最后一次搞懂 Event Loop》。原文链接仅用于保留历史出处,当前语义以本文列出的规范和官方文档为准。

参考链接

807 DOCUMENTS · 12 COLLECTIONS
ARCHIVE SEARCH807 篇文章