Skip to content

深入理解 Node.js 事件循环

一句话概述:六个阶段、微任务宏任务、libuv 线程池、nextTick vs setImmediate——掌握 Node.js 异步执行的核心机制

什么是事件循环?

定义:事件循环是 Node.js 处理非阻塞 I/O 操作的核心机制,尽管 JavaScript 是单线程的,但通过将操作卸载到系统内核(通过 libuv),Node.js 可以执行非阻塞操作。

涉及场景

  • 异步 I/O:文件读写、网络请求、数据库查询
  • 定时器setTimeoutsetInterval
  • 事件处理:HTTP 请求、WebSocket 消息
  • 进程通信:IPC、子进程

作用

  1. 协调异步操作的执行顺序
  2. 避免阻塞主线程
  3. 实现高并发处理

一、事件循环的六个阶段

完整流程图

text
   ┌───────────────────────────┐
┌─>│           timers          │ 执行 setTimeout/setInterval 回调
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │     pending callbacks     │ 执行延迟到下一轮的 I/O 回调
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │       idle, prepare       │ 仅内部使用
│  └─────────────┬─────────────┘      ┌───────────────┐
│  ┌─────────────┴─────────────┐      │   incoming:   │
│  │           poll            │<─────┤  connections, │
│  └─────────────┬─────────────┘      │   data, etc.  │
│  ┌─────────────┴─────────────┐      └───────────────┘
│  │           check           │ 执行 setImmediate 回调
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
└──┤      close callbacks      │ 执行 close 事件回调(如 socket.on('close'))
   └───────────────────────────┘

各阶段详解

1. timers 阶段

执行 setTimeout()setInterval() 的回调。

javascript
setTimeout(() => {
  console.log('timeout');
}, 0);

// 注意:延迟 0ms 不代表立即执行,而是在 timers 阶段执行
// 实际延迟受事件循环当前阶段影响

关键点

  • 定时器回调的执行时间不是精确的,取决于事件循环的繁忙程度
  • 最小延迟约 1ms(操作系统限制)

2. pending callbacks 阶段

执行某些系统操作的回调,如 TCP 错误。大多数情况下可以忽略此阶段。

3. idle, prepare 阶段

仅供 libuv 内部使用,开发者无需关心。

4. poll 阶段(最重要)

作用

  • 检索新的 I/O 事件
  • 执行 I/O 相关的回调(几乎所有回调,除了 close、timers、setImmediate)
  • 如果没有其他任务,Node.js 会在此阶段阻塞等待

执行逻辑

text
poll 阶段:
1. 如果 poll 队列不为空
   → 同步执行队列中的回调,直到队列清空或达到系统限制
2. 如果 poll 队列为空
   → 检查是否有 setImmediate 回调
      - 有 → 进入 check 阶段
      - 无 → 检查是否有到期的 timers
         - 有 → 回到 timers 阶段
         - 无 → 阻塞等待新的 I/O 事件

5. check 阶段

执行 setImmediate() 的回调。

javascript
setImmediate(() => {
  console.log('immediate');
});

为什么需要 setImmediate?

  • 允许在 poll 阶段完成后立即执行回调
  • setTimeout(fn, 0) 更可预测

6. close callbacks 阶段

执行关闭事件的回调,如 socket.on('close', ...)

javascript
const net = require('net');
const server = net.createServer();

server.on('close', () => {
  console.log('服务器已关闭'); // 在 close callbacks 阶段执行
});

server.close();

二、微任务与宏任务

任务分类

微任务(Microtask)

  • process.nextTick()
  • Promise.then/catch/finally
  • queueMicrotask()

宏任务(Macrotask)

  • setTimeout
  • setInterval
  • setImmediate
  • I/O 操作

执行顺序

text
每个事件循环阶段之间:
1. 清空 nextTick 队列
2. 清空微任务队列(Promise)
3. 进入下一个阶段

