返回博客
技术 2025年3月16日 8 分钟阅读 · 2066 字

Node.js 事件循环与异步编程深度解析

从 Libuv 到事件循环六阶段,理解 Node.js 异步 I/O 的底层原理

#Node.js #事件循环 #异步编程 #Libuv
本文由 AI 辅助生成,经人工审核发布

Node.js 能以单线程处理高并发请求,其核心秘密就藏在事件循环(Event Loop)之中。本文将从底层的 Libuv 架构出发,逐层拆解事件循环的六个阶段,深入理解宏任务与微任务的执行顺序,并探讨集群与多进程如何突破单核瓶颈。

一、Libuv 架构概述

Node.js 的异步 I/O 并非由 V8 引擎完成,而是交由底层的 C 语言库 Libuv 统一调度。Libuv 提供了一个跨平台的异步 I/O 抽象层,是 Node.js 脱离浏览器、走向服务端的基石。

Libuv 的整体架构可以分为以下几个部分:

组件职责说明
事件循环驱动异步任务单线程轮询,处理定时器、I/O 回调等
线程池处理阻塞型任务默认 4 个线程,可配置
I/O 多路复用监听文件描述符Linux 使用 epoll,macOS 使用 kqueue,Windows 使用 IOCP
异步 DNS非阻塞域名解析利用线程池或系统原生异步接口
文件系统操作异步文件读写由于平台差异,统一走线程池

值得强调的是,Node.js 并非”纯单线程”。JavaScript 代码确实在单线程中执行,但底层的 I/O 操作会由 Libuv 的线程池并行处理。可以通过环境变量调整线程池大小:

# 将线程池大小设置为 8
export UV_THREADPOOL_SIZE=8
const fs = require('fs');
const crypto = require('crypto');

// 文件系统操作和 PBKDF2 都会使用线程池
console.time('total');
Promise.all([
  new Promise(resolve => fs.readFile('./large.txt', resolve)),
  new Promise(resolve => crypto.pbkdf2('secret', 'salt', 100000, 64, 'sha512', resolve)),
]).then(() => console.timeEnd('total'));

二、事件循环的六个阶段

事件循环可以理解为一个无限循环,每一轮(tick)都会依次经过若干阶段。Node.js 官方将其划分为六个阶段:

┌───────────────────────────┐
│        timers             │  ← 执行 setTimeout / setInterval 回调
├───────────────────────────┤
│     pending callbacks     │  ← 执行系统级回调(如 TCP 错误)
├───────────────────────────┤
│       idle, prepare       │  ← 内部使用
├───────────────────────────┤
│           poll            │  ← 获取新 I/O 事件,执行 I/O 回调
├───────────────────────────┤
│           check           │  ← 执行 setImmediate 回调
├───────────────────────────┤
│         close             │  ← 执行 close 事件回调
└───────────────────────────┘

2.1 timers 阶段

timers 阶段处理所有到期的 setTimeoutsetInterval 回调。需要注意的是,定时器指定的时间并非精确执行时间,而是最小延迟时间。如果前一个阶段耗时过长,定时器回调会被推迟。

const start = Date.now();
setTimeout(() => {
  console.log(`定时器延迟了 ${Date.now() - start - 100}ms 才执行`);
}, 100);

// 模拟一个耗时操作,会拖延 timers 阶段
const heavy = Date.now() + 300;
while (Date.now() < heavy) {}

上述代码中,虽然定时器设置为 100ms,但由于同步代码阻塞了 300ms,回调实际会在 300ms 后才执行。

2.2 pending callbacks 阶段

这个阶段执行某些系统操作的回调,例如 TCP 连接失败时的 ECONNREFUSED 错误回调。日常开发较少直接接触。

2.3 poll 阶段

poll 阶段是事件循环中最核心的部分,它做两件事:

  1. 计算应该阻塞多久等待 I/O 事件
  2. 处理 poll 队列中的事件

如果 poll 队列为空,且没有 setImmediate 回调待执行,事件循环会阻塞等待新事件;如果有 setImmediate 待执行,则直接进入 check 阶段。

2.4 check 阶段

check 阶段执行通过 setImmediate 注册的回调。setImmediate 的设计目的是在当前 poll 阶段完成后立即执行。

2.5 close 阶段

close 阶段处理关闭事件的回调,例如 socket 被意外关闭时触发的回调。

const net = require('net');

const server = net.createServer(socket => {
  socket.on('close', () => {
    console.log('连接已在 close 阶段关闭');
  });
});

server.listen(3000);

三、宏任务与微任务

与浏览器类似,Node.js 也区分宏任务和微任务,但 Node.js 的微任务机制有其独特之处。

3.1 微任务队列

Node.js 中有两类微任务:

  • Promise 回调Promise.thenqueueMicrotask
  • process.nextTick:Node.js 特有,优先级高于 Promise

微任务会在每个阶段切换之间执行。这意味着在一个宏任务回调执行完毕后、进入下一个阶段前,所有微任务都会被清空。

Promise.resolve().then(() => console.log('Promise 微任务'));
process.nextTick(() => console.log('nextTick 微任务'));
console.log('同步代码');

// 输出顺序:
// 同步代码
// nextTick 微任务
// Promise 微任务

3.2 执行顺序对比

