process 事件、Signal 与优雅退出
本章解决的问题
beforeExit、exit、SIGINT、SIGTERM分别何时发生?- 为什么
exit事件不能做异步清理? - HTTP 服务如何停止接收新请求、等待在途工作并设置兜底超时?
工作原理
Signal 是操作系统通知进程控制事件的一种机制。服务最常处理:
SIGINT 终端 Ctrl+C 常见
SIGTERM systemd、PM2、Docker、Kubernetes 请求优雅终止时常见
SIGKILL 立即终止,应用无法捕获、阻止或清理
“收到终止信号”不等于“立即调用 process.exit()”。优雅停机是有上限、可重复调用的状态转换:
RUNNING
→ 收到 SIGTERM/SIGINT
SHUTTING_DOWN
→ 停接新请求
→ 等待在途请求和子进程
→ 关闭数据库、Redis、日志
EXITING
安装 SIGINT/SIGTERM 监听器后,不能再依赖原来的默认退出行为;处理器必须让资源最终关闭,使 Event Loop 清空,或在兜底条件下明确退出。
高频事件与准确边界
beforeExit
process.on("beforeExit", (code: number) => {
console.log({ code });
});
Event Loop 即将没有工作时触发。监听器安排新的异步工作可使进程继续运行,之后还可能再次触发。显式 process.exit() 或某些致命终止路径不会触发它,因此不能作为通用关机钩子。
exit
process.on("exit", (code: number) => {
// 此处只能同步收尾。
console.error(`进程退出:${code}`);
});
此时 Event Loop 已不能继续运行。定时器、Promise、网络请求和异步关闭都不会被等待:
process.on("exit", () => {
setTimeout(() => {
// 不会执行。
}, 0);
});
warning
process.on("warning", (warning: Error) => {
process.stderr.write(`${warning.name}: ${warning.message}\n`);
});
可记录 Node.js 警告,如监听器过多。记录不等于解决根因,也不要因普通 warning 自动终止生产服务。
uncaughtException 与 unhandledRejection
它们是最后诊断边界,不是普通业务错误处理机制:
process.on("uncaughtExceptionMonitor", (error: Error, origin: string) => {
process.stderr.write(`致命异常(${origin}):${error.stack ?? error.message}\n`);
});
uncaughtExceptionMonitor 可同步诊断且不改变默认崩溃行为。未捕获异常后,应用状态可能已不可靠,不应记录后继续提供流量。Node.js 22 默认设置下,未处理 Promise 拒绝通常会升级为未捕获异常;启动参数可改变行为,所以业务 Promise 仍应在边界显式 .catch()。
若注册 uncaughtException,也只应做最小同步清理并尽快由进程管理器重启,不能把它当成“防崩溃”。
具体代码:HTTP 服务优雅停机
以下用 node:http 展示机制;Express app.listen() 返回的也是 http.Server,关闭思路相同。
import {
createServer,
type IncomingMessage,
type Server,
type ServerResponse,
} from "node:http";
const port: number = 8080;
const shutdownGraceMs: number = 10_000;
let shuttingDown: boolean = false;
let activeRequests: number = 0;
const server: Server = createServer(
(request: IncomingMessage, response: ServerResponse) => {
if (shuttingDown) {
response.setHeader("Connection", "close");
response.writeHead(503, { "Content-Type": "application/json" });
response.end('{"error":"server_shutting_down"}');
return;
}
activeRequests += 1;
let counted: boolean = true;
const completeRequest = (): void => {
if (!counted) {
return;
}
counted = false;
activeRequests -= 1;
};
// finish 表示响应正常交给底层;close 也覆盖客户端中断。
response.once("finish", completeRequest);
response.once("close", completeRequest);
response.writeHead(200, { "Content-Type": "application/json" });
response.end(JSON.stringify({ pid: process.pid }));
},
);
server.on("error", (error: NodeJS.ErrnoException) => {
process.stderr.write(`Server 错误:${error.message}\n`);
process.exitCode = 1;
});
server.listen(port, () => {
process.stdout.write(`Server pid=${process.pid} listening=${port}\n`);
});
async function shutdown(signal: NodeJS.Signals): Promise<void> {
if (shuttingDown) {
process.stderr.write(`忽略重复的 ${signal}\n`);
return;
}
shuttingDown = true;
process.stderr.write(
`收到 ${signal},开始停机,activeRequests=${activeRequests}\n`,
);
// 强制退出会截断异步输出,只作为最后手段。
const forceExitTimer: NodeJS.Timeout = setTimeout(() => {
process.stderr.write("优雅停机超时,强制退出\n");
process.exit(1);
}, shutdownGraceMs);
forceExitTimer.unref();
try {
await new Promise<void>((resolve, reject) => {
server.close((error?: Error) => {
if (error) {
reject(error);
return;
}
resolve();
});
});
// 此处继续 await:停领任务、等待或取消子进程、关闭 DB/Redis。
process.exitCode = 0;
process.stderr.write("资源已关闭,等待自然退出\n");
} catch (error: unknown) {
const message: string =
error instanceof Error ? error.message : "未知关闭错误";
process.stderr.write(`关闭失败:${message}\n`);
process.exitCode = 1;
} finally {
clearTimeout(forceExitTimer);
}
}
function requestShutdown(signal: NodeJS.Signals): void {
void shutdown(signal).catch((error: unknown) => {
const message: string =
error instanceof Error ? error.message : "未知停机错误";
process.stderr.write(`停机流程异常:${message}\n`);
process.exitCode = 1;
});
}
process.once("SIGINT", () => requestShutdown("SIGINT"));
process.once("SIGTERM", () => requestShutdown("SIGTERM"));
server.close() 停止接受新连接并等待连接收尾,但任意长请求仍可能卡住,因此需要总期限。Node.js 22 中还可按策略使用 closeIdleConnections(),或在宽限期最后使用 closeAllConnections();后者会丢请求,只能最后使用。
推荐关闭顺序
1. readiness 标记失败,让负载均衡器摘流
2. server.close() 停止新连接
3. 停止领取后台任务
4. 等待或取消请求与 child_process
5. 关闭数据库、Redis、队列、日志传输
6. 设置 exitCode,等待自然退出
7. 超过总期限才强制退出
正常时间线
SIGTERM → shutdown 首次进入 → shuttingDown=true
→ server.close → 在途请求完成 → 其他资源关闭
→ exitCode=0 → Event Loop 清空 → exit 0
异常时间线
关闭失败:
SIGTERM → 数据库 close 拒绝 → 记录 stderr → exitCode=1
→ 继续必要收尾 → Event Loop 清空 → exit 1
关闭卡死:
SIGTERM → 长请求一直不结束 → 达到 grace period
→ 记录诊断 → process.exit(1) → 剩余异步工作可能被截断
重复信号:
第一次 SIGTERM 启动关闭 → 第二次通知到来
→ 幂等保护忽略或只记录 → 不重复关闭资源
示例用 once 使同一种信号只触发一次,但不同信号仍可能依次触发,因此 shuttingDown 幂等保护仍必要。
Cluster 下的职责
每个 Cluster Worker 都是独立进程,各自关闭自己的 HTTP Server、连接和子进程。Primary 负责停止派生 Worker、通知 Worker 退出、等待并设置整体期限。
Primary:协调 Worker 生命周期,停机期间不再重启退出的 Worker
Worker:停接自身请求,清理自身连接与任务
每个 Worker 各注册一次监听器,并不是同一 EventEmitter 上重复监听;真正风险是外部副作用发生多次。同一进程的初始化函数被多次调用,才可能形成重复监听和 MaxListenersExceededWarning。生命周期模块应只初始化一次。
Windows 与 Linux
- Linux/macOS 终端 Ctrl+C 通常产生
SIGINT;服务管理器常发送SIGTERM。 - Windows 对 POSIX Signal 支持有限,
SIGTERM不能假设与 Linux 完全等价;taskkill的不同参数可能不给异步清理机会。 - Node.js 不允许为
SIGKILL、SIGSTOP安装监听器。 - IDE 停止按钮、PM2、Docker、Kubernetes 的具体信号和等待期限不同,要在真实环境演练。
- 容器 PID 1 和 Shell 包装层可能影响信号转发。入口尽量直接运行 Node.js,或使用能正确转发信号的 init/wrapper。
常见错误
- 在
exit事件中异步关闭数据库。 - 收到 SIGTERM 后立刻
process.exit(0),丢弃在途请求。 - 注册 Signal 监听器后不关闭 Server,进程永远不退出。
- 多个模块分别监听 SIGTERM,重复关闭同一资源。
- 把
uncaughtException当恢复点,捕获后继续服务。 - 没有总期限,部署永远卡住;或期限短于正常最长请求。
- Primary 停机时仍自动重启退出的 Cluster Worker。
最佳实践
- 生命周期处理集中在应用入口或专用协调器,并保证幂等。
- 业务 Promise 在本地边界处理错误,不依赖全局兜底。
- 先摘流和停接新工作,再等待/取消已有工作,最后关闭共享连接。
- 正常停机设置
exitCode并自然结束;强制exit()只做超时兜底。 - 记录
signal、pid、在途任务数、阶段耗时和退出原因。 - PM2/systemd/容器的停机宽限期要长于应用内部宽限期。
练习题
- 启动示例后按 Ctrl+C,记录事件时间线和退出码。
- 添加一个 5 秒请求,请求执行中发送 SIGTERM,验证它是否完成。
- 模拟数据库关闭失败,确认退出码为 1。
- 让 shutdown 被调用两次,验证资源只关闭一次。
- 比较
process.exit(1)与只设置exitCode=1对异步日志的影响。
验收点
- [ ] 能区分
beforeExit、exit和 Signal 事件。 - [ ] 知道
exit监听器不能完成异步清理。 - [ ] 能写出幂等、有超时的优雅停机流程。
- [ ] 未捕获异常后不会盲目继续提供服务。
- [ ] 能说明 Primary 与 Cluster Worker 各自的关闭职责。