服务端与数据 / 进阶

Node.js 事件循环:别让一段同步代码拖住所有请求

Node.js 擅长并发 I/O,但 JavaScript 仍运行在主线程上。本文用可观测指标、任务分片和 Worker Threads 说明如何处理 CPU 密集型工作。

先建立正确的心智模型

事件循环并不等于所有代码都会并行执行。网络和文件 I/O 可以交给系统或线程池,但回调、JSON 解析、模板渲染和普通计算最终仍要回到 JavaScript 主线程。一次 200 毫秒的同步计算,会让同一进程里的其他请求一起等待。

把函数声明为 async 不会自动把计算移出主线程。只有函数内部真正等待异步 I/O 时,控制权才会交还给事件循环。

用事件循环延迟确认阻塞

不要只看接口平均响应时间。事件循环延迟、P95/P99 响应时间和 CPU 使用率一起升高,才是主线程阻塞的强信号。Node.js 内置的 perf_hooks 可以低成本记录延迟分布。

EXAMPLE / 02Node.js
import { monitorEventLoopDelay } from 'node:perf_hooks';

const delay = monitorEventLoopDelay({ resolution: 20 });
delay.enable();

setInterval(() => {
  console.log({
    p95Ms: Math.round(delay.percentile(95) / 1e6),
    maxMs: Math.round(delay.max / 1e6)
  });
  delay.reset();
}, 10_000).unref();

按任务类型选择处理方式

短计算:减少循环次数、缓存中间结果,避免重复 JSON 序列化;可分片任务:每处理一批数据后用 setImmediate 让出主线程;持续 CPU 密集任务:使用 Worker Threads,并限制 Worker 数量;耗时且可延迟任务:写入队列,由独立消费者异步处理。

EXAMPLE / 03Node.js
const yieldToLoop = () => new Promise(resolve => setImmediate(resolve));

export async function normalizeRows(rows, batchSize = 500) {
  const result = [];
  for (let i = 0; i < rows.length; i += batchSize) {
    result.push(...rows.slice(i, i + batchSize).map(normalizeRow));
    await yieldToLoop();
  }
  return result;
}