事件循环
What — 是什么
事件循环(Event Loop)是 Node.js 实现异步非阻塞 I/O 的核心机制,在单线程中通过轮询事件队列来调度任务执行。
核心概念:
- 调用栈(Call Stack):执行同步代码的 LIFO 栈
- 微任务队列(Microtask Queue):
Promise.then、process.nextTick等,每个阶段后立即清空 - 宏任务队列(Macrotask Queue):
setTimeout、setImmediate、I/O 回调等,按阶段处理 - 6 个阶段:timers → pending callbacks → idle/prepare → poll → check → close callbacks
关键特性:
- 单线程执行,避免锁和竞争
- 非阻塞 I/O:I/O 操作委托给 libuv 线程池
process.nextTick优先级高于Promise.then
运行机制:
- 内存模型:V8 堆内存 + libuv 事件循环
- 执行模型:同步代码 → 微任务 → 下一个宏任务阶段 → 微任务 → …
- 并发模型:单线程事件循环 + 线程池(4 线程默认,可配置 UV_THREADPOOL_SIZE)
Why — 为什么
适用场景:
- I/O 密集型服务(HTTP 服务器、数据库查询)
- 实时应用(WebSocket、聊天室)
- 流处理(文件、网络数据流)
对比其他语言:
| 维度 | Node.js 事件循环 | Go Goroutine | Java NIO |
|---|---|---|---|
| 性能 | 高(I/O 密集) | 极高 | 高 |
| 生态 | 极丰富(npm) | 丰富 | 成熟 |
| 上手难度 | 中(理解异步模型) | 低 | 高(Netty 复杂) |
| 并发能力 | 高(I/O 并发) | 极高 | 高 |
优缺点:
- ✅ 优点:
- 异步 I/O 天然适合高并发网络服务
- 避免多线程的锁和竞争问题
- 前后端统一语言(JavaScript)
- ❌ 缺点:
- CPU 密集任务会阻塞事件循环
- 回调地狱(可用 async/await 缓解)
- 错误栈不直观,调试异步链困难
How — 怎么用
快速上手
// 理解执行顺序
console.log('1 - 同步');
setTimeout(() => console.log('2 - 宏任务 setTimeout'), 0);
Promise.resolve().then(() => console.log('3 - 微任务 Promise'));
process.nextTick(() => console.log('4 - 微任务 nextTick'));
console.log('5 - 同步');
// 输出顺序:1 → 5 → 4 → 3 → 2
// nextTick 优先于 Promise
代码示例
避免阻塞事件循环:
// ❌ 危险:CPU 密集任务阻塞整个进程
function heavyCompute(n) {
let sum = 0;
for (let i = 0; i < n; i++) sum += Math.sqrt(i);
return sum;
}
// ✅ 修复:拆分为小块,用 setImmediate 让出控制权
function heavyComputeAsync(n, callback) {
let sum = 0;
let i = 0;
const chunk = 100000;
function computeChunk() {
const end = Math.min(i + chunk, n);
for (; i < end; i++) sum += Math.sqrt(i);
if (i < n) {
setImmediate(computeChunk); // 让出事件循环
} else {
callback(sum);
}
}
computeChunk();
}
正确处理异步错误:
// ❌ 无法捕获异步回调中的错误
try {
setTimeout(() => { throw new Error('missed'); }, 0);
} catch (e) {
// 永远不会到这里
}
// ✅ 使用 async/await + Promise
async function safeOperation() {
try {
await riskyAsyncTask();
} catch (e) {
console.error('caught:', e.message);
}
}
常见问题与踩坑
| 问题 | 原因 | 解决方案 |
|---|---|---|
| 事件循环阻塞 | CPU 密集任务占用主线程 | 拆分任务 / 使用 Worker Threads / Offload 到子进程 |
| 微任务饥饿 | 大量 nextTick 递归,宏任务无法执行 | 避免递归 nextTick,改用 setImmediate |
setTimeout(fn, 0) 不是立即执行 | 受最小延迟限制(1ms 嵌套后 4ms) | 用 setImmediate 替代(check 阶段) |
| 未处理的 Promise 拒绝 | 没有 .catch 或 try/catch | 监听 process.on('unhandledRejection') |
最佳实践
- CPU 密集任务使用
worker_threads或子进程 - 始终处理 Promise 的 rejection
- 用
async/await替代回调链,代码更清晰 - 生产环境监控事件循环延迟(
perf_hooks.monitorEventLoopDelay)
面试题
Q1: Node.js 事件循环的 6 个阶段分别是什么?各自处理什么任务?
6 个阶段依次为:timers(执行 setTimeout/setInterval 回调)、pending callbacks(执行系统级回调如 TCP 错误)、idle/prepare(仅内部使用)、poll(获取新 I/O 事件,执行 I/O 回调)、check(执行 setImmediate 回调)、close callbacks(执行 socket.on(‘close’) 等)。每个阶段结束后都会清空微任务队列。
Q2: process.nextTick 和 Promise.then 都是微任务,执行顺序有什么区别?
process.nextTick 属于 nextTick 队列,Promise.then 属于微任务队列。每个阶段结束后先清空 nextTick 队列,再清空微任务队列,因此 nextTick 始终优先于 Promise.then 执行。应避免递归调用 nextTick,否则会造成微任务饥饿,阻塞宏任务。
Q3: setTimeout(fn, 0) 和 setImmediate(fn) 谁先执行?
在主模块(非 I/O 上下文)中,执行顺序不确定,取决于性能和系统调度。但在 I/O 回调(如 fs.readFile 的回调)内部,setImmediate 一定先于 setTimeout 执行,因为 I/O 回调在 poll 阶段处理完毕后立即进入 check 阶段。因此需要”在 I/O 之后优先执行”时应使用 setImmediate。
Q4: 什么是微任务饥饿?如何避免?
微任务饥饿是指大量微任务(尤其是 process.nextTick)不断递归入队,导致宏任务永远无法执行的现象。避免方法:不要递归调用 process.nextTick,改用 setImmediate 让出控制权;确保微任务数量可控,不在循环中无限制地产生微任务。
Q5: 为什么 CPU 密集型任务会阻塞事件循环?如何解决?
Node.js 主线程是单线程,事件循环在主线程上运行。CPU 密集型任务会占用主线程,导致事件循环无法继续轮询,所有 I/O 回调和定时器都被延迟。解决方案:使用 worker_threads 将计算移到工作线程;使用 child_process 拆分到子进程;或将大任务拆分为小片段,通过 setImmediate 分步执行让出主线程。
相关链接: