Node.js 监控事件循环阻塞:原理与实现方案
本文深入解析 Node.js 单线程模型下事件循环(Event Loop)的工作机制,探讨同步操作导致主进程阻塞的风险。通过基于 libuv 循环次数采样的两种代码方案,演示如何检测 CPU 占用过高及请求处理延迟问题,并对比同步与异步处理对性能的影响,提供实用的监控思路。
开场白
Event Loop 基础与阻塞问题
近期在研究 libuv 库,并观察了 Node.js 中应用 libuv 的实际案例。本文不赘述已被广泛讨论的 event loop 基础概念,而是聚焦于如何正确使用 event loop,以及如何检测程序是否出现异常阻塞。
基础
相信读者对 event loop 的基本原理已有所掌握。此处引用官方图示进行简要回顾,作为后续内容的基础:

event loop 是 Node.js 实现单线程异步的核心机制。简而言之,它在一个持续的 while 循环中遍历各个阶段(phases),并执行相应的回调函数。这种机制实现了真正的异步调用:调用时无需等待响应,只需在资源就绪后触发回调即可。 以上是基础背景,接下来进入核心讨论:
问题提出
直接切入主题,我们提出以下两个关键问题:
- 既然 JavaScript 是单线程模型,是否存在方法阻塞整个程序?即便使用了 libuv,主程序是否仍可能被阻塞?
- 如何有效地监测到程序发生了阻塞?
对于第一个问题,答案明确:是的。任何 I/O 密集型计算都会阻塞主进程;调用耗时的同步系统 API(例如同步读取大文件)同样会导致阻塞。
针对第二个问题,需要对 libuv 有基本认知(回想前文提到的那个大 while 循环)。既然 event loop 是一个循环,必然涉及迭代概念。联想到循环次数,解决方案便浮出水面:通过统计循环次数来检测阻塞。
方案
这里提出一种思路(并非空谈,将附带代码):假设在正常逻辑下,本机每秒能执行 100W 次事件循环。如果某段时间内,每秒循环数骤降至 50W,甚至更少(如个位数),这是否意味着程序某处发生了阻塞?显然,这种情况非常严重。因此,实时监控 event loop 至关重要。
监控方案与代码实现
第一版代码
// 环境准备
const http = require('http');
const path = require('path');
const {execFile, execFileSync} = require('child_process');
const max = 9999;
const getComputedValueFromChildProcess = (max) => execFileSync('node', [path.join(__dirname, './childprocess.js'), max]);
http.createServer((req, res) => {
const k = getComputedValueFromChildProcess(max);
res.write('origin-text: ' + k);
res.end();
}).listen(8888);
// 第一版实现
const MS_MULTI = 1000 * 1000;
const blockDelta = 10 * MS_MULTI;
let start;
function meature() {
start = process.hrtime();
setImmediate(function() {
let seconds;
[seconds, start] = process.hrtime(start);
if (seconds * 1000 * MS_MULTI + start > blockDelta) {
console.log(`node.eventloop_blocked for ${seconds}secs and ${(start / MS_MULTI).toFixed(2)}ms.`);
}
meature();
});
}
meature();
// childprocess.js 文件
#!/use/env node
const args = Number(process.argv[2]);
function computeIo(args) {
let k;
for (let i = 0; i < args; ++i) {
for (let j = 0; j < args; ++j) {
k = i + j;
}
}
return k;
}
console.log(computeIo(args));
设定一个大环境为 Web 服务器。我们选择 check 阶段作为起点(此处不使用 timer 阶段,因为 setTimeout 的最小超时时间为 1ms。在 event loop 空转时,1ms 可执行大量循环,本机数据约为 100K 次/ms)。应用启动时立即调用 measure 方法开始高频测试,旨在检测从一次 check 到下一次 check 的时间间隔是否超过 10ms:
# 没有请求前
# 等了很久出现一个15ms
➜ test node blocked.js
node.eventloop_blocked for 0secs and 15.71ms.
# 当我执行几次
curl http://localhost:8888
# 出现:
node.eventloop_blocked for 0secs and 175.60ms.
node.eventloop_blocked for 0secs and 149.92ms.
node.eventloop_blocked for 0secs and 147.25ms.
是的,基本雏形已现。基于这些数值可以进行数据上报和问题排查。但是!存在明显缺陷。
如果尝试上述示例,会发现电脑发烫,风扇狂转。
查看任务管理器,Node 进程的 CPU 占用率高达 100%!注释掉 measure 逻辑后,CPU 占用率恢复至 0% 左右。显然此版本不可行。我们需要修改,原因是不断执行 setImmediate 代码并添加回调,导致 CPU 持续满载运行。
第二版代码
引入采样概念:每 10 秒采样一次,且采样周期至少为 2 秒(为何至少 2 秒?因为 setTimeout 的超时定义本身也至少是 1ms,哈哈😏)。
const EVERY_SEC_MIN_LOOPS = 1000000; // 定义每秒最小循环数
let times = 0; // 一次采样中的循环数
let nowShowIncreaseTimes = false; // 当前是否应该增加 times
let start = Date.now();
const CD = 10 * 1000; // 间隔
function meature(callback = () => {}) {
setTimeout(function() {
start = Date.now();
nowShowIncreaseTimes = true;
_inter();
setTimeout(() => {
endMeature();
meature(); // 开始预约下次采样
}, 2000);
}, CD);
}
function _inter() {
setImmediate(() => {
if (nowShowIncreaseTimes) {
++times;
return _inter();
}
});
}
function endMeature() {
const now = Date.now();
nowShowIncreaseTimes = false;
const totalMsSpan = now - start;
const everySecLoops = (times / (totalMsSpan / 1000)).toFixed(0);
if (everySecLoops < EVERY_SEC_MIN_LOOPS) {
console.log(`当前每秒循环数${everySecLoops}`);
}
times = 0;
return everySecLoops
}
meature();
测试结果:
# 当我不断执行: curl http://localhost:8888 # 出现 ➜ test node blocked.js 当前每秒循环数777574 当前每秒循环数890565 # 当我们进行压力测试时: ab -c 10 -n 200 http://localhost:8888/
# 结果是这样的: ➜ test node blocked.js 当前每秒循环数843594 当前每秒循环数913329 当前每秒循环数2 当前每秒循环数2
修改为第二版后,电脑不再发烫,风扇停止转动。CPU 仅在采样时短暂上升至 30% 至 40%,表现良好。
同步与异步处理对比
但同时也暴露出一个问题:一秒仅执行 2 次循环!!此时程序基本处于停滞状态。为什么呢?
因为我们的请求处理是同步的!代码同步生成子进程,并等待子进程运行完毕后才返回结果。由此可见,在 Server 项目中启用耗时的同步操作,风险极大。
我们将同步操作改为异步处理试试:
// non-blocked.js
const max = 9999;
const getComputedValueFromChildProcess = (max) => new Promise((res, rej) => {
execFile('node', [path.join(__dirname, './childprocess.js'), max], (err, stdout) => {
const valueFromChildProcess = Number(stdout);
res(valueFromChildProcess);
});
});
http.createServer(async (req, res) => {
const k = await getComputedValueFromChildProcess(max);
res.write('origin-text: ' + k);
res.end();
}).listen(8888);
PS: 为了直观演示同步与异步的区别,本文使用了子进程方式。实际上,更优的方案是使用 worker_thread 或分片计算。让我们使用相同的 ab 工具进行测试,得到结果:
➜ test node non-blocked.js 当前每秒循环数239920 当前每秒循环数242286
可以看到,虽然比空转时的 100W 低了不少,但相对于同步方式,这个数量级有着天壤之别。
总结
总结
至此,大家对监控 event loop 应有了基本认识。原本计划封装 npm 包,但近期较为忙碌,只能先抛砖引玉,期待各位高手赐教。😬😬😬
好了,以上就是这篇文章的全部内容了,希望本文的内容对大家的学习或者工作具有一定的参考学习价值,谢谢大家对本站的支持。
crypto-js 处理 Base64 和 Hex 编码转换的正确姿势
2026-11-05 16:39:11
ammo.js 内存管理:避免 Wasm 环境下的内存泄漏技巧
2026-11-05 10:21:09
JS几种补零方法的性能对比与选择建议
2026-11-04 21:17:50
JS数组去重最佳实践:Set、filter与reduce实现方案
2026-11-04 10:57:15
基准元素选择策略对 JavaScript 快速排序性能的影响
2026-11-03 22:37:29








