2014年,Philip Roberts 在 JSConf EU 上做了一场名为"What the heck is the event loop anyway?“的演讲。这个演讲后来在 YouTube 上突破了百万播放量,成为理解 JavaScript 异步机制的入门必修课。然而,演讲中并未触及一个更深层的问题:当 setTimeout(fn, 0)Promise.resolve().then(fn) 同时存在时,谁先执行?

答案远比"微任务优先于宏任务"复杂。这个看似简单的问题,牵涉到 HTML 规范的任务调度模型、ECMAScript 的 Job Queue 机制、V8 引擎的实现细节,以及 Node.js 与浏览器之间的根本差异。

单线程的困境与事件循环的诞生

JavaScript 从设计之初就是单线程语言。这个决策源于 1995 年 Brendan Eich 在 Netscape 实现脚本语言时面临的约束:浏览器中的脚本不应该有能力冻结整个用户界面。如果允许并发,一个死循环脚本就能让浏览器完全无响应。

单线程意味着同一时刻只能执行一段代码。但浏览器需要处理大量异步操作:网络请求、用户输入、定时器、动画帧。如果每个操作都要等待完成才能继续,整个页面就会卡死。事件循环(Event Loop)正是为解决这个问题而设计的调度机制。

事件的"循环"本质是一个不断重复的过程:从任务队列中取出一个任务执行,执行完毕后检查微任务队列,处理完所有微任务后进行渲染,然后回到任务队列取下一个任务。这个模型看似简单,但其中的"任务"和"微任务"划分,却隐藏着复杂的权衡考量。

任务与微任务:两种不同的异步语义

在 HTML 规范中,“任务”(Task)被定义为浏览器要执行的离散工作单元。任务的来源包括:

任务来源 示例
脚本执行 <script> 标签、eval()
事件回调 用户点击、键盘输入、网络事件
定时器 setTimeoutsetInterval
DOM 操作 requestAnimationFrame

每个任务在执行时,会独占主线程直到完成。任务之间按顺序执行,浏览器可以在任务之间进行渲染更新。

微任务(Microtask)则是另一种性质的工作单元。根据 MDN 文档的定义,微任务应该"在当前任务完成后立即执行,只要 JavaScript 执行栈为空”。微任务的设计目标是让某些操作能够在"尽可能早的时机"执行,但又不会阻塞当前正在运行的代码。

微任务的主要来源包括:

  • Promise.then()Promise.catch()Promise.finally() 的回调
  • MutationObserver 的回调
  • queueMicrotask() 显式调度的任务

关键的区别在于执行时机。每次事件循环迭代中,任务队列只会取出一个任务执行,而微任务队列则会被"排空"——执行完队列中所有的微任务,包括在执行过程中新添加的微任务。

console.log('script start');

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

Promise.resolve()
  .then(() => console.log('promise1'))
  .then(() => console.log('promise2'));

console.log('script end');

这段代码的输出顺序是:script startscript endpromise1promise2setTimeout。原因是整个脚本本身是一个任务,执行完毕后,事件循环首先处理微任务队列中的两个 Promise 回调,然后才进入下一个任务——setTimeout 的回调。

为什么 Promise 被设计为微任务?

一个有趣的问题是:为什么 Promise 的回调要走微任务队列,而不是像 setTimeout 一样走任务队列?

这涉及到 Promise 设计的核心原则。根据 ECMAScript 规范,Promise 的回调必须是异步执行的,即使 Promise 已经 resolve。这个设计确保了代码行为的一致性:

const p = Promise.resolve(42);

p.then(value => {
  // 这个回调一定是异步执行的
  // 即使 Promise 已经 resolve
  console.log(value);
});

console.log('after then');
// 输出顺序:after then → 42

如果 Promise 回调是同步执行的,那么在 then 调用时,回调就会立即执行,打乱代码的预期执行顺序。这会带来严重的可维护性问题。

但为什么选择微任务而不是普通任务?Jake Archibald 在他的经典文章《Tasks, microtasks, queues and schedules》中解释了原因:

将 Promise 作为微任务执行有几个优势。首先是性能——如果在任务之间执行,可以避免被渲染等操作延迟。其次是一致性——微任务在任务完成后立即执行,可以保证状态的一致性。

更重要的是,微任务机制允许对一系列操作进行批量响应。考虑 IndexedDB 事务的场景:当事务完成时,相关的回调需要在事务仍然活跃时执行,否则会抛出异常。微任务机制确保了回调能够在正确的时机执行。

V8 引擎的实现印证了这一点。在 V8 的实现中,Promise 的 then 回调被放入内部的 microtask queue。当调用栈清空时,V8 会检查并执行 microtask queue 中的所有任务。

V8 引擎中的微任务实现

2018年,V8 团队发表了一篇题为《Faster async functions and promises》的博客文章,详细介绍了 async/await 和 Promise 的优化历程。文章揭示了一个惊人的事实:在最初的实现中,每个 await 表达式需要创建两个额外的 Promise,并且至少需要三个微任务 tick 才能完成。

