Node.js 监控事件循环阻塞:原理与实现方案

作者:听澜

时间:2026-10-23

浏览:344

本文深入解析 Node.js 单线程模型下事件循环(Event Loop)的工作机制,探讨同步操作导致主进程阻塞的风险。通过基于 libuv 循环次数采样的两种代码方案,演示如何检测 CPU 占用过高及请求处理延迟问题,并对比同步与异步处理对性能的影响,提供实用的监控思路。

文章目录

开场白

Event Loop 基础与阻塞问题

近期在研究 libuv 库,并观察了 Node.js 中应用 libuv 的实际案例。本文不赘述已被广泛讨论的 event loop 基础概念,而是聚焦于如何正确使用 event loop,以及如何检测程序是否出现异常阻塞。

基础

相信读者对 event loop 的基本原理已有所掌握。此处引用官方图示进行简要回顾,作为后续内容的基础:

Event Loop 基础与阻塞问题

event loop 是 Node.js 实现单线程异步的核心机制。简而言之,它在一个持续的 while 循环中遍历各个阶段(phases),并执行相应的回调函数。这种机制实现了真正的异步调用:调用时无需等待响应,只需在资源就绪后触发回调即可。 以上是基础背景,接下来进入核心讨论:

问题提出

直接切入主题,我们提出以下两个关键问题:

  1. 既然 JavaScript 是单线程模型,是否存在方法阻塞整个程序?即便使用了 libuv,主程序是否仍可能被阻塞?
  2. 如何有效地监测到程序发生了阻塞?

对于第一个问题,答案明确:是的。任何 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 包,但近期较为忙碌,只能先抛砖引玉,期待各位高手赐教。😬😬😬

好了,以上就是这篇文章的全部内容了,希望本文的内容对大家的学习或者工作具有一定的参考学习价值,谢谢大家对本站的支持。

实用工具推荐

电子包浆

绿化、压缩、噪点和虚拟水印,自由调节做旧程度。

使用 ›
图片压缩

选择画质和输出格式,预览压缩前后的大小。

使用 ›
相关文章