← 中文资源

简体中文版本

Node.js — 进程迟迟不退出:有东西占着事件循环

找出 Node.js 脚本完成工作后进程仍存活的原因,枚举活跃句柄与 ref 状态,并对正确的资源做 unref 或关闭。

阅读英文原文

症状

  脚本完成工作、打印最后一行,然后什么都没有——shell 永不返回。CI 步骤在真实工作完成后仍被记为卡死,docker run 挂着不退出,也没有任何错误日志。Node 只在事件循环排空时退出,所以直接问循环它还持有什么:在“工作完成”之后打印 process._getActiveHandles(),每个句柄类型都是一份自白——Timeout 意味着未清理的 interval,Socket 通常属于数据库连接池或 keep-alive agent,ChildProcess 意味着未等待的 spawn。把句柄映射回创建点,生命周期缺口就具体化了。

诊断思路

  先在收尾处枚举活跃句柄与活跃请求;再按类型定位所有者:Timers 对应 setInterval、Socket 对应池与 agent、ChildProcess 对应子进程残留;最后 grep 创建点找没有对应关闭点的资源——createPool、createClient、subscribe、setInterval 出现却找不到 end/clear/unref 的地方就是缺口。

根因

  Node 在事件循环无事可做时退出。仍被引用的句柄——连接池、未清理的定时器、运行中的子进程——都算“有事可做”,于是脚本工作早已完成,进程却一直活着。

修复与验证

  在 main 结束时按依赖顺序显式收尾:停掉消费者、pool.end() 关闭池化套接字、redis.quit()、clearInterval、await worker.terminate(),再让退出码自然传播。必须存在但不应决定生命周期的句柄(心跳、指标上报)用 unref 反转与循环的关系:循环排空时忽略它们。绝不要 unref 一个还有真实工作在途的句柄——写到一半的 socket 被 unref,进程退出时载荷就被截断了。规则是:存活信号可以 unref,在途工作必须 await。验证时用 timeout 10 node job.js 做墙钟护栏,断言远在时限内返回;在退出路径打印句柄列表要求为空(或只含已文档化的 unref 存活句柄);最后验证退出码传播——失败的运行必须非零退出,否则修挂起却吞错误码,等于把可见故障换成静默故障。

代码示例

import { inspect } from 'node:util';

process.on('exit', () => {});
setTimeout(() => {
  console.log('work done');
  console.log(inspect(process._getActiveHandles(), { depth: 1 }));
  // [Timeout, Socket, Socket, ChildProcess]
}, 100);
async function main() {
  await runJob();
  await consumer.stop();
  await pool.end();       // closes pooled sockets
  await redis.quit();
  clearInterval(metricsTimer);
  await worker.terminate();
}

main()
  .then(() => process.exitCode = 0)
  .catch((err) => { console.error(err); process.exitCode = 1; });
const heartbeat = setInterval(ping, 30_000);
heartbeat.unref(); // loop ignores it when otherwise empty