别阻塞事件循环
本教程共 76 篇 · 第 17 篇 · 更新于 2026-07-25 · 约 6 分钟阅读
17. 别阻塞事件循环
本节目标:哪些操作会阻塞事件循环,如何用异步和拆分任务避免卡顿。
Node.js 的高并发有个隐含前提:每个回调都必须尽快结束。如果某个回调霸占线程不放,后面所有请求都得干等。轻则响应变慢,重则整个服务失去响应,甚至触发 DoS 攻击。
这一章不讲新 API,只讲一个核心原则:保持事件循环畅通。
什么是阻塞
阻塞分两种:
- I/O 阻塞:同步文件读写、同步子进程调用。这些会占用 libuv 的 Worker Pool,但也会卡住事件循环等结果。
- 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.parse 和 JSON.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.readFileSync、fs.writeFileSync等同步文件操作。crypto.pbkdf2Sync、crypto.randomFillSync等同步加密。zlib.inflateSync、zlib.deflateSync等同步压缩。child_process.execSync、spawnSync等同步子进程。
这些 API 的 Sync 后缀就是在警告你:我会阻塞。脚本工具里用用无妨,服务端请求处理链里出现它们,等于埋了颗雷。
Tip优先使用
fs/promises、crypto的异步版本、以及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.js、0x 火焰图、Node.js 内置的 --prof,能帮你定位具体哪段代码在吃 CPU。性能诊断章节会展开。