最后一次搞懂 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;某个异步操作何时完成只决定它何时具备入队资格。
一个常见浏览器流程可以抽象为:
- 用户代理选择一个 runnable task
- 运行 task 内的脚本;当前 task 的同步代码 run-to-completion
- 执行 microtask checkpoint,清空期间新增的 microtask
- 用户代理可能在 rendering opportunity 中执行 update-the-rendering 和
requestAnimationFrame回调 - 选择下一个 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 流程图,已本地化。原图来自第三方字节图床,转载授权未核实;图中素材的再发布许可需要另行确认。
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,并确认消息传递和展示方式。

图片来源:原文阶乘示例配图,已本地化。原图来自第三方字节图床,转载授权未核实;请确认公开发布许可。
以两道 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》。原文链接仅用于保留历史出处,当前语义以本文列出的规范和官方文档为准。
参考链接
- ECMAScript:Agents
- ECMAScript:Execution Contexts
- ECMAScript:Promise reaction job
- ECMAScript:Await
- WHATWG HTML:event loop processing model
- WHATWG HTML:worker event loop
- WHATWG HTML:dedicated worker 示例
- MDN:Web Workers API
- MDN:requestAnimationFrame
- Node.js:event loop、timers 和 nextTick
- Node.js:queueMicrotask 与 process.nextTick
- Node.js:worker_threads
- libuv:design overview