async function foo(v) {
  const w = await v;
  return w;
}

V8 团队发现,即使 v 已经是一个 Promise,引擎仍然会创建一个包装 Promise。这个设计虽然符合规范,但带来了不必要的开销。

优化后的实现引入了 promiseResolve 操作,对于已经是 Promise 的值直接返回,避免了额外的包装。这个优化将微任务 tick 数从三个减少到一个。更重要的是,这个优化被提交到了 ECMAScript 规范中,成为了标准的一部分。

V8 的实现还展示了微任务队列的核心操作流程:

  1. await 一个值时,引擎首先将值包装成 Promise(如果还不是的话)
  2. 创建一个内部的"throwaway" Promise 用于恢复执行
  3. 将恢复逻辑附加到这个 Promise 上
  4. 暂停 async 函数,返回 implicit Promise 给调用者
  5. 当 Promise resolve 时,微任务被调度,async 函数恢复执行

这个流程解释了为什么 async/await 本质上是 Promise 的语法糖——它依赖相同的微任务机制。

渲染时机:微任务与视觉更新的关系

浏览器的渲染是理解事件循环的关键一环。根据 HTML 规范,事件循环在每个迭代中可能会执行"Update the rendering"步骤。这个步骤发生在:

  1. 从任务队列取出一个任务并执行
  2. 执行所有微任务
  3. 如果满足条件,进行渲染

渲染的时机意味着:如果在微任务中修改 DOM,用户会在当前帧看到更新;如果在 setTimeout 回调中修改,用户要等到下一帧。

// 修改 DOM 后立即读取布局属性
element.style.width = '100px';
const height = element.offsetHeight; // 强制同步布局

// 在微任务中操作
Promise.resolve().then(() => {
  element.style.width = '200px';
  // 这个修改会在当前帧渲染
});

// 在定时器中操作
setTimeout(() => {
  element.style.width = '300px';
  // 这个修改会在下一帧渲染
}, 0);

requestAnimationFrame 的回调在渲染前执行,属于另一类特殊的任务。它允许开发者在下一次重绘之前更新动画,避免布局抖动。

理解渲染时机对于性能优化至关重要。Chrome 将超过 50ms 的任务标记为"长任务",因为它们会阻塞渲染,导致用户感知到的延迟。将长任务拆分成多个小任务,让浏览器有机会在任务之间进行渲染,是提升响应性的关键。

Node.js 事件循环:不同的模型

Node.js 的事件循环与浏览器有根本性差异。浏览器的事件循环由 HTML 规范定义,而 Node.js 使用 libuv 库实现,具有明确的阶段划分:

   ┌───────────────────────────┐
┌─>│           timers          │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │     pending callbacks     │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │       idle, prepare       │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │           poll            │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │           check           │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
└──┤      close callbacks      │
   └───────────────────────────┘

每个阶段有自己的队列,处理特定类型的回调。setTimeout 在 timers 阶段执行,setImmediate 在 check 阶段执行,I/O 回调在 poll 阶段执行。

这导致了一个著名的"不确定性"问题:在主模块中,setTimeout(fn, 0)setImmediate(fn) 的执行顺序是不确定的。但在 I/O 回调内部,setImmediate 总是先于 setTimeout 执行。

// 主模块中 - 执行顺序不确定
setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));
// 可能输出 timeout → immediate 或 immediate → timeout

// I/O 回调中 - setImmediate 总是先执行
const fs = require('fs');
fs.readFile(__filename, () => {
  setTimeout(() => console.log('timeout'), 0);
  setImmediate(() => console.log('immediate'));
  // 一定输出 immediate → timeout
});

Node.js 还有一个特殊的 process.nextTick(),它不属于事件循环的任何阶段,而是在每个阶段之间、微任务队列之前执行。这赋予了它比 Promise 更高的优先级,也带来了"饿死"事件循环的风险。

// process.nextTick 饥饿示例
function recursiveNextTick() {
  process.nextTick(recursiveNextTick);
}
recursiveNextTick();
// 事件循环永远无法进入下一个阶段

微任务饥饿:隐蔽的性能陷阱

微任务的高优先级是一把双刃剑。如果微任务不断产生新的微任务,事件循环就会被"饿死"——永远无法处理下一个任务。

function infiniteMicrotask() {
  Promise.resolve().then(() => {
    console.log('microtask');
    infiniteMicrotask(); // 递归创建新的微任务
  });
}

infiniteMicrotask();
// setTimeout 永远不会执行
setTimeout(() => console.log('timeout'), 0);

这个问题在实践中并不罕见。框架中的批量更新、数据同步逻辑,如果设计不当,都可能触发微任务饥饿。

解决方法是在适当的时候让出主线程:

async function processBatch(items) {
  for (const item of items) {
    processItem(item);
    
    // 每 50ms 让出主线程
    if (performance.now() - lastYield > 50) {
      await new Promise(resolve => setTimeout(resolve, 0));
      lastYield = performance.now();
    }
  }
}

