Skip to content
事件循环、微任务、宏任务
概述
事件循环(Event Loop)是浏览器与 Node.js 等宿主环境调度异步任务的基础模型。它本身不属于 ECMAScript 规范,而是由宿主的 Web API 或底层库实现——浏览器遵循 HTML Standard 的 Event Loop Processing Model,Node.js 依赖 libuv。微任务(Microtask)与宏任务(Macrotask)是事件循环中两类执行时机不同的任务调度单元,直接决定了异步代码的执行顺序与渲染行为。
基本概念
事件循环的定义不在 ECMAScript 规范中,而是由 HTML 标准 §8.1.6 给出。ECMAScript 只定义了 Promise、async/await 等语言特性,它们通过宿主环境提供的 Job Queue(在实现中常对应微任务队列)与事件循环对接。
宏任务(macrotask) 又称 task,每一次从 task queue 中取出一个任务执行的过程构成一个事件循环迭代。典型的宏任务源包括:
setTimeout、setInterval- I/O 回调
- UI 交互事件(鼠标、键盘)
- 网络事件(XHR、fetch 回调)
微任务(microtask) 则在一个宏任务执行完毕后、下一个宏任务开始前被批量清空。微任务源主要包括:
Promise.then/catch/finallyqueueMicrotask(ES2020)MutationObserver- Node.js 中的
process.nextTick(优先于其他微任务)
微任务队列清空后,浏览器才可能进入渲染流程。这一时序差异是理解异步行为的关键。
工作原理
事件循环的一次迭代
一个完整的事件循环迭代(agent event loop turn)的典型阶段为:
text
1. 从 task queue 中取出最老的 task(task selection)
2. 设置为当前 running task
3. 执行该 task 中的同步代码段
4. 清空 microtask queue(Perform a microtask checkpoint)
├── 依次执行每个 microtask,包括执行过程中新产生的 microtask
└── microtask queue 完全为空后才结束
5. 检查是否需要渲染(Update the rendering)
├── 执行 requestAnimationFrame 回调
├── 样式计算、布局、绘制、合成
└── requestIdleCallback 在渲染后空闲时段执行
6. 进入下一次迭代(跳回步骤 1)要点:microtask queue 的清空发生在每个宏任务执行完毕后,若在清空过程中持续产生新的微任务,该过程会一直持续直到队列为空;渲染则必须在微任务全部完成之后才可能执行。
V8 的实现角色
V8 本身并不实现事件循环——事件循环由嵌入器(embedder)提供,即浏览器端的 Blink 或 Node.js 端的 libuv。V8 向嵌入器暴露微任务相关的接口:
cpp
// 简化的 V8 嵌入器 API
v8::Isolate::PerformMicrotaskCheckpoint() // 清空微任务队列
v8::MicrotasksScope // 微任务作用域控制
v8::platform::PumpMessageLoop() // 驱动消息循环微任务入队机制
Promise.then() 产生的微任务入队并非 Promise 自身直接完成,而是通过 V8 的 EnqueueMicrotask 回调:
text
Promise.then(onFulfilled)
→ NewPromiseCapability()
→ PerformPromiseThen()
→ EnqueueJob("PromiseJobs", PromiseReactionJob) // 规范抽象
→ V8::EnqueueMicrotask() // 引擎层实现
→ 微任务队列追加V8 源码中,MicrotaskQueue 底层是一个单向链表(std::unique_ptr<Microtask> head_)。每次 EnqueueMicrotask 向链表尾部追加节点,PerformMicrotaskCheckpoint 从头部逐个取出执行,直到链表为空。
Blink 侧的调度
在 Chromium 中,事件循环的具体实现位于 scheduler/ 下的 TaskQueueManager。渲染进程维护多个 task queue,并区分优先级:
| Queue | 优先级 | 典型内容 |
|---|---|---|
| Input Queue | 最高 | 鼠标/键盘/触摸事件 |
| Compositor Queue | 高 | requestAnimationFrame |
| Default Queue | 中 | setTimeout、网络回调、IO |
| Idle Queue | 低 | requestIdleCallback |
同一事件循环迭代中,高优先级队列先被消费。这就是为什么 setTimeout(fn, 0) 的实际延迟通常大于 0ms——回调不仅要排队,还需要等待更高优先级队列被消耗完毕。
渲染检查点
浏览器的「Update the rendering」并非在每个 task 之间都执行,而是受 vsync 信号驱动(通常 60 Hz,约 16.6 ms 间隔)。在一个 vsync 周期内的典型流程:
text
vsync → input events (macrotasks) → microtask checkpoint → rAF → layout → paint → composite → vsync几个关键事实:
requestAnimationFrame在渲染阶段的 layout 之前执行,不与宏/微任务队列混排。requestIdleCallback在帧末尾空闲时执行,deadline 到下一帧 vsync 为止。- 当前宏任务内产生的所有微任务,必须在 microtask checkpoint 中全部清空后,才可能进入渲染。
若在微任务中无休止地追加新任务,渲染将永远无法执行——页面卡死:
js
// 微任务饥饿 — 页面永不渲染
function recursiveMicrotask() {
Promise.resolve().then(() => {
recursiveMicrotask() // 在清空过程中不断追加新的微任务
})
}
recursiveMicrotask()
// 此后的宏任务(包括用户交互、渲染)永远不会被执行基本 API
宏任务调度
setTimeout(fn, delay)、setInterval(fn, delay):将回调加入 task queue,延迟至少为指定毫秒数(受嵌套层级、系统负载影响)。- 在 Node.js 中,
setImmediate(fn)将回调注册到 check 阶段(一种特定宏任务队列)。
微任务调度
js
// 通过 Promise
Promise.resolve().then(() => {
// 微任务
})
// 通过 queueMicrotask(ES2020,等价于 Promise.resolve().then(fn) 但无额外 Promise 包装)
queueMicrotask(() => {
// 微任务
})与渲染相关的调度
js
// 请求动画帧 — 在渲染前执行
requestAnimationFrame(() => {
// 用于视觉更新,与帧率同步
})
// 空闲回调 — 在帧末尾空闲时执行
requestIdleCallback((deadline) => {
// deadline.timeRemaining() 可获取剩余空闲时间
})Node.js 专有:process.nextTick
js
process.nextTick(() => {
// nextTick 回调在当前操作完成后立即执行,优先级高于其他微任务
})process.nextTick 的回调存于 nextTickQueue,在每个 C/C++ → JS 边界转换时被清空,执行时机早于 Promise 微任务。
示例
async/await 的执行顺序分析
await 是 Promise.then() 的语法糖,其求值算法(ECMAScript §14.7.5.14)的关键步骤为:
- 对
expr求值 - 调用
Promise.resolve(expr);若expr不是 Promise,则包装为一个立即 resolve 的 Promise - 调用
.then(resume),将await后续的代码作为 then 回调注册为微任务
以下面的代码为例:
js
function log(key, ms) {
return setTimeout(() => {
console.log(key);
}, ms);
}
(async function () {
await log(1, 300); // 返回 timer ID(数字),非 Promise
await log(2, 200);
})();
(async function () {
let a = log(3, 200);
let b = log(4, 300);
await a;
await b;
})();执行过程拆解:
- 第一个 IIFE 执行,调用
log(1, 300),setTimeout回调安排到约 300ms 后触发,返回值是 timer ID。 await log(1, 300)→Promise.resolve(timerId).then(resume),then 回调入队微任务。此时同步代码尚未执行完毕,微任务未开始执行。- 第二个 IIFE 执行:
log(3, 200)在约 200ms 后触发,log(4, 300)在约 300ms 后触发。 await a(a 是 timer ID)→Promise.resolve(timerId).then(resume)再次入队微任务。- 同步代码执行完毕,开始清空微任务队列。
- 第一个微任务恢复第一个 IIFE,调用
log(2, 200)(安排约 200ms 后的回调),并因为await log(2, 200)再次入队微任务。 - 第二个微任务恢复第二个 IIFE,
await b(b 也是 timer ID)再次入队微任务。 - 微任务队列继续执行,依此类推。
最终输出受 setTimeout 延迟控制:200ms 级的 3 和 2 先输出(同为 200ms 时按注册顺序),300ms 级的 1 和 4 后输出。输出结果为:
3
2
1
4微任务链的交错执行
js
async function chain() {
console.log('A')
await Promise.resolve()
console.log('B')
await Promise.resolve()
console.log('C')
}
chain()
Promise.resolve().then(() => console.log('D'))输出顺序为 A → B → D → C。
原因:第一个 await 之后的 console.log('B') 作为微任务执行,执行后第二个 await 才产生新的 then 回调进入微任务队列,而此时 'D' 的微任务已先于它在队列中,所以 D 在 C 之前打印。
并发与串行
js
// 串行 — 每个 await 等待上一个 Promise resolve
async function sequential(urls) {
const results = []
for (const url of urls) {
const data = await fetch(url)
results.push(data)
}
return results
}
// 并发 — 所有请求同时发出,再等待结果
async function concurrent(urls) {
const promises = urls.map(url => fetch(url))
const results = await Promise.all(promises)
return results
}for...of + await 会让每次迭代等待上一个 Promise 完成,整体耗时累加。map + Promise.all 则让所有请求并行发起,总耗时取决于最慢的那个。
rAF 与微任务的交互
js
requestAnimationFrame(() => {
console.log('rAF')
Promise.resolve().then(() => console.log('microtask in rAF'))
})rAF 回调内的 Promise.then 微任务,会在 rAF 回调执行完毕后、实际 layout 之前被清空。因为微任务清空发生在每个 JS 执行上下文退出时,rAF 回调退出会触发 microtask checkpoint。
注意点
微任务饥饿与任务拆分
在微任务中递归调度自身会阻塞所有宏任务和渲染。正确的做法是将工作分散到不同的宏任务迭代中,为渲染留出间隙:
js
// ❌ 微任务递归 — 阻塞渲染
function processBatch(items) {
if (items.length === 0) return
doWork(items.shift())
Promise.resolve().then(() => processBatch(items))
}
// ✅ 使用 setTimeout 切分到多个宏任务
function processBatch(items) {
if (items.length === 0) return
doWork(items.shift())
setTimeout(() => processBatch(items), 0)
}当单个任务执行耗时超过 50ms 时,DevTools 会将其标记为长任务(Long Task)。将长任务拆分为多个短任务可保持页面交互响应。
setTimeout 的延迟保护
HTML 规范要求:当 setTimeout 的嵌套层级超过 5 时,最小延迟会被强制设为 4ms。现代浏览器在此基础上进一步扩展了保护机制——例如在 Chromium 中,setTimeout(fn, 0) 的实际最小延迟通常为 1ms(通过 DOMTimer::CalculateDelay 强制调整)。对于高精度定时或动画,应使用 requestAnimationFrame。
async/await 中同步与微任务的边界
js
async function demo() {
console.log(1) // 同步
await Promise.resolve()
console.log(2) // 微任务
}
demo()
console.log(3)
// 输出:1 → 3 → 2await 之前的代码同步执行,await 之后的代码被注册为微任务,因此会在本轮同步代码(3)之后执行。
await 的错误处理
await 将 Promise 的 reject 转换为同步的异常抛出(在微任务执行过程中)。若未捕获,会成为未处理的 Promise rejection。
js
async function fetchData() {
await fetch('/api/data') // 若失败,此处抛出
processData() // 不会执行
}
// 应当显式捕获
async function fetchData() {
try {
const res = await fetch('/api/data')
processData(res)
} catch (e) {
handleError(e)
}
}Node.js 中 process.nextTick 的优先级
js
Promise.resolve().then(() => console.log('microtask'))
process.nextTick(() => console.log('nextTick'))
// 输出:nextTick → microtaskprocess.nextTick 在 Node.js 的事件循环各阶段之间被清空,早于 Promise 微任务。该机制常用于确保异步操作中错误优先回调的错误能被正确传递。
Promise.all 的并发微任务
js
await Promise.all([
fetch('/api/a'),
fetch('/api/b'),
fetch('/api/c'),
])三个请求并发发出,但它们的 resolve 回调会在同一个 microtask checkpoint 中依次执行,按 Promise.all 内部记录的完成顺序逐个运行,其间不会插入宏任务或渲染。
应用:性能调试
在 Chrome DevTools Performance 面板中录制页面活动,可以观察 Main Thread 上的任务与微任务分布。
- 长任务标记:耗时超过 50ms 的 Task 会被标记为红色,它们是引起交互延迟的主要原因。
- 微任务诊断:Main Thread 中连续出现
Run Microtasks块,且没有Update Layer Tree/Paint/Composite等渲染事件,表明存在微任务饥饿。 - 调用栈分析:利用 Call Tree 或 Bottom-Up 面板,可以定位耗时最久的 Promise.then 调用链。
修复方向:将长任务拆分为 setTimeout 或 requestIdleCallback 调度的小任务块;避免在微任务中无限追加新任务。
参考链接
- HTML Standard: Event Loop Processing Model
https://html.spec.whatwg.org/multipage/webappapis.html#event-loop-processing-model - ECMAScript Specification:
await
https://tc39.es/ecma262/#sec-await - V8 Embedder’s Guide: Microtasks
https://v8.dev/docs/embed - Chromium Task Scheduling
https://chromium.googlesource.com/chromium/src/+/refs/heads/main/docs/task_scheduling.md - queueMicrotask API (MDN)
https://developer.mozilla.org/en-US/docs/Web/API/queueMicrotask - Node.js: process.nextTick
https://nodejs.org/api/process.html#process_process_nexttick_callback_args
