技术知识文章集合TECHNICAL ARCHIVE · 457 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 endpromise2,最后才有机会运行 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 中,典型顺序是:CEDAB。调用 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)

典型输出是 4321。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

关键在于:初始队列是 p1then11 reaction 和 p2then31 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》。原文链接仅用于保留历史出处,当前语义以本文列出的规范和官方文档为准。

参考链接

457 DOCUMENTS · 10 COLLECTIONS
ARCHIVE SEARCH457 篇文章

SEARCH GUIDE

输入关键词开始搜索

支持搜索文章标题、所属分类和原始文档路径。

按分类浏览

10 COLLECTIONS