现代浏览器提供了 scheduler.yield() API,专门用于让出主线程并保持执行顺序的连续性。这个 API 在微任务和任务之间提供了一个平衡点:既能响应高优先级任务,又不会完全打乱执行顺序。

requestAnimationFrame:与渲染同步的任务

requestAnimationFrame(rAF)是一个特殊的 API,它的回调在"Update the rendering"步骤之前执行,专门用于动画场景。

rAF 回调的执行时机决定了它的最佳使用模式:

// 正确:在 rAF 中读取布局,在下一个 rAF 中写入
function animate() {
  const scrollTop = element.scrollTop; // 读取
  
  requestAnimationFrame(() => {
    element.style.transform = `translateY(${scrollTop}px)`; // 写入
    animate();
  });
}

// 错误:在同一帧中读写,导致布局抖动
function animateWrong() {
  element.scrollTop; // 强制布局
  element.style.transform = 'translateY(100px)'; // 写入
  requestAnimationFrame(animateWrong);
}

rAF 回调的执行顺序是另一个值得注意的细节。多个 rAF 回调按注册顺序执行,形成一个独立的任务队列。在同一个 rAF 回调中触发的微任务,会在下一个 rAF 回调之前执行完。

调试与性能分析

Chrome DevTools 的 Performance 面板提供了分析事件循环行为的强大工具。长任务会被标记为红色三角形,其阻塞部分(超过 50ms 的部分)用红色斜线填充。

分析事件循环性能的几个关键指标:

指标 含义 优化方向
Long Tasks 超过 50ms 的任务 拆分为小任务
Total Blocking Time 长任务阻塞时间总和 减少主线程占用
Event Loop Lag Node.js 事件循环延迟 检查 CPU 密集操作
INP 交互响应延迟 分散处理逻辑

Node.js 环境下监控事件循环延迟:

const { performance, monitorEventLoopDelay } = require('perf_hooks');

const h = monitorEventLoopDelay({
  resolution: 10 // 统计精度(毫秒)
});
h.enable();

// 定期检查
setInterval(() => {
  console.log('Event loop lag:', h.mean / 1e6, 'ms');
  h.reset();
}, 1000);

实践指南:选择正确的异步机制

理解事件循环后,选择正确的异步机制变得清晰:

使用微任务的场景:

  • 需要在当前任务完成后立即执行
  • 需要保持状态一致性
  • 批量处理 DOM 变化

使用 setTimeout(fn, 0) 的场景:

  • 需要让出主线程,允许渲染和用户交互
  • 防止长任务阻塞页面
  • 延迟非关键工作

使用 requestAnimationFrame 的场景:

  • 动画更新
  • 需要与渲染同步的操作
  • 布局计算

使用 requestIdleCallback 的场景:

  • 低优先级后台任务
  • 分析和埋点
  • 预加载非关键资源
// 综合示例:优先级分层的任务处理
function processPriorityQueue() {
  // 最高优先级:响应用户输入
  processUserInput();
  
  // 高优先级:使用微任务更新状态
  Promise.resolve().then(updateApplicationState);
  
  // 中优先级:使用 rAF 更新 UI
  requestAnimationFrame(updateUI);
  
  // 低优先级:使用 idle callback 发送分析
  requestIdleCallback(sendAnalytics);
  
  // 最低优先级:使用 setTimeout 预加载
  setTimeout(preloadNonCriticalResources, 0);
}

回到最初的问题

setTimeout(fn, 0) 为什么总是比 Promise 慢?答案已经清晰:

  1. setTimeout 的回调是一个任务,被放入任务队列
  2. Promise 的回调是一个微任务,被放入微任务队列
  3. 事件循环的规则是:执行一个任务,然后排空微任务队列
  4. 因此,微任务总是在下一个任务之前执行

这个差异不是性能优化的细节,而是 JavaScript 异步模型的根本设计。理解它,才能写出行为可预测、性能可优化的代码。

从 Jake Archibald 2015 年的经典文章,到 V8 团队的持续优化,再到 HTML 规范的明确定义,事件循环模型经历了十多年的演进。今天,我们终于有了一个跨浏览器一致、规范清晰、性能优化的实现。但这个复杂性的存在本身,提醒着我们:单线程异步编程从来都不是一件简单的事。


参考文献

  1. HTML Living Standard - Event Loops. whatwg.org
  2. ECMAScript Specification - Jobs and Job Queues. ecma-international.org
  3. Jake Archibald. Tasks, microtasks, queues and schedules. jakearchibald.com, 2015
  4. V8 Blog. Faster async functions and promises. v8.dev, 2018
  5. MDN Web Docs. In depth: Microtasks and the JavaScript runtime environment. developer.mozilla.org
  6. Node.js Documentation. The Node.js Event Loop. nodejs.org
  7. Philip Roberts. What the heck is the event loop anyway? JSConf EU, 2014
  8. web.dev. Optimize long tasks. web.dev, 2024
  9. libuv Documentation. Design overview. docs.libuv.org
  10. Chrome DevTools. Performance features reference. developer.chrome.com