关键规则

  • process.nextTick() 优先级最高,在任何阶段切换前执行
  • 微任务在每个宏任务之间执行
  • 过度使用 nextTick 可能导致事件循环饥饿

完整示例

javascript
console.log('1: 同步代码');

setTimeout(() => {
  console.log('2: setTimeout');
  Promise.resolve().then(() => console.log('3: Promise in setTimeout'));
}, 0);

setImmediate(() => {
  console.log('4: setImmediate');
});

Promise.resolve().then(() => {
  console.log('5: Promise');
  process.nextTick(() => console.log('6: nextTick in Promise'));
});

process.nextTick(() => {
  console.log('7: nextTick');
  Promise.resolve().then(() => console.log('8: Promise in nextTick'));
});

console.log('9: 同步代码结束');

// 输出顺序:
// 1: 同步代码
// 9: 同步代码结束
// 7: nextTick(nextTick 队列清空)
// 8: Promise in nextTick(微任务队列清空)
// 5: Promise
// 6: nextTick in Promise
// 2: setTimeout(timers 阶段)
// 3: Promise in setTimeout
// 4: setImmediate(check 阶段)

三、process.nextTick vs setImmediate

核心区别

特性process.nextTick()setImmediate()
执行时机当前操作完成后立即执行check 阶段执行
优先级最高(在所有阶段切换前)低于 nextTick 和微任务
递归风险可能导致事件循环饥饿不会阻塞事件循环
适用场景错误处理、API 一致性I/O 操作后的回调

事件循环饥饿示例

javascript
// ❌ 危险:nextTick 递归会阻塞事件循环
function recursiveNextTick() {
  process.nextTick(recursiveNextTick);
}
recursiveNextTick();

// 此时 setTimeout、setImmediate、I/O 回调都无法执行
// 因为 nextTick 队列永远不为空

// ✅ 安全:setImmediate 不会阻塞
function recursiveImmediate() {
  setImmediate(recursiveImmediate);
}
recursiveImmediate();

// 每次循环只执行一个 setImmediate,其他任务有机会执行

使用建议

使用 process.nextTick()

  • 在构造函数中延迟事件发射(保证监听器已注册)
  • 错误优先处理
  • 确保 API 始终异步
javascript
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('准备就绪')); // 能正常接收

使用 setImmediate()

  • I/O 操作后的回调
  • 拆分 CPU 密集型任务
javascript
// 拆分大任务
function processLargeArray(array, callback) {
  const chunk = array.splice(0, 1000);
  
  // 处理当前块
  chunk.forEach(item => process(item));
  
  if (array.length > 0) {
    setImmediate(() => processLargeArray(array, callback));
  } else {
    callback();
  }
}

四、setTimeout vs setImmediate 执行顺序

不确定性

在主模块中,两者的执行顺序是不确定的:

javascript
setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));

// 可能输出:
// timeout
// immediate

// 也可能输出:
// immediate
// timeout

原因

  • 事件循环启动时,如果进入 timers 阶段时定时器已到期 → setTimeout 先执行
  • 如果定时器未到期(受系统调度影响)→ 直接进入 poll 阶段 → setImmediate 先执行

确定性

在 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 阶段执行,poll 阶段之后是 check 阶段(setImmediate),然后才回到 timers 阶段。


五、libuv 与线程池

libuv 架构

Node.js 的异步 I/O 由 libuv 库实现:

text
┌─────────────────────────────────────────┐
│          JavaScript (V8)                │
├─────────────────────────────────────────┤
│          Node.js Bindings               │
├─────────────────────────────────────────┤
│              libuv                      │
│  ┌───────────────┬──────────────────┐  │
│  │  Event Loop   │   Thread Pool    │  │
│  │  (单线程)      │   (默认 4 线程)   │  │
│  └───────────────┴──────────────────┘  │
├─────────────────────────────────────────┤
│          操作系统 (epoll/kqueue/IOCP)   │
└─────────────────────────────────────────┘

