Skip to content

事件循环、微任务、宏任务

事件循环在规范中的定义

事件循环(Event Loop)的定义不在 ECMAScript 规范中,而在 HTML 标准 §8.1.6 的 Event Loop Processing Model 中。ECMAScript 只定义了 Promise、async/await 等语言特性,它们通过宿主环境(浏览器、Node.js)提供的 Job Queue 接口与事件循环交互。

一个完整的事件循环迭代(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
   └── microtask queue 完全为空后才结束
5. 检查是否需要渲染(Update the rendering)
   ├── requestAnimationFrame 回调在此阶段执行
   ├── 样式计算 → 布局 → 绘制 → 合成
   └── requestIdleCallback 在渲染后执行(空闲时段)
6. 循环回到步骤 1

关键点:microtask queue 的清空发生在每个 macrotask 执行完毕后、渲染之前。这是区分宏任务和微任务的核心——微任务在当前 task 内同步清空,宏任务各自占据独立的 task turn。


V8 中的实现路径

V8 本身不实现事件循环——事件循环由嵌入器(embedder)提供,即浏览器(Blink)或 Node.js(libuv)。V8 暴露了以下接口供嵌入器调用:

cpp
// 简化的 V8 嵌入器 API
v8::Isolate::PerformMicrotaskCheckpoint()  // 清空微任务队列
v8::MicrotasksScope                          // 微任务作用域控制
v8::platform::PumpMessageLoop()              // 驱动消息循环

Microtask 入队机制

Promise.then() 的微任务入队不是由 Promise 本身完成的,而是通过 V8 的 EnqueueMicrotask 回调:

text
Promise.then(onFulfilled)
  → NewPromiseCapability()
  → PerformPromiseThen()
  → EnqueueJob("PromiseJobs", PromiseReactionJob)  // 规范抽象
  → V8::EnqueueMicrotask()                          // 引擎层实现
  → 微任务队列追加

在 V8 源码中,MicrotaskQueue 底层是一个单向链表(std::unique_ptr<Microtask> head_),每个 Microtask 节点持有指向下一个 microtask 的指针。每次 EnqueueMicrotask 向链表尾部追加节点,PerformMicrotaskCheckpoint 从链表头部逐个取出执行,直到链表为空。

在 Chromium 中,事件循环的具体实现位于 scheduler/ 目录下的 TaskQueueManager。渲染进程维护多个 task queue:

Queue优先级典型内容
Input Queue最高鼠标/键盘/触摸事件
Compositor QueuerequestAnimationFrame
Default QueuesetTimeout、网络回调、IO
Idle QueuerequestIdleCallback

同一 task turn 中,高优先级队列被优先消费。这是为什么 setTimeout 0 的实际延迟通常 >0ms——回调不仅排队,还要等更高优先级的队列先被 drain。


async/await 的 Promise 展开

await 的语义是 Promise.then() 的语法糖,但展开方式决定了执行顺序。规范(ECMAScript §14.7.5.14)定义了 await 的求值算法,简化后的关键步骤:

text
await expr 的执行:
1. 对 expr 求值
2. 调用 Promise.resolve(expr)
3. 如果 expr 不是 Promise,则包一层 Promise(立即 resolve)
4. 调用 .then(resume) — resume 是 await 后续代码
   这一步将 await 后面的代码作为 .then 的回调入队微任务队列

以原始代码为例来分析实际执行顺序:

js
function log(key, ms) {
    return setTimeout(() => {
        console.log(key);
    }, ms);
}

(async function () {
    await log(1, 300);  // log 返回 setTimeout ID(数字),非 Promise
    await log(2, 200);
})();

(async function () {
    let a = log(3, 200);
    let b = log(4, 300);
    await a;
    await b;
})();

拆解过程:

  1. 第一个 IIFE 开始执行,调用 log(1, 300)——setTimeout 被调用,回调被安排到 300ms 后执行,返回值是 timer ID(一个数字)
  2. await log(1, 300)Promise.resolve(timerId).then(resume)——log(1, 300) 返回数字,await 把它包成 Promise.then 作为微任务入队
  3. 微任务队列中有第一个 .then,此时同步代码还未执行完(第二个 IIFE 尚未执行),微任务未执行
  4. 第二个 IIFE 开始执行:log(3, 200)log(4, 300) 分别安排在 200ms 和 300ms 后
  5. await a(a 是 timer ID 数字),Promise.resolve(timerId).then(resume) 入队微任务
  6. 同步代码执行完毕,开始清空微任务队列
  7. 第一个微任务执行:恢复第一个 IIFE,调用 log(2, 200),安排 200ms 后执行,await log(2, 200) 再次入队微任务
  8. 第二个微任务执行:恢复第二个 IIFE,await b(b 是 timer ID),Promise.resolve(timerId).then(resume) 入队微任务
  9. 微任务继续执行,以此类推

最终输出:3 → 2 → 1 → 4(受 setTimeout 延迟控制,200ms 的 3 和 2 先到达,300ms 的 1 和 4 后到达)

要点:await 后面的代码总是作为微任务执行,不论 await 后面是否跟 Promise。区分点在于:若后面不是 Promise,只产生一层 .then;若是 Promise,.then 的注册时机取决于 Promise 的状态。


渲染检查点与事件循环的关系

浏览器的渲染(Update the Rendering)不是每个 task 之间都执行,而是受 vsync 信号 驱动(通常 60Hz,约 16.6ms 间隔)。在一个 vsync 周期内:

text
vsync → input events (macrotasks) → microtask checkpoint → rAF → layout → paint → composite → vsync

关键结论:

  1. requestAnimationFrame 在渲染阶段执行,不在 macrotask/microtask 队列中。它的回调在当前帧的 layout 之前执行
  2. requestIdleCallback 在帧末尾空闲时执行,deadline 到下一帧 vsync 为止
  3. 在一个 macrotask 内产生的所有 microtask 都在同一个 task turn 中清空,渲染要等 microtask queue 完全为空后才可能发生

这意味着:如果 microtask queue 被无休止地追加新任务(microtask starvation),渲染永远无法执行——页面卡死。

js
// microtask 饥饿示例 — 页面永不渲染
function recursiveMicrotask() {
  Promise.resolve().then(() => {
    recursiveMicrotask() // 每次清空 microtask 时又追加新的
  })
}
recursiveMicrotask()
// 之后的 macrotask(包括用户交互、渲染)永远不会执行

处理方案:常见异步时序问题

方案一:拆分长任务,防止 microtask 饥饿

js
// ❌ 微任务递归 — 阻塞渲染
function processBatch(items) {
  if (items.length === 0) return
  doWork(items.shift())
  Promise.resolve().then(() => processBatch(items)) // 永远在 microtask
}

// ✅ 交替调度 — macrotask 间允许渲染
function processBatch(items) {
  if (items.length === 0) return
  doWork(items.shift())
  setTimeout(() => processBatch(items), 0) // 切到下一个 macrotask turn
}

setTimeout 0 将下一次处理推入新的 macrotask,为渲染检查点留出间隙。对于 >50ms 的长任务,拆分为多个 <50ms 的任务块可保持页面 60fps 响应。

方案二:利用 queueMicrotask 显式控制优先级

js
// 需要在当前 task 内、渲染前完成的高优操作
queueMicrotask(() => {
  updateCriticalState() // 微任务 — 当前 task 结束前执行
})

// 可推迟到下一帧的操作
requestAnimationFrame(() => {
  updateVisualState() // 渲染前执行 — 避免 layout 抖动
})

// 完全不急的操作
requestIdleCallback(() => {
  sendAnalytics() // 空闲时段执行
})

queueMicrotask(ES2020)是 Promise.resolve().then(fn) 的语义等价物,但更直接、无 Promise 包装开销。

方案三:async/await 的错误处理

js
// ❌ await 后面的代码抛错,作为微任务的未捕获异常
async function fetchData() {
  await fetch('/api/data') // 若 fetch 失败 reject,错误在这里抛出
  processData()             // 这行不会执行
}

// ✅ 明确 try/catch 或 .catch
async function fetchData() {
  try {
    const res = await fetch('/api/data')
    processData(res)
  } catch (e) {
    handleError(e)
  }
}

await 将 Promise reject 转换为同步 throw(在微任务执行过程中),未被 catch 的 reject 会成为未处理的 Promise rejection。

方案四:setTimeout 延迟的 4ms 保护

js
// 嵌套的 setTimeout 有最小延迟
setTimeout(() => {
  setTimeout(() => {
    // 嵌套 5 层后,最小延迟从 0 变为 4ms(浏览器实现)
  }, 0)
}, 0)

HTML 规范规定:setTimeout 嵌套层级 > 5 时,最小延迟为 4ms。这是为了防止 setTimeout 的紧密循环阻塞事件循环。现代浏览器扩展了此保护——Chromium 中 setTimeout(fn, 0) 的实际最小延迟通常为 1ms(非 0ms),通过 DOMTimer::CalculateDelay 强制调整。

对于动画或高精度定时,用 requestAnimationFrame 替代 setTimeout

方案五:Promise.all 与并发微任务

js
// ✅ Promise.all — 所有 Promise 的 .then 在同一次 microtask 清空中执行
await Promise.all([
  fetch('/api/a'),
  fetch('/api/b'),
  fetch('/api/c'),
])
// 三个请求并发,但它们的 resolve 回调在同一个 microtask checkpoint 中依次执行
// 顺序:按 Promise.all 内部记录的 completion index

注意 Promise.all 的并发限制——三个 fetch 的 .then 在同一个 microtask checkpoint 内依次入队、依次执行,三个微任务之间不会插入 macrotask 或渲染。

方案六:Node.js 侧的 process.nextTick

js
// Node.js 专有:nextTick 优先级高于所有微任务
Promise.resolve().then(() => console.log('microtask'))
process.nextTick(() => console.log('nextTick'))
// 输出:nextTick → microtask

process.nextTick 的回调在 Node.js 的 nextTickQueue 中,它在每个 C/C++ → JS 边界转换时被 drain——在 microtask 之前。浏览器中无对应机制。这常用于 Node.js 的 error-first callback 模式中确保异步错误处理。


事件循环与 async/await 的时序陷阱

陷阱 1:await 与循环

js
// ❌ 串行执行(每个 await 等待上一个 Promise resolve)
async function sequential(urls) {
  for (const url of urls) {
    const data = await fetch(url) // 一个接一个
    results.push(data)
  }
}

// ✅ 并发执行(Promise.all)
async function concurrent(urls) {
  const promises = urls.map(url => fetch(url))
  const results = await Promise.all(promises)
}

for...of + await 是串行的——每次循环迭代的 await 等待上一个 resolve。用 map + Promise.all 将所有 Promise 并发启动,再统一等待结果。

陷阱 2:async 函数内部 await 前的同步代码

js
async function demo() {
  console.log(1)         // 同步
  await Promise.resolve()
  console.log(2)         // 微任务
}
demo()
console.log(3)
// 输出:1 → 3 → 2

await 之前的代码同步执行(1),await 之后的代码作为微任务入队(2),在同步代码(3)之后执行。

陷阱 3:多个 await 产生的微任务链

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
// 不是 A → B → C → D

每次 await 产生一个 .then,后续 await.then 链在微任务队列中交替穿插。第二个 await 需要等 console.log('B') 执行完后才注册下一个 .then,此时 'D' 的微任务已在队列中。


生产环境中的调试

Chrome DevTools Performance 面板

  1. Record → 分析 Main Thread:灰色块 = Task,Task 内部嵌套的块标有 Run Microtasks 的就是微任务执行段
  2. Call Tree 面板:搜索 FunctionCallPromise.then 调用链,确认微任务来源
  3. Bottom-Up 面板:按 Self Time 排序,找出耗时最长的微任务

诊断 microtask 饥饿

  • 症状:页面无响应、点击无反馈、但 CPU 使用率高
  • Performance 面板:Main Thread 一直显示 Task,Task 之间没有 Paint 事件
  • 火焰图:看到连续的 Run Microtasks 块,没有 Update Layer Tree / Paint / Composite

诊断长任务

Performance 面板中标记为红色的 Task(>50ms)即为长任务。长任务导致用户交互延迟、渲染卡顿。修复方向:将长任务拆分为 setTimeoutrequestIdleCallback 调度的小任务块。


requestAnimationFrame 的正确使用

js
// ✅ rAF 用于视觉更新,时序精确到帧
function animate() {
  updatePosition()
  requestAnimationFrame(animate) // 每帧执行一次
}

// ❌ setTimeout 用于动画 — 频率不可控
setInterval(updatePosition, 16) // 可能丢帧或重复帧

rAF 在渲染前执行,浏览器可将其与布局/绘制合并到同一帧内,避免 layout thrashing。setInterval 与 vsync 不同步,回调可能在帧中间执行导致额外 layout。

rAF + microtask 交互

js
requestAnimationFrame(() => {
  console.log('rAF')
  Promise.resolve().then(() => console.log('microtask in rAF'))
})

rAF 回调内的 Promise.then 微任务在 rAF 回调执行完毕后、实际 layout 之前被清空——因为 microtask 清空发生在每个 JS 执行上下文退出时,rAF 回调退出触发 microtask checkpoint。