Node.js 事件循环机制详解
1. 问题解析
事件循环(Event Loop)是 Node.js 处理非阻塞 I/O 操作的核心机制。与浏览器的事件循环基于 HTML5 规范不同,Node.js 的事件循环基于 libuv 库实现,具有独特的阶段划分和执行顺序。深入理解事件循环对于编写高性能、避免竞态条件的 Node.js 代码至关重要。
2. 核心概念
2.1 libuv 简介
libuv 是一个跨平台的异步 I/O 库,为 Node.js 提供:
- 事件循环
- 异步文件 I/O
- 异步 TCP/UDP socket
- 子进程管理
- 线程池(用于文件系统和 DNS 操作)
2.2 事件循环的 6 个阶段
┌───────────────────────────┐
┌─>│ timers │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ I/O callbacks │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ idle, prepare │
│ └─────────────┬─────────────┘ ┌───────────────┐
│ ┌─────────────┴─────────────┐ │ incoming: │
│ │ poll │<─────┤ connections, │
│ └─────────────┬─────────────┘ │ data, etc. │
│ ┌─────────────┴─────────────┐ └───────────────┘
│ │ check │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
└──┤ close callbacks │
└───────────────────────────┘
| 阶段 | 说明 |
|---|---|
| timers | 执行 setTimeout 和 setInterval 的回调 |
| I/O callbacks | 执行延迟到下一个循环迭代的 I/O 回调 |
| idle, prepare | 内部使用,开发者很少直接使用 |
| poll | 获取新的 I/O 事件,执行 I/O 回调 |
| check | 执行 setImmediate 的回调 |
| close callbacks | 执行 socket.on('close', ...) 等关闭回调 |
2.3 宏任务与微任务
- 宏任务(Macrotasks):timers、I/O callbacks、check、close callbacks
- 微任务(Microtasks):
process.nextTick(严格来说不属于事件循环,优先级最高)Promise.then/catch/finallyqueueMicrotask
3. 详细解答
3.1 各阶段详解
// timers 阶段
setTimeout(() => {
console.log('setTimeout');
}, 0);
// I/O callbacks 阶段
const fs = require('fs');
fs.readFile(__filename, () => {
console.log('I/O callback');
});
// check 阶段
setImmediate(() => {
console.log('setImmediate');
});
// 同步代码
console.log('sync');
// 输出顺序:
// sync
// setTimeout(timers 阶段)
// setImmediate(check 阶段)
// I/O callback(poll 阶段后如果有 I/O)
3.2 process.nextTick
// process.nextTick 不属于任何阶段,在当前操作完成后立即执行
console.log('1');
process.nextTick(() => {
console.log('2');
});
Promise.resolve().then(() => {
console.log('3');
});
setTimeout(() => {
console.log('4');
}, 0);
console.log('5');
// 输出:1, 5, 2, 3, 4
// 解释:
// 1. 同步代码:1, 5
// 2. nextTick 队列:2
// 3. 微任务队列:3
// 4. 事件循环 timers 阶段:4
3.3 nextTick 的递归风险
// 危险:递归 nextTick 会阻塞事件循环
function recursiveNextTick() {
process.nextTick(() => {
console.log('tick');
recursiveNextTick(); // 递归调用
});
}
// 这会完全阻塞事件循环,timers 和 I/O 永远不会执行
// recursiveNextTick();
// 安全的做法:使用 setImmediate
function safeRecursive() {
setImmediate(() => {
console.log('immediate');
safeRecursive();
});
}
// safeRecursive(); // 允许事件循环继续
3.4 setTimeout vs setImmediate
// 在主模块中,执行顺序不确定
setTimeout(() => {
console.log('timeout');
}, 0);
setImmediate(() => {
console.log('immediate');
});
// 可能输出 timeout -> immediate,也可能相反
// 原因:取决于进程启动到进入事件循环的时间
// 在 I/O 回调中,setImmediate 总是先于 setTimeout
const fs = require('fs');
fs.readFile(__filename, () => {
setTimeout(() => {
console.log('timeout');
}, 0);
setImmediate(() => {
console.log('immediate');
});
});
// 总是输出:immediate -> timeout
// 原因:I/O 回调在 poll 阶段执行,
// check 阶段(setImmediate)在 timers 阶段之前
3.5 Promise 微任务执行时机
// Promise 微任务在 nextTick 之后,每个阶段之前执行
console.log('script start');
setTimeout(() => {
console.log('setTimeout');
}, 0);
Promise.resolve().then(() => {
console.log('promise1');
}).then(() => {
console.log('promise2');
});
process.nextTick(() => {
console.log('nextTick');
});
console.log('script end');
// 输出:
// script start
// script end
// nextTick
// promise1
// promise2
// setTimeout
4. 深入理解
4.1 事件循环的完整执行流程
// 综合示例展示完整的事件循环流程
const fs = require('fs');
console.log('1. 同步代码开始');
// Timers 阶段
setTimeout(() => {
console.log('2. setTimeout 0ms');
}, 0);
setTimeout(() => {
console.log('3. setTimeout 100ms');
}, 100);
// Check 阶段
setImmediate(() => {
console.log('4. setImmediate');
});
// I/O 操作
fs.readFile(__filename, () => {
console.log('5. I/O callback (poll 阶段)');
// I/O 回调中的定时器
setTimeout(() => {
console.log('6. I/O 中的 setTimeout 0ms');
}, 0);
setImmediate(() => {
console.log('7. I/O 中的 setImmediate');
});
process.nextTick(() => {
console.log('8. I/O 中的 nextTick');
});
});
// nextTick
process.nextTick(() => {
console.log('9. nextTick 1');
process.nextTick(() => {
console.log('10. nextTick 2');
});
});
// Promise
Promise.resolve().then(() => {
console.log('11. Promise 1');
Promise.resolve().then(() => {
console.log('12. Promise 2');
});
});
console.log('13. 同步代码结束');
// 典型输出顺序:
// 1. 同步代码开始
// 13. 同步代码结束
// 9. nextTick 1
// 10. nextTick 2
// 11. Promise 1
// 12. Promise 2
// 2. setTimeout 0ms
// 4. setImmediate
// 5. I/O callback (poll 阶段)
// 8. I/O 中的 nextTick
// 7. I/O 中的 setImmediate
// 6. I/O 中的 setTimeout 0ms
// 3. setTimeout 100ms
4.2 线程池与事件循环
// 某些操作使用 libuv 线程池,完成后回调进入事件循环
const crypto = require('crypto');
console.time('pbkdf2');
// pbkdf2 使用线程池
for (let i = 0; i < 4; i++) {
crypto.pbkdf2('password', 'salt', 100000, 64, 'sha512', () => {
console.log(`pbkdf2 ${i} done`);
});
}
console.timeEnd('pbkdf2'); // 几乎立即打印
// 4 个 pbkdf2 操作并行在 libuv 线程池执行
// 完成后回调进入 poll 阶段
4.3 事件循环的 uv_run 模式
// Node.js 使用 UV_RUN_ONCE 模式运行事件循环
// 这意味着每次事件循环迭代后,Node.js 会检查是否还有工作要做
const http = require('http');
const server = http.createServer((req, res) => {
res.end('Hello');
});
server.listen(3000);
// 只要还有活跃的 handles(如服务器监听)或 requests,
// 事件循环就会继续运行
5. 最佳实践
5.1 合理使用 nextTick
// 场景 1:确保在事件触发前执行
const EventEmitter = require('events');
class MyEmitter extends EventEmitter {
constructor() {
super();
// 在构造函数中触发事件
// 但此时子类还未完成初始化
// 使用 nextTick 延迟到当前操作完成后
process.nextTick(() => {
this.emit('ready');
});
}
}
const emitter = new MyEmitter();
emitter.on('ready', () => {
console.log('Ready!');
});
// 场景 2:错误处理时清理资源
function processData(data, callback) {
let resource;
try {
resource = acquireResource();
const result = doSomething(data);
// 使用 nextTick 确保回调异步执行
process.nextTick(() => {
callback(null, result);
});
} catch (err) {
// 同样异步执行错误回调
process.nextTick(() => {
if (resource) releaseResource(resource);
callback(err);
});
}
}
5.2 避免阻塞事件循环
// 不好的做法:同步计算阻塞事件循环
app.get('/slow', (req, res) => {
let sum = 0;
for (let i = 0; i < 1e10; i++) {
sum += i;
}
res.json({ sum });
});
// 好的做法 1:使用 setImmediate 分片执行
function calculateSum(n, callback) {
let sum = 0;
let i = 0;
function chunk() {
const end = Math.min(i + 1e6, n);
for (; i < end; i++) {
sum += i;
}
if (i < n) {
setImmediate(chunk); // 让出事件循环
} else {
callback(sum);
}
}
chunk();
}
// 好的做法 2:使用 worker_threads
const { Worker } = require('worker_threads');
function calculateInWorker(n) {
return new Promise((resolve, reject) => {
const worker = new Worker('./sum-worker.js', {
workerData: n
});
worker.on('message', resolve);
worker.on('error', reject);
});
}
5.3 正确处理异步初始化
// 数据库连接等异步初始化
class Database {
constructor() {
this.connected = false;
this.queue = [];
}
async connect() {
// 模拟连接
await new Promise(resolve => setTimeout(resolve, 100));
this.connected = true;
// 处理队列中的请求
this.queue.forEach(({ sql, resolve }) => {
this.execute(sql).then(resolve);
});
this.queue = [];
}
query(sql) {
if (!this.connected) {
// 未连接时加入队列
return new Promise(resolve => {
this.queue.push({ sql, resolve });
});
}
return this.execute(sql);
}
async execute(sql) {
// 执行查询
return { result: 'data' };
}
}
// 使用
const db = new Database();
db.connect();
// 立即查询会被排队,连接成功后执行
db.query('SELECT * FROM users').then(console.log);
6. 面试要点
-
Node.js 与浏览器事件循环的区别
- Node.js 基于 libuv,有 6 个明确的阶段
- 浏览器基于 HTML5 规范,主要有 task 和 microtask
- Node.js 有
process.nextTick和setImmediate,浏览器没有 - Node.js 的宏任务分阶段执行,浏览器按任务队列顺序
-
setTimeout vs setImmediate 的执行顺序
- 在主模块中:不确定,取决于系统性能
- 在 I/O 回调中:
setImmediate先于setTimeout(0) - 原因:I/O 回调在 poll 阶段执行,check 阶段在 timers 阶段之前
-
process.nextTick 的特殊性
- 不属于事件循环的任何阶段
- 优先级高于 Promise 微任务
- 在当前操作完成后立即执行
- 递归使用会阻塞事件循环
-
微任务的执行时机
process.nextTick:当前操作后,进入事件循环前Promise.then:每个事件循环阶段结束后- 执行顺序:nextTick 队列 -> Promise 微任务队列
-
poll 阶段的特性
- 如果 poll 队列不为空,遍历执行回调
- 如果 poll 队列为空:
- 有 setImmediate:进入 check 阶段
- 无 setImmediate:等待新的 I/O 事件
- timers 的阈值检查也在 poll 阶段进行
-
实际应用注意事项
- 避免在 nextTick 中递归调用
- CPU 密集型任务使用 worker_threads 或分片执行
- I/O 回调中优先使用 setImmediate 而非 setTimeout(0)
- 理解事件循环有助于调试异步代码的执行顺序问题