线程池任务

以下操作在 libuv 线程池中执行(默认 4 个线程):

  • 文件 I/O(fs.*
  • DNS 查询(dns.lookup
  • 加密操作(crypto.pbkdf2crypto.randomBytes
  • Zlib 压缩
javascript
const crypto = require('crypto');

// 这些操作在线程池中并行执行
for (let i = 0; i < 4; i++) {
  crypto.pbkdf2('password', 'salt', 100000, 64, 'sha512', (err, key) => {
    console.log(`任务 ${i} 完成`);
  });
}

// 如果有 5 个任务,第 5 个会等待前 4 个中的一个完成

调整线程池大小

bash
# 设置线程池大小为 8
UV_THREADPOOL_SIZE=8 node app.js
javascript
// 或在代码中设置(必须在任何 libuv 操作之前)
process.env.UV_THREADPOOL_SIZE = 8;

注意

  • 网络 I/O 不使用线程池,直接使用操作系统的异步机制(epoll/kqueue)
  • 线程池大小不是越大越好,过多线程会增加上下文切换开销

六、实战:诊断事件循环问题

检测事件循环阻塞

javascript
const { performance } = require('perf_hooks');

let lastCheck = performance.now();

setInterval(() => {
  const now = performance.now();
  const delay = now - lastCheck - 1000; // 减去预期的 1000ms
  
  if (delay > 100) {
    console.warn(`事件循环延迟: ${delay.toFixed(2)}ms`);
  }
  
  lastCheck = now;
}, 1000);

// 模拟阻塞
setTimeout(() => {
  const start = Date.now();
  while (Date.now() - start < 2000) {} // 阻塞 2 秒
}, 3000);

使用 clinic.js 诊断

bash
# 安装
npm install -g clinic

# 诊断事件循环延迟
clinic doctor -- node app.js

# 诊断 CPU 使用
clinic flame -- node app.js

# 诊断内存泄漏
clinic heapprofiler -- node app.js

面试高频题

1. Node.js 事件循环的六个阶段分别是什么?

答案:timers(定时器)、pending callbacks(待定回调)、idle/prepare(内部使用)、poll(轮询,最重要)、check(setImmediate)、close callbacks(关闭回调)。poll 阶段负责执行 I/O 回调,如果队列为空会检查 setImmediate 或等待新事件。

2. process.nextTick()setImmediate() 有什么区别?

答案nextTick 在当前操作完成后、事件循环继续前立即执行,优先级最高但可能导致事件循环饥饿。setImmediate 在 check 阶段执行,不会阻塞事件循环。在 I/O 回调中 setImmediate 总是先于 setTimeout 执行,但在主模块中顺序不确定。

3. 为什么 setTimeout(fn, 0)setImmediate(fn) 的执行顺序不确定?

答案:在主模块中,事件循环启动时如果定时器已到期则先执行 setTimeout,否则直接进入 poll 阶段后执行 setImmediate。这取决于进程启动速度和系统调度。但在 I/O 回调中,setImmediate 总是先执行,因为 I/O 回调在 poll 阶段,之后就是 check 阶段。

4. libuv 线程池用于哪些操作?

答案:文件 I/O、DNS 查询(dns.lookup)、加密操作(crypto.pbkdf2)、Zlib 压缩。网络 I/O 不使用线程池,直接使用操作系统的异步机制(Linux 的 epoll、macOS 的 kqueue、Windows 的 IOCP)。默认线程池大小为 4,可通过 UV_THREADPOOL_SIZE 环境变量调整。

5. 如何避免事件循环阻塞?

答案:(1)避免同步阻塞操作(如 fs.readFileSync);(2)拆分 CPU 密集型任务,使用 setImmediate 让出事件循环;(3)CPU 密集型任务使用 Worker Threads;(4)使用 Stream 处理大数据;(5)监控事件循环延迟,使用 clinic.js 等工具诊断性能瓶颈。