服务端与数据 / 进阶
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;
}