Node.js Event Loop
1. 架构概览
┌──────────────────────────────────────────────────────────────────────┐
│ Node.js 架构 │
├──────────────────────────────────────────────────────────────────────┤
│ JavaScript 应用代码 │
├──────────────────────────────────────────────────────────────────────┤
│ Node.js API (fs, http, etc.) │
├────────────────────────────┬─────────────────────────────────────────┤
│ V8 Engine │ Node.js Bindings │
│ (JavaScript 执行引擎) │ (C++ 绑定层) │
├────────────────────────────┴─────────────────────────────────────────┤
│ Libuv │
│ (跨平台异步 I/O 库,Event Loop 核心实现) │
├─────────────────┬────────────────────────────────┬───────────────────┤
│ Event Loop │ Thread Pool │ Async I/O │
│ (主线程) │ (UV_THREADPOOL_SIZE=4) │ (epoll/kqueue) │
└─────────────────┴────────────────────────────────┴───────────────────┘关键组件
| 组件 | 职责 |
|---|---|
| V8 | 解析执行 JavaScript,管理内存 (GC) |
| Libuv | 跨平台异步 I/O,线程池,Event Loop |
| Thread Pool | 处理阻塞操作 (DNS, fs, crypto 等) |
| Event Demultiplexer | 操作系统级别的 I/O 多路复用 (epoll/kqueue/IOCP) |
2. Event Loop 六阶段详解
┌───────────────────────────┐
┌─>│ Timers │ ─ setTimeout, setInterval
│ └───────────┬───────────────┘
│ │ <── nextTick/microTask 队列
│ ┌───────────▼───────────────┐
│ │ Pending Callbacks │ ─ 系统级回调 (TCP errors)
│ └───────────┬───────────────┘
│ │ <── nextTick/microTask 队列
│ ┌───────────▼───────────────┐
│ │ Idle, Prepare │ ─ 内部使用
│ └───────────┬───────────────┘
│ │ <── nextTick/microTask 队列
│ ┌───────────▼───────────────┐
│ │ Poll │ ─ I/O 回调 (网络、文件等)
│ └───────────┬───────────────┘ ↑
│ │ │ 如果队列为空:
│ │ │ - 有 setImmediate → 进入 Check
│ │ │ - 无 → 等待新事件/计时器
│ │ <── nextTick/microTask 队列
│ ┌───────────▼───────────────┐
│ │ Check │ ─ setImmediate
│ └───────────┬───────────────┘
│ │ <── nextTick/microTask 队列
│ ┌───────────▼───────────────┐
│ │ Close Callbacks │ ─ socket.on('close')
│ └───────────┬───────────────┘
│ │
└──────────────┘各阶段详细说明
| 阶段 | 执行内容 | 典型场景 |
|---|---|---|
| Timers | setTimeout/setInterval 到期回调 | 延时任务、定时任务 |
| Pending Callbacks | 延迟到下一轮的 I/O 回调 | TCP 连接错误回调 |
| Idle, Prepare | 内部使用 | - |
| Poll | I/O 回调 + 等待新事件 | 网络请求、文件读取 |
| Check | setImmediate 回调 | I/O 后立即执行 |
| Close | 关闭事件回调 | socket.destroy() |
IMPORTANT
Node.js 11+: 每执行一个宏任务后,立即清空微任务队列(与浏览器一致)。 Node.js 10-: 执行完当前阶段所有宏任务后,才清空微任务队列。
3. 特殊队列
- NextTickQueue:
process.nextTick()- 优先级最高,在当前阶段完成后、进入下一个阶段前立即执行。
- MicroTaskQueue:
Promise- 优先级次于 NextTick。
4. 浏览器 vs Node (旧版本差异)
- Node 11+: 行为趋向于浏览器。每执行一个 MacroTask (如 Timer),就清空一次 MicroTask 队列。
- Node 10-: 执行完当前阶段的所有 MacroTask (如所有过期的 Timers),才去清空 MicroTask 队列。
5. 深入: setTimeout vs setImmediate 🔥
经典面试题
javascript
setTimeout(() => { console.log('timeout'); }, 0);
setImmediate(() => { console.log('immediate'); });执行顺序不确定性
现象: 在主模块顶层代码中,timeout 和 immediate 的顺序不确定。
底层原理:
setTimeout(fn, 0)的阈值实际是 1ms (源码限制)- Event Loop 启动时先进入 Timers Phase
- 如果系统启动耗时 < 1ms:
- 定时器还没到期 → 跳过 → Poll → Check Phase → 执行
immediate - 下一轮 Loop 回到 Timers → 执行
timeout
- 定时器还没到期 → 跳过 → Poll → Check Phase → 执行
- 如果系统启动耗时 > 1ms:
- 定时器已到期 → 执行
timeout→ 后续执行immediate
- 定时器已到期 → 执行
启动耗时 < 1ms: immediate → timeout
启动耗时 > 1ms: timeout → immediate确定性场景
在 I/O 回调中,setImmediate 总是先于 setTimeout:
javascript
const fs = require('fs');
fs.readFile(__filename, () => {
setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));
});
// 输出固定: immediate → timeout原因: I/O 回调在 Poll Phase 执行,Poll 结束后直接进入 Check Phase,不会回退到 Timers Phase。
6. 深入: 微任务队列差异 🔥
process.nextTick vs Promise
| 特性 | process.nextTick | Promise.then |
|---|---|---|
| 队列 | nextTickQueue | PromiseJobQueue |
| 优先级 | 更高 | 次于 nextTick |
| 执行时机 | 当前操作完成后立即 | nextTick 队列清空后 |
javascript
Promise.resolve().then(() => console.log('promise'));
process.nextTick(() => console.log('nextTick'));
// 输出: nextTick → promiseEvent Loop 饥饿 (Starvation)
WARNING
递归调用 process.nextTick 会导致 Event Loop 饥饿,阻塞后续 I/O!
javascript
// 危险: 死循环阻塞 I/O
function recursiveNextTick() {
process.nextTick(recursiveNextTick);
}
recursiveNextTick();
// I/O 永远不会被处理!对比 setImmediate: 不会饥饿,因为它在每轮 Loop 的特定阶段 (Check Phase) 执行,不会无限插队。
javascript
// 安全: 每轮只执行一次
function recursiveImmediate() {
setImmediate(recursiveImmediate);
}
recursiveImmediate();
// I/O 可以正常处理最佳实践
| 场景 | 推荐 |
|---|---|
| 在同步代码后立即执行 | process.nextTick |
| 允许 I/O 优先处理 | setImmediate |
| 通用异步延迟 | Promise / queueMicrotask |
7. 线程池 (Thread Pool) 深入 🔥
7.1 为什么需要线程池?
Node.js 主线程是单线程的,但某些操作天生是阻塞的:
| 阻塞操作类型 | 示例 |
|---|---|
| 文件系统 | fs.readFile(), fs.writeFile() |
| DNS 查询 | dns.lookup() |
| 加密操作 | crypto.pbkdf2(), crypto.randomBytes() |
| 压缩 | zlib.gzip() |
7.2 线程池配置
bash
# 默认 4 个线程,最大 1024
UV_THREADPOOL_SIZE=8 node app.jsjavascript
// 运行时查看
process.env.UV_THREADPOOL_SIZE // "8"7.3 线程池瓶颈示例
javascript
const crypto = require('crypto');
const start = Date.now();
// 5 个 CPU 密集型任务,但只有 4 个线程
for (let i = 0; i < 5; i++) {
crypto.pbkdf2('password', 'salt', 100000, 64, 'sha512', () => {
console.log(`Task ${i + 1}: ${Date.now() - start}ms`);
});
}
// 输出示例 (4 核机器):
// Task 1: 52ms ─┐
// Task 2: 53ms │ 同时完成 (并行)
// Task 3: 53ms │
// Task 4: 54ms ─┘
// Task 5: 105ms ← 等待线程空闲 (串行)7.4 哪些操作不走线程池?
| 操作 | 处理方式 |
|---|---|
| 网络 I/O (TCP/UDP) | 操作系统异步 API (epoll/kqueue/IOCP) |
dns.resolve() | 操作系统异步 API |
setTimeout/setInterval | Event Loop Timers |
setImmediate | Event Loop Check Phase |
8. 执行顺序综合练习 🔥
练习 1: 基础顺序
javascript
console.log('1');
setTimeout(() => console.log('2'), 0);
Promise.resolve().then(() => console.log('3'));
process.nextTick(() => console.log('4'));
console.log('5');点击查看答案
1 → 5 → 4 → 3 → 2解析:
- 同步代码:
1,5 - nextTick 队列:
4 - 微任务队列:
3 - 宏任务队列:
2
练习 2: 嵌套微任务
javascript
Promise.resolve().then(() => {
console.log('promise1');
process.nextTick(() => console.log('nextTick1'));
}).then(() => console.log('promise2'));
process.nextTick(() => {
console.log('nextTick2');
Promise.resolve().then(() => console.log('promise3'));
});点击查看答案
nextTick2 → promise3 → promise1 → nextTick1 → promise2解析:
- nextTick 先于 Promise:
nextTick2 - nextTick 回调中产生的 Promise:
promise3 - 第一个 Promise.then:
promise1 - promise1 回调中产生的 nextTick:
nextTick1 - 链式 Promise.then:
promise2
练习 3: 混合定时器
javascript
setImmediate(() => {
console.log('immediate1');
Promise.resolve().then(() => console.log('promise1'));
});
setImmediate(() => {
console.log('immediate2');
Promise.resolve().then(() => console.log('promise2'));
});
// Node.js 11+ 输出点击查看答案
immediate1 → promise1 → immediate2 → promise2解析 (Node 11+): 每个宏任务后清空微任务队列。
immediate1 → promise2 → immediate2 → promise2 // Node 10- 输出9. 浏览器 vs Node.js 对比
┌─────────────────────────────────────────────────────────────────┐
│ 浏览器 Event Loop │
├─────────────────────────────────────────────────────────────────┤
│ ┌──────────┐ ┌──────────────┐ ┌──────────────────────┐ │
│ │ 宏任务队列 │ → │ 微任务队列清空 │ → │ requestAnimationFrame│ │
│ │(一个任务) │ │ │ │ + 渲染 │ │
│ └──────────┘ └──────────────┘ └──────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ Node.js Event Loop │
├─────────────────────────────────────────────────────────────────┤
│ Timers → Pending → Idle → Poll → Check → Close │
│ ↓ ↓ ↓ ↓ ↓ ↓ │
│ [微任务] [微任务] [微任务] [微任务] [微任务] [微任务] │
└─────────────────────────────────────────────────────────────────┘| 特性 | 浏览器 | Node.js |
|---|---|---|
| 宏任务来源 | Event Queue | 6个阶段队列 |
| 微任务执行时机 | 每个宏任务后 | 每个阶段后 (11+) |
| 特有 API | requestAnimationFrame | setImmediate, process.nextTick |
| nextTick | ❌ | ✅ (最高优先级微任务) |
| queueMicrotask | ✅ | ✅ (等同 Promise.then) |
10. 性能监控与调试
10.1 监控 Event Loop 延迟
javascript
const start = process.hrtime.bigint();
setImmediate(() => {
const delay = Number(process.hrtime.bigint() - start) / 1e6;
console.log(`Event Loop Delay: ${delay.toFixed(2)}ms`);
});10.2 使用 perf_hooks
javascript
const { monitorEventLoopDelay } = require('perf_hooks');
const h = monitorEventLoopDelay({ resolution: 20 });
h.enable();
setInterval(() => {
console.log({
min: h.min / 1e6, // ms
max: h.max / 1e6,
mean: h.mean / 1e6,
p99: h.percentile(99) / 1e6
});
}, 5000);10.3 阻塞检测
javascript
// 简易阻塞检测器
let lastCheck = Date.now();
const threshold = 100; // ms
setInterval(() => {
const now = Date.now();
const delta = now - lastCheck;
if (delta > threshold) {
console.warn(`⚠️ Event Loop blocked for ${delta - 10}ms`);
}
lastCheck = now;
}, 10);11. 面试高频问题
Q1: Node.js 是单线程的吗?
不完全正确。JavaScript 执行在单线程 (主线程),但 Libuv 的线程池用于处理阻塞 I/O。
- 主线程: V8 + Event Loop
- 线程池: 默认 4 线程,处理 fs / dns / crypto
Q2: 为什么 setTimeout(fn, 0) 不能保证 0ms 执行?
- HTML5 规范最小延迟 4ms (浏览器)
- Node.js 源码限制最小 1ms
- Event Loop 需要先执行完同步代码和微任务
Q3: setImmediate vs setTimeout(fn, 0)
| 场景 | 顺序 |
|---|---|
| 主模块顶层 | 不确定 (取决于启动时间) |
| I/O 回调内 | setImmediate 先执行 |
Q4: 如何避免 Event Loop 阻塞?
- 使用
setImmediate分片处理大任务 - CPU 密集任务使用
worker_threads - 避免同步 API (
fs.readFileSync) - 增大线程池 (
UV_THREADPOOL_SIZE)
Q5: process.nextTick 和 queueMicrotask 的区别?
| 特性 | process.nextTick | queueMicrotask |
|---|---|---|
| 队列 | nextTickQueue | PromiseJobQueue |
| 优先级 | 最高 | 次于 nextTick |
| 规范 | Node.js 特有 | WHATWG 标准 |
| 递归风险 | 可能饥饿 | 不会饥饿 |
Q6: Event Loop 和 Call Stack 的关系?
┌───────────────────────────────────────────────────┐
│ Call Stack 空 │
│ ↓ │
│ Event Loop 检查任务队列 (宏任务/微任务) │
│ ↓ │
│ 取出回调函数压入 Call Stack 执行 │
│ ↓ │
│ Call Stack 再次空 → 循环 │
└───────────────────────────────────────────────────┘只有当 Call Stack 为空 时,Event Loop 才会将队列中的回调推入执行。