PHP 中的 SIGTERM 处理:优雅关闭进程的完整指南
目录导读
- 为什么需要处理 SIGTERM? —— 理解信号与进程生命周期
- PHP 信号处理基础 ——
pcntl_signal与pcntl_async_signals - 实战:捕获 SIGTERM 并优雅退出 —— 代码示例与详解
- 陷阱与最佳实践 —— 避免僵尸进程、处理阻塞操作
- 常见问题问答(FAQ) —— 解决你心中最后的疑惑
为什么需要处理 SIGTERM?
在长驻内存的 PHP 脚本(如队列消费者、WebSocket 服务器、定时任务守护进程)中,SIGTERM(信号值 15)是系统通知进程“请准备退出”的默认信号,它不同于 SIGKILL(信号值 9)——后者会被内核立即强制终止,进程无法进行任何清理工作,而 SIGTERM 允许我们执行收尾操作:关闭数据库连接、提交未完成的事务、保存临时状态、释放锁等。

如果不捕获 SIGTERM,PHP 默认行为是直接终止脚本,导致:
- 数据丢失(如未写满的日志缓冲区)
- 资源未释放(导致端口占用、文件锁未解锁)
- 产生孤儿进程(子进程未被回收)
在需要高可靠性的服务中,处理 SIGTERM 是必不可少的。
PHP 信号处理基础
PHP 主要依赖 pcntl 扩展(POSIX Control)来处理信号,两个核心函数:
pcntl_signal(int $signo, callable $handler, bool $restart_syscalls = true):注册信号处理器。pcntl_async_signals(bool $enable):开启异步信号分发(PHP 7.1+ 推荐),默认情况下,PHP 需要等待脚本执行完一行代码后才会检查信号(即“同步派发”),而开启异步后可实现即时响应。
关键点:信号处理函数必须尽快执行,避免复杂逻辑(如 HTTP 请求、数据库查询),否则会阻塞主循环。
实战:捕获 SIGTERM 并优雅退出
以下是一个典型的队列消费者模型,通过 while 循环持续处理任务,并响应 SIGTERM 信号退出:
<?php
declare(ticks=1); // 可选,若未开启异步信号则必须使用
// 设置信号处理器
pcntl_async_signals(true); // 开启异步信号分发(PHP 7.1+)
$shouldStop = false;
pcntl_signal(SIGTERM, function () use (&$shouldStop) {
$shouldStop = true;
// 还可以在此记录日志、通知主进程等
fwrite(STDOUT, "收到 SIGTERM,准备退出...\n");
});
// 模拟工作循环
while ($shouldStop === false) {
// 模拟从队列获取任务(如 Redis / Beanstalk)
$job = getJob();
if ($job !== null) {
processJob($job); // 可能耗时 1-2 秒
} else {
// 无任务时短暂休眠,避免 CPU 空转
usleep(500000); // 0.5 秒
}
}
// 收尾工作
cleanup();
exit(0); // 返回 0 表示正常退出
代码说明:
pcntl_async_signals(true)确保信号在任意时刻(如正在执行usleep时)能够被立即捕获,而非必须等待当前代码块执行完。- 信号处理器仅将
$shouldStop置为true,主循环下一次检查时优雅退出,这种方式不会中断正在处理的$job,保证任务完整性。 - 若某些任务特别耗时,可在循环内部加入
pcntl_signals_dispatch()手动派发信号(不推荐与异步模式混用)。
陷阱与最佳实践
陷阱 1:阻塞系统调用无法中断
若脚本正在执行 sleep() 或 file_get_contents() 等阻塞调用,信号可能无法立刻触发(即使开启异步),解决方法是使用更短的“分片”睡眠(如 usleep(100000) 循环),或者通过 stream_select 对信号文件描述符监控。
陷阱 2:多次收到 SIGTERM
如果在第一次收到信号后,清理过程又收到第二个(如用户不耐烦地再次 kill),默认信号处理会覆盖,导致强制退出,最佳实践是:在第一次收到信号后,将后续信号的 handler 设置为 SIG_DFL(即默认终止),防止清理被二次打断:
pcntl_signal(SIGTERM, function () use (&$shouldStop) {
$shouldStop = true;
pcntl_signal(SIGTERM, SIG_DFL); // 第二次信号直接杀死进程
});
陷阱 3:未处理 SIGINT(Ctrl+C)
开发调试时通常发送 SIGINT(信号值 2),建议同时捕获 SIGINT 并做相同处理,便于本地测试。
最佳实践清单:
- 始终在脚本开头设置
error_reporting(E_ALL)并输出到日志文件,便于跟踪退出原因。 - 在
cleanup()中关闭所有资源(PDO、Redis连接、文件句柄)。 - 利用
register_shutdown_function兜底清理(但它不保证在信号处理时调用,需配合)。 - 对于大型框架(Laravel、Symfony),可参考它们的
Queue:listen如何封装信号处理。
常见问题问答(FAQ)
Q1:declare(ticks=1) 和 pcntl_async_signals(true) 有什么区别?
declare(ticks=1) 是 PHP 5.3 时代的旧方法,要求每执行 N 条语句就检查一次信号,性能开销大。pcntl_async_signals(true)(PHP 7.1+)由系统在信号到达时异步调用处理函数,效率更高,推荐后者。
Q2:如果我不捕获 SIGTERM 会怎样?
PHP 脚本直接被内核终止,不会执行 __destruct 或 register_shutdown_function,所有未关闭的连接由内核在进程销毁后回收,但无法执行自定义清理逻辑。
Q3:在 Windows 下能用这些函数吗?
不能。pcntl 扩展仅适用于类 Unix 系统(Linux、macOS、FreeBSD),Windows 下可考虑使用 sapi_windows_set_ctrl_handler 处理 CTRL_C_EVENT,但架构差异较大。
Q4:我可以在信号处理器中调用 exit() 吗?
可以,但不推荐,直接 exit 会绕过主循环后的收尾代码,导致部分资源未释放,应当设置标志位,让主循环自然退出。
Q5:如何处理多个子进程?
对于 pcntl_fork 出来的子进程,父进程需要持续 pcntl_waitpid 回收,在收到 SIGTERM 时,父进程应先向子进程发送同样的信号,再等待其退出,最后自行退出,可参考 supervisord 的进程组管理策略。
处理 SIGTERM 是编写健壮 PHP 常驻脚本的“分水岭”,通过pcntl_async_signals + 标志位 + 干净收尾,你可以确保任务不丢、资源不泄漏,在实际生产环境中,建议配合进程管理器(如 Supervisor、systemd)的 KillSignal 配置,实现无缝重启与部署。
(完)