首页 / Node.js 教程 / 别阻塞事件循环

Node.js 教程

别阻塞事件循环

本教程共 76 篇 · 第 17 篇 · 更新于 2026-07-25 · 约 6 分钟阅读

Node.js事件循环阻塞性能CPU密集

17. 别阻塞事件循环

本节目标:哪些操作会阻塞事件循环,如何用异步和拆分任务避免卡顿。

Node.js 的高并发有个隐含前提:每个回调都必须尽快结束。如果某个回调霸占线程不放,后面所有请求都得干等。轻则响应变慢,重则整个服务失去响应,甚至触发 DoS 攻击。

这一章不讲新 API,只讲一个核心原则:保持事件循环畅通

什么是阻塞

阻塞分两种:

  1. I/O 阻塞:同步文件读写、同步子进程调用。这些会占用 libuv 的 Worker Pool,但也会卡住事件循环等结果。
  2. CPU 阻塞:大循环、复杂正则、大数据 JSON 序列化。这些直接占用 JavaScript 主线程,事件循环完全停摆。

不管是哪种,结果都一样:其他客户的请求得不到处理。

CPU 密集型陷阱

看一个看似无害的 Express 路由:

import express from 'express';
const app = express();

app.get('/sum', (req, res) => {
  const n = Number(req.query.n) || 1000;
  let total = 0;
  for (let i = 0; i < n; i++) {
    total += i;
  }
  res.json({ total });
});

n 小的时候没问题。但如果用户传 n=1e9,循环跑几秒钟,期间所有其他 HTTP 请求全挂起。一个恶意用户就能让整个 Node.js 服务瘫痪。

Warning

永远不要让回调的执行时间依赖于用户输入的大小。如果必须处理大输入,要加边界校验,拒绝超量请求:

if (n > 1_000_000) {
  return res.status(400).json({ error: 'n too large' });
}

正则表达式也能杀人:ReDoS

正则匹配看起来是“一行代码”,但某些模式会让 V8 引擎进入指数级回溯。比如:

app.get('/validate', (req, res) => {
  const path = req.query.path || '';
  if (/^(\/[^\/]+)+$/.test(path)) {
    res.json({ valid: true });
  } else {
    res.json({ valid: false });
  }
});

这个正则用来匹配类似 /a/b/c 的路径。但如果传入一串斜杠加换行(///.../\n),V8 会疯狂回溯,CPU 被吃满。这就是正则表达式拒绝服务(ReDoS)

防御建议:

  • 避免嵌套量词,如 (a+)*
  • 避免分支重叠,如 (a|a)*
  • 简单字符串匹配用 String.prototype.indexOf,不用正则。
  • 需要严格安全保障时,可以用 node-re2 模块替换 V8 正则引擎。

JSON 操作也不安全

JSON.parseJSON.stringify 是 O(n) 复杂度,n 是字符串长度。对于小对象可以忽略,但如果服务端接收客户端传来的 JSON,不做大小限制,就可能被大 payload 卡死:

app.post('/api/data', express.json(), (req, res) => {
  // 如果客户端发来 100MB 的 JSON,这里 parse 可能耗时数秒
  res.json({ received: req.body });
});

Express 的 express.json() 默认限制是 100KB,但很多开发者为了“方便”把它调得很大。别这么做。合理设置 limit,超长的直接拒绝:

app.use(express.json({ limit: '10kb' }));

同步 API:服务器里的黑名单

在服务端代码中,以下同步 API 能不用就不用:

  • fs.readFileSyncfs.writeFileSync 等同步文件操作。
  • crypto.pbkdf2Synccrypto.randomFillSync 等同步加密。
  • zlib.inflateSynczlib.deflateSync 等同步压缩。
  • child_process.execSyncspawnSync 等同步子进程。

这些 API 的 Sync 后缀就是在警告你:我会阻塞。脚本工具里用用无妨,服务端请求处理链里出现它们,等于埋了颗雷。

Tip

优先使用 fs/promisescrypto 的异步版本、以及 child_process.spawn 等非阻塞替代方案。

分片:把大任务拆成小片

如果确实要在事件循环里做大量计算,可以把它切成小片,每片之间让出时间,给其他事件机会:

function sumInChunks(n, chunkSize = 10000) {
  return new Promise((resolve) => {
    let i = 0;
    let total = 0;

    function step() {
      const end = Math.min(i + chunkSize, n);
      for (; i < end; i++) {
        total += i;
      }

      if (i < n) {
        setImmediate(step); // 让出事件循环
      } else {
        resolve(total);
      }
    }

    step();
  });
}

// 使用
const result = await sumInChunks(1e8);
console.log(result);

setImmediate 把下一片推到 check 阶段,期间 poll 阶段可以处理新来的 I/O。总时间变长,但服务不会僵死。

不过,这种“分片”只解决不阻塞的问题,没解决多核利用的问题。真要榨干 CPU,得把工作丢给其他线程或进程。

卸载:worker_threads 与 child_process

Node.js 提供了两个把 CPU 密集型任务搬出主线程的方案:

worker_threads

同进程内的多线程,适合纯 JavaScript 的计算任务:

import { Worker } from 'node:worker_threads';

const worker = new Worker('./hash-worker.js', {
  workerData: 'password-to-hash',
});

worker.on('message', (result) => {
  console.log('hash result:', result);
});

worker 之间通过 postMessage 通信,还能用 SharedArrayBuffer 共享内存。线程启动开销比进程小,适合频繁调用的小任务。

child_process

另起一个新进程,完全隔离,适合跑外部命令或需要独立崩溃域的场景:

import { fork } from 'node:child_process';

const child = fork('./cpu-task.js');
child.send({ job: 'heavy-calc', data: [1, 2, 3] });
child.on('message', (result) => {
  console.log(result);
});
Note

cluster 模块(基于 child_process)用于把 HTTP 服务扩展到多核,worker_threads 用于把计算任务搬离主线程。两者目标不同,不要混用。后面并发章节会详细对比。

为事件循环做体检

Node.js 没有直接告诉你“事件循环被阻塞了”的 API,但你可以间接测量:

let last = Date.now();

setInterval(() => {
  const now = Date.now();
  const lag = now - last - 1000; // 期望每秒一次
  if (lag > 100) {
    console.warn(`事件循环延迟 ${lag}ms,可能被阻塞`);
  }
  last = now;
}, 1000);

更专业的工具如 clinic.js0x 火焰图、Node.js 内置的 --prof,能帮你定位具体哪段代码在吃 CPU。性能诊断章节会展开。