Node.js 事件循环与异步编程完全指南
什么是事件循环?
Node.js 是基于 Chrome V8 引擎的 JavaScript 运行时,天生采用单线程 + 事件循环的架构。事件循环是 Node.js 能够用少量线程处理大量并发请求的核心所在。
与传统多线程服务器不同,Node.js 在单线程中通过事件循环不断从任务队列中取出回调执行,从而实现非阻塞 I/O 操作。
事件循环的执行顺序
Node.js 事件循环分为多个阶段(phase),每个阶段都有自己独立的 FIFO 回调队列:
1. timers — 执行 setTimeout() 和 setInterval() 的回调
2. pending callbacks — 执行上一轮延迟的 I/O 回调
3. idle, prepare — 内部使用
4. poll — 获取新的 I/O 事件,执行 I/O 相关的回调(除 close、timers、setImmediate 外的几乎所有回调)
5. check — 执行 setImmediate() 的回调
6. close callbacks — 执行 close 事件的回调,例如 socket.on('close', ...)
宏任务与微任务
JavaScript 中的任务分为宏任务(MacroTask)和微任务(MicroTask):
- <strong>宏任务</strong>:整体代码块、<code>setTimeout</code>、<code>setInterval</code>、<code>setImmediate</code>、<code>I/O</code>、<code>UI rendering</code>
- <strong>微任务</strong>:<code>Promise.then()</code>、<code>Promise.catch()</code>、<code>process.nextTick()</code>、<code>queueMicrotask()</code>
执行顺序规律:同步代码 → 微任务 → (一个宏任务完成后,再次检查微任务)→ 下一个宏任务
其中 process.nextTick() 优先级高于 Promise.then()。
代码示例:手写理解事件循环
// event-loop-demo.js
const fs = require('fs');
const { promisify } = require('util');
const readFile = promisify(fs.readFile);
// async/await 函数
async function fetchData() {
console.log('[1] async 函数开始');
// 微任务:当前宏任务结束后立即执行
Promise.resolve().then(() => {
console.log('[2] Promise.then() 微任务');
});
// process.nextTick 比 Promise.then 优先级更高
process.nextTick(() => {
console.log('[3] process.nextTick() 微任务(最高优先级)');
});
// 同步代码
console.log('[4] 同步代码执行');
// 等待一个微任务
await Promise.resolve();
console.log('[7] await 之后的代码(属于下一个微任务)');
return 'done';
}
// setImmediate vs setTimeout 演示
setImmediate(() => {
console.log('[5] setImmediate (check 阶段)');
});
setTimeout(() => {
console.log('[6] setTimeout (timers 阶段,延迟 0ms)');
}, 0);
console.log('[0] 主程序同步代码开始');
fetchData().then(() => {
console.log('[8] async 函数 Promise resolved');
});
// 最终输出顺序:
// [0] 主程序同步代码开始
// [1] async 函数开始
// [4] 同步代码执行
// [3] process.nextTick() 微任务(最高优先级)
// [2] Promise.then() 微任务
// [7] await 之后的代码
// [6] setTimeout (timers 阶段)
// [5] setImmediate (check 阶段)
// [8] async 函数 Promise resolved
async/await 进阶用法
// async-await-advanced.js
// 并发执行多个请求(Promise.all)
async function fetchAll() {
const [users, posts] = await Promise.all([
fetch('https://jsonplaceholder.typicode.com/users').then(r => r.json()),
fetch('https://jsonplaceholder.typicode.com/posts').then(r => r.json()),
]);
console.log(`获取到 ${users.length} 个用户,${posts.length} 篇文章`);
return { users, posts };
}
// 错误处理
async function safeFetch(url) {
try {
const res = await fetch(url);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return await res.json();
} catch (err) {
console.error('请求失败:', err.message);
return null;
}
}
// 顺序执行 vs 并发执行对比
async function sequential() {
// 串行:每个请求等待前一个完成,总耗时 = sum(各请求耗时)
const r1 = await fetch('/api/a').then(r => r.json());
const r2 = await fetch('/api/b').then(r => r.json());
const r3 = await fetch('/api/c').then(r => r.json());
return [r1, r2, r3];
}
async function parallel() {
// 并发:所有请求同时发送,总耗时 = max(各请求耗时)
const [r1, r2, r3] = await Promise.all([
fetch('/api/a').then(r => r.json()),
fetch('/api/b').then(r => r.json()),
fetch('/api/c').then(r => r.json()),
]);
return [r1, r2, r3];
}
常见问题
Q1: 为什么 setTimeout(fn, 0) 不一定在 setImmediate 之前执行?
在 Node.js 中,当 I/O 循环内调用时,setImmediate 的回调总是在 poll 阶段的 setTimeout 回调之前执行,因为 setImmediate 本身就是 check 阶段。但从主模块直接运行时,两者的顺序不确定,取决于进程启动时性能的影响。
Q2: <code>process.nextTick()</code> 和 <code>queueMicrotask()</code> 有什么区别?
两者都是微任务,但 process.nextTick() 属于 Node.js 特有的 API,会被放入一个独立的队列(nextTickQueue),在每次切换到下个阶段前清空;而 queueMicrotask() 放入微任务队列(microTaskQueue),受 V8 引擎管理。process.nextTick() 优先级更高,但可能导致微任务堆积,建议仅在必要时使用。
Q3: async/await 和 Promise.then() 哪个性能更好?
两者本质相同,性能差异可以忽略。async/await 是 Promise 的语法糖,优势在于代码可读性更高、调试更方便(可以在断点处逐步执行)。在高并发场景下,避免在循环中大量使用 await(导致串行化),改用 Promise.all() 批量并发处理。
延伸阅读
- <a href="https://nodejs.org/zh-cn/docs/guides/event-loop-timers-and-nexttick/">Node.js 官方文档 - 事件循环</a>
- <a href="https://nodejs.org/zh-cn/docs/guides/asynchronous-programming/">Node.js 异步编程的正确姿势</a>
- 《深入浅出 Node.js》— 朴灵著