Skip to content

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')
│  └───────────┬───────────────┘
│              │
└──────────────┘

各阶段详细说明

阶段执行内容典型场景
TimerssetTimeout/setInterval 到期回调延时任务、定时任务
Pending Callbacks延迟到下一轮的 I/O 回调TCP 连接错误回调
Idle, Prepare内部使用-
PollI/O 回调 + 等待新事件网络请求、文件读取
ChecksetImmediate 回调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'); });

执行顺序不确定性

现象: 在主模块顶层代码中,timeoutimmediate 的顺序不确定

底层原理:

  1. setTimeout(fn, 0) 的阈值实际是 1ms (源码限制)
  2. Event Loop 启动时先进入 Timers Phase
  3. 如果系统启动耗时 < 1ms:
    • 定时器还没到期 → 跳过 → Poll → Check Phase → 执行 immediate
    • 下一轮 Loop 回到 Timers → 执行 timeout
  4. 如果系统启动耗时 > 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.nextTickPromise.then
队列nextTickQueuePromiseJobQueue
优先级更高次于 nextTick
执行时机当前操作完成后立即nextTick 队列清空后
javascript
Promise.resolve().then(() => console.log('promise'));
process.nextTick(() => console.log('nextTick'));
// 输出: nextTick → promise

Event 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.js
javascript
// 运行时查看
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/setIntervalEvent Loop Timers
setImmediateEvent 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. 同步代码: 1, 5
  2. nextTick 队列: 4
  3. 微任务队列: 3
  4. 宏任务队列: 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

解析:

  1. nextTick 先于 Promise: nextTick2
  2. nextTick 回调中产生的 Promise: promise3
  3. 第一个 Promise.then: promise1
  4. promise1 回调中产生的 nextTick: nextTick1
  5. 链式 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 Queue6个阶段队列
微任务执行时机每个宏任务后每个阶段后 (11+)
特有 APIrequestAnimationFramesetImmediate, 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 执行?

  1. HTML5 规范最小延迟 4ms (浏览器)
  2. Node.js 源码限制最小 1ms
  3. Event Loop 需要先执行完同步代码和微任务

Q3: setImmediate vs setTimeout(fn, 0)

场景顺序
主模块顶层不确定 (取决于启动时间)
I/O 回调内setImmediate 先执行

Q4: 如何避免 Event Loop 阻塞?

  1. 使用 setImmediate 分片处理大任务
  2. CPU 密集任务使用 worker_threads
  3. 避免同步 API (fs.readFileSync)
  4. 增大线程池 (UV_THREADPOOL_SIZE)

Q5: process.nextTick 和 queueMicrotask 的区别?

特性process.nextTickqueueMicrotask
队列nextTickQueuePromiseJobQueue
优先级最高次于 nextTick
规范Node.js 特有WHATWG 标准
递归风险可能饥饿不会饥饿

Q6: Event Loop 和 Call Stack 的关系?

┌───────────────────────────────────────────────────┐
│                  Call Stack 空                     │
│                       ↓                            │
│    Event Loop 检查任务队列 (宏任务/微任务)          │
│                       ↓                            │
│         取出回调函数压入 Call Stack 执行           │
│                       ↓                            │
│              Call Stack 再次空 → 循环              │
└───────────────────────────────────────────────────┘

只有当 Call Stack 为空 时,Event Loop 才会将队列中的回调推入执行。

前端面试知识库