setImmediate(() => console.log('setImmediate'));
setTimeout(() => console.log('setTimeout'), 0);
Promise.resolve().then(() => console.log('Promise'));
process.nextTick(() => console.log('nextTick'));

// 典型输出顺序:
// nextTick
// Promise
// setTimeout / setImmediate(顺序不确定)

四、process.nextTick vs setImmediate

这两个 API 经常被混淆,但它们的执行时机完全不同。

特性process.nextTicksetImmediate
类型微任务宏任务(check 阶段)
执行时机当前阶段结束后立即执行下一轮事件循环的 check 阶段
优先级高于 Promise低于 I/O 回调
递归风险可能阻塞事件循环不会阻塞,让出控制权

4.1 nextTick 的递归陷阱

function recursiveNextTick(n) {
  if (n > 0) process.nextTick(() => recursiveNextTick(n - 1));
}
recursiveNextTick(1000000);
console.log('这句会在所有 nextTick 执行前打印');
// 事件循环会被 nextTick 完全阻塞

由于 process.nextTick 的回调在阶段切换时执行,递归调用会不断向微任务队列添加新任务,导致事件循环无法进入下一个阶段。相比之下,setImmediate 递归则会让出控制权:

function recursiveImmediate(n) {
  if (n > 0) setImmediate(() => recursiveImmediate(n - 1));
}
recursiveImmediate(1000000);
setInterval(() => console.log('事件循环没有被阻塞'), 100);

五、I/O 多路复用

Libuv 在不同平台上使用不同的 I/O 多路复用机制:

平台机制特点
LinuxepollO(1) 复杂度,支持边缘触发
macOSkqueue类似 epoll,BSD 系特有
WindowsIOCP完成端口模型,真正异步 I/O
通用回退poll/select性能较差,仅做兜底

这种抽象让 Node.js 开发者无需关心底层平台差异。无论在哪个系统上,写出的异步代码行为一致:

const http = require('http');

const server = http.createServer((req, res) => {
  // 底层由 epoll/kqueue/IOCP 驱动
  res.end('Hello World');
});

server.listen(3000, () => {
  console.log('服务器启动,I/O 多路复用已就绪');
});

六、集群与多进程

单线程虽然避免了锁的开销,但无法充分利用多核 CPU。Node.js 提供了 cluster 模块来解决这一问题。

6.1 cluster 模块基础

const cluster = require('cluster');
const os = require('os');
const http = require('http');

if (cluster.isPrimary) {
  const cpuCount = os.cpus().length;
  console.log(`主进程 PID: ${process.pid}`);
  console.log(`将启动 ${cpuCount} 个工作进程`);

  for (let i = 0; i < cpuCount; i++) {
    cluster.fork();
  }

  cluster.on('exit', (worker, code) => {
    console.log(`工作进程 ${worker.process.pid} 退出`);
    cluster.fork(); // 自动重启
  });
} else {
  http.createServer((req, res) => {
    res.end(`由进程 ${process.pid} 处理`);
  }).listen(3000);

  console.log(`工作进程 PID: ${process.pid}`);
}

6.2 负载均衡策略

Node.js 从 v16 起默认使用轮询(round-robin)策略分配连接,避免了早期”惊群效应”的问题。

// 设置调度策略
cluster.schedulingPolicy = cluster.SCHED_RR; // 轮询(默认)
// cluster.schedulingPolicy = cluster.SCHED_NONE; // 由操作系统分配

6.3 进程间通信

if (cluster.isPrimary) {
  const worker = cluster.fork();
  worker.send({ type: 'task', data: '处理这个任务' });
  worker.on('message', msg => {
    console.log('收到工作进程消息:', msg);
  });
} else {
  process.on('message', msg => {
    console.log('收到主进程消息:', msg);
    process.send({ type: 'result', data: '任务完成' });
  });
}

6.4 优雅关闭

process.on('SIGTERM', () => {
  console.log('收到终止信号,开始优雅关闭');
  server.close(() => {
    console.log('所有连接已断开,进程退出');
    process.exit(0);
  });

  // 超时强制退出
  setTimeout(() => process.exit(1), 10000);
});

七、实战建议

  1. CPU 密集型任务:不要在主线程执行,使用 worker_threads 或拆分为子进程
  2. 谨慎使用 process.nextTick:它可能阻塞 I/O,优先用 setImmediate
  3. 合理设置线程池大小:I/O 密集场景可适当增大 UV_THREADPOOL_SIZE
  4. 生产环境使用 cluster:充分利用多核,并实现自动重启
  5. 监控事件循环延迟:使用 perf_hooks 监测事件循环是否被阻塞
const { monitorEventLoopDelay } = require('perf_hooks');

const h = monitorEventLoopDelay();
setInterval(() => {
  console.log(`事件循环延迟 - 最小: ${h.min}ms, 最大: ${h.max}ms, 平均: ${h.mean.toFixed(2)}ms`);
  h.reset();
}, 5000);

总结

Node.js 的事件循环是其高性能的根基。理解 Libuv 的线程池模型、六阶段执行流程、微任务的优先级规则,能帮助开发者写出更高效、更健壮的异步代码。在生产环境中,配合 cluster 模块与进程管理工具(如 PM2),可以让 Node.js 应用在多核环境下稳定运行,充分发挥异步 I/O 的优势。