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() |
| 事件回调 | 用户点击、键盘输入、网络事件 |
| 定时器 | setTimeout、setInterval |
| 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 start → script end → promise1 → promise2 → setTimeout。原因是整个脚本本身是一个任务,执行完毕后,事件循环首先处理微任务队列中的两个 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 的实现还展示了微任务队列的核心操作流程:
- 当
await一个值时,引擎首先将值包装成 Promise(如果还不是的话) - 创建一个内部的"throwaway" Promise 用于恢复执行
- 将恢复逻辑附加到这个 Promise 上
- 暂停 async 函数,返回 implicit Promise 给调用者
- 当 Promise resolve 时,微任务被调度,async 函数恢复执行
这个流程解释了为什么 async/await 本质上是 Promise 的语法糖——它依赖相同的微任务机制。
渲染时机:微任务与视觉更新的关系
浏览器的渲染是理解事件循环的关键一环。根据 HTML 规范,事件循环在每个迭代中可能会执行"Update the rendering"步骤。这个步骤发生在:
- 从任务队列取出一个任务并执行
- 执行所有微任务
- 如果满足条件,进行渲染
渲染的时机意味着:如果在微任务中修改 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 慢?答案已经清晰:
setTimeout的回调是一个任务,被放入任务队列- Promise 的回调是一个微任务,被放入微任务队列
- 事件循环的规则是:执行一个任务,然后排空微任务队列
- 因此,微任务总是在下一个任务之前执行
这个差异不是性能优化的细节,而是 JavaScript 异步模型的根本设计。理解它,才能写出行为可预测、性能可优化的代码。
从 Jake Archibald 2015 年的经典文章,到 V8 团队的持续优化,再到 HTML 规范的明确定义,事件循环模型经历了十多年的演进。今天,我们终于有了一个跨浏览器一致、规范清晰、性能优化的实现。但这个复杂性的存在本身,提醒着我们:单线程异步编程从来都不是一件简单的事。
参考文献
- HTML Living Standard - Event Loops. whatwg.org
- ECMAScript Specification - Jobs and Job Queues. ecma-international.org
- Jake Archibald. Tasks, microtasks, queues and schedules. jakearchibald.com, 2015
- V8 Blog. Faster async functions and promises. v8.dev, 2018
- MDN Web Docs. In depth: Microtasks and the JavaScript runtime environment. developer.mozilla.org
- Node.js Documentation. The Node.js Event Loop. nodejs.org
- Philip Roberts. What the heck is the event loop anyway? JSConf EU, 2014
- web.dev. Optimize long tasks. web.dev, 2024
- libuv Documentation. Design overview. docs.libuv.org
- Chrome DevTools. Performance features reference. developer.chrome.com