← 中文资源

简体中文版本

Node.js — Worker 线程以退出码 13 结束(未完成的工作)

解读 worker_threads 的退出码 13,把它与崩溃及未捕获异常区分开,并用显式完成信号实现 worker 干净退出。

阅读英文原文

症状

退出码 13 到来时不附带任何 error 对象,这是“没有东西崩溃”的第一线索:worker 7 terminated with exit code: 13。把时间线与缩容或进程停机对齐,若吻合,说明是父线程提前切断了 worker;若是自发生,说明 worker 的事件循环在还有排队任务时就排空了——句柄被 unref 或通道关闭都会造成这种“看起来空闲其实有活”。让 worker 定期上报队列深度,最后一条 stats 消息里 queued > 0 后紧跟 exit 13,就是任务被丢弃的凭证。

诊断思路

<arg_value>父线程侧记录 exit code 并区分错误类别:13 且无 error 事件是“终止时有未完成工作”;1 或 7 伴随 uncaught exception 是另一类故障。再全局 grep terminate 与 destroy 的调用点,每一处都可能是提前切断队列的嫌疑人。最后向 worker 发 stats 消息取回队列深度与活跃句柄,终止时刻深度非零即证明任务是“被丢弃”而非“已完成”。

根因

退出码 13 是 Node 为“未完成工作”设置的哨兵值:worker 进程在仍有任务待处理时结束,要么是父线程提前 terminate,要么是 worker 事件循环带着排队工作排空。父线程收到退出码却收不到错误,因为在运行时看来确实什么都没有失败——这正是它隐蔽的原因。

修复与验证

  让完成变成显式协议。worker 侧循环处理队列,每完成一个任务回发 ack,队列清空后发送 drained 消息,不再依赖事件循环排空来暗示结束;父线程侧把停机当协议执行:发送 shutdown 信号(停止接新任务、跑完现有队列),等待 drained 消息或超时兜底,最后才 terminate。验证时在负载中途触发停机:退出码 13 必须为零;每个已派发的任务 id 在 ack 与重入队日志中恰好出现一次;每个 worker 退出前都发过 drained。再用一个故意放慢的 worker 重复实验,验证超时路径会重入队而不是悄悄丢掉队尾任务。重试要有幂等保护——先持久化任务状态再执行,否则盲目重试会放大副作用。

代码示例

worker 7 terminated with exit code: 13
setInterval(() => {
  parentPort.postMessage({ type: 'stats', queued: taskQueue.length });
}, 1000);
// worker.js
async function processQueue() {
  while (taskQueue.length) {
    await run(taskQueue.shift());
    parentPort.postMessage({ type: 'ack', id: lastId });
  }
  parentPort.postMessage({ type: 'drained' });
}
async function stopWorker(worker) {
  const drained = once(worker, 'message', (m) => m.type === 'drained');
  worker.postMessage({ type: 'shutdown' }); // stop accepting, finish queue
  await Promise.race([drained, delay(5000)]);
  await worker.terminate(); // now safe, and no code 13 work remains
}