PHP-FPM 的生死时速:当系统向 PHP 发出“分手信”时,Master 和 Worker 都在干什么?
大家好,我是你们的老朋友,一个在 PHP 深渊里摸爬滚打多年的资深“搬砖工”。
今天我们不聊怎么造轮子,也不聊怎么优化 ORM,我们来聊聊一个稍微有点“重口味”的话题——当服务器要重启了,或者管理员按下 Ctrl+C 的时候,你的 PHP-FPM 到底在干什么?
你可能会说:“这有啥好聊的?不就是进程挂了重启吗?” 错!大错特错!如果 PHP-FPM 只是简单的“一杀解千愁”,那你的网站在高峰期重启时,一定会出现几十秒的“白屏”或者 502 错误,用户体验直接拉胯。
要理解这个优雅的过程,我们必须扒开 PHP-FPM 的底层裤衩,看看它的 Master/Worker 模型在面对系统中断(也就是俗称的“信号”)时,那套复杂的防御机制。这不仅是面试题,更是救命的救命稻草。
第一幕:指挥官与苦力——Master 与 Worker 的双重奏
在深入信号处理之前,咱们得先认识一下这两位主角。
想象一下,你开了一家快餐店。
- Master 进程 是店长(老板)。他不动手炒菜,他只负责管钱、招人、管库存。当老板发现店要倒闭或者要搬家时,他得告诉员工怎么离开。
- Worker 进程 是大厨(苦力)。他们一个个守在灶台前。当你点单时,一个空闲的大厨会接单;如果大厨在炒菜,他就不能接新单。
PHP-FPM 的运行机制完全一致。Master 进程只有一个,负责管理;Worker 进程有多个(比如 50 个),负责处理请求。
在 Linux 的世界里,进程之间不说话,他们用 信号 交流。信号就像是发短信、打电话或者敲门。
第二幕:暴力的 SIGTERM 与 残酷的 SIGINT
如果 Master 进程是个暴躁的暴君,当系统资源不足时,他可能会直接冲进后厨,对每一个 Worker 进程喊一声 “SIGTERM”。
收到这个信号,Worker 进程会立马收到通知。在大多数简单的程序里,这就像是听到了防空警报,立马扔下铲子,不关火,直接关门走人。
后果是什么? 满锅的菜焦了,锅铲丢了,客户气得骂娘。这就是所谓的“硬重启”。
还有个更狠的叫 SIGINT(通常是 Ctrl + C 触发)。这就像老板把大厨赶出店门,还不允许大厨拿走工资。
PHP-FPM 的设计哲学是:绝不暴政,优雅退场。
所以,PHP-FPM 并不直接用 SIGTERM 来停机。它用的是 SIGQUIT(优雅停机信号)和 SIGUSR1(重载配置信号)。
第三幕:优雅的“Wait,让我把饭端完”——Master 进程的保护机制
当系统管理员执行 kill -QUIT 或者 systemctl reload php-fpm 时,Master 进程会接收到信号。
Master 进程不会马上去杀 Worker。它会怎么做?它会先给自己加把锁,然后进入一个漫长的、甚至可以说是“慈祥”的循环。
让我们用伪代码来看看 Master 的内心戏:
// PHP-FPM Master 进程的信号处理逻辑(简化版)
void handle_sigquit(int signo) {
// 1. 设置全局标志位,告诉 Worker 们“我要停了”
g_should_exit = 1;
// 2. 向所有 Worker 发送 SIGQUIT
for (i = 0; i < worker_count; i++) {
kill(worker[i]->pid, SIGQUIT);
}
}
void master_loop() {
for (;;) {
int status;
pid_t child = waitpid(-1, &status, WNOHANG); // 等待子进程结束,但不阻塞
if (g_should_exit) {
// 3. 如果 Master 自己也被杀,那就别等了,大家一起挂
if (child == -1 && errno == ECHILD) {
exit(0);
}
continue;
}
// 4. 这里有个关键点:检查是否有 Worker 突然挂了(僵尸进程处理)
// 如果有 Worker 挂了,Master 马上启动一个新的 Worker 补位
if (child > 0) {
spawn_worker();
}
// 5. 如果所有 Worker 都空闲了,Master 睡觉
if (all_workers_idle()) {
sleep(1);
}
}
}
这里的保护机制是什么?
- 不完全关闭(Decoupling): Master 只发信号,不发 Kill。它让 Worker 自己决定什么时候停。
- 重生机制: 你看上面的
spawn_worker()。即使你在停机过程中有一个 Worker 卡住了,Master 也会立刻重启一个新的。这意味着你的服务 可用性(Availability) 在重启期间是不降级的。 - 互斥锁: 在重启配置或优雅停机时,Master 会阻止新的请求进来,直到当前所有的请求都处理完。
第四幕:大厨的挣扎——Worker 进程的内部状态机
现在焦点转移到了 Worker 身上。Master 说:“老王,你可以走了。” Worker 老王收到 SIGQUIT。
老王在干什么?他在炒菜!
- 状态 A:空闲 -> 老王立马关火,擦擦汗,拿起包袱就走。
- 状态 B:忙碌 -> 老王得先把锅里的菜端给顾客,把账本记完,把火关掉。这一过程叫“Graceful Shutdown”(优雅停机)。
对于 PHP 脚本来说,Graceful Shutdown 对应的是 PHP 的 php_request_shutdown 钩子。在这个钩子函数里,通常会执行数据库断开连接、关闭文件句柄等清理工作。
但是,这里有个巨大的坑!PHP 是同步语言。 当一个 PHP 脚本正在执行一个 sleep(100) 或者正在读取一个巨大的文件时,Master 发送的信号能打断它吗?
答案是:能,但有条件。
1. 阻塞系统的保护机制
在 C 层面,当一个 Worker 执行 accept() 接收连接,或者 read() 等待数据,或者 socket_write() 发送数据时,这些系统调用是阻塞的。
当 Master 发送信号时,操作系统会强制打断这些系统调用,让 Worker 进程从内核态返回到用户态。然后,PHP 解释器会捕获到信号(通过 zend_signal_handler),并执行注册的信号处理函数。
关键代码逻辑(伪代码):
// 在 PHP-FPM 的源码逻辑中(简化)
void php_request_shutdown() {
// 1. 关闭连接,告诉 Nginx "我结束了"
fcgi_finish_request();
// 2. 执行用户定义的 register_shutdown_function
execute_shutdown_functions();
// 3. 关闭资源
close_all_resources();
}
这里有一个隐形的保护机制:fastcgi_finish_request()
这是 PHP 里最强大的函数之一。如果你在脚本里调用了 fastcgi_finish_request(),意味着你提前告诉了 Web 服务器(Nginx/Apache):“响应头已经发完了,你可以断开 TCP 连接了。”
场景演示:
<?php
// 这是一个极其耗时且耗内存的脚本
echo "Start processing...n";
ob_flush();
flush();
// 模拟耗时操作
for ($i = 0; $i < 1000000; $i++) {
// 做点无意义的事
$a = $i * 2;
}
// 此时,Master 发送了 SIGQUIT,脚本还在循环里跑
// 但是!如果我们在循环开始前调用了 fastcgi_finish_request()
// 那么这里的代码继续跑,但用户已经看不到输出了!
echo "Script continues but user might not see this if connection closed.n";
// 这里的代码执行不会受信号影响,因为“任务”已经移交给 Nginx 处理了
// 就像你去银行办业务,填完表扔进窗口,你就可以回家了,柜员还在慢吞吞盖戳
?>
2. register_shutdown_function 的生死状
如果脚本没有调用 fastcgi_finish_request(),那么当信号到来时,Worker 进程会强行中断当前的执行流,跳转到 register_shutdown_function 定义的回调函数。
保护机制:
PHP-FPM 不会允许 shutdown function 再次被信号打断。一旦进入 shutdown function,系统会强制执行完它,直到脚本结束,Worker 进程才会退出。这防止了资源泄漏。
3. 信号量与互斥锁
这是最高级的保护机制。当 Master 重启时,它不希望 Worker 还在占用宝贵的数据库连接。
Worker 进程在启动时(php_module_startup)会去抢数据库连接。在 shutdown 时,Worker 进程需要尝试释放这些连接。
如果 Worker 正在执行 SQL 查询(比如 mysql_query),数据库服务器可能会抛出一个警告,但连接通常会保持打开。PHP-FPM 在 shutdown 阶段会显式调用 mysql_close。如果 MySQL 连接已经死掉,Worker 不会崩溃,而是会记录一个错误日志,然后断开。
第五幕:实战演练——当我们按下 Ctrl+C
为了让你彻底明白,我们来做一个实验。
步骤一:准备“作死”脚本
创建一个文件 die.php:
<?php
// 开启显式输出
ob_implicit_flush(1);
echo "I am a stubborn worker.n";
flush();
// 开启一个不会自动结束的循环
// 模拟处理一个需要 30 秒的复杂计算
for ($i = 0; $i < 30; $i++) {
echo "Working... $i/30r"; // r 回到行首,不换行
flush();
// 模拟工作
usleep(1000000); // 1秒
// 模拟正在持有锁(比如正在写文件)
file_put_contents("/tmp/php_fpm_test.log", "Working... $in", FILE_APPEND);
}
echo "nJob done!n";
?>
步骤二:启动 Nginx 和 PHP-FPM
模拟一个正在处理这个请求的场景。
步骤三:发送“分手信”
打开第二个终端,查看进程 ID:
ps aux | grep php-fpm
假设你的 Worker 进程 ID 是 12345。
现在,发送优雅停机信号:
kill -QUIT 12345
步骤四:观察结果
现象 A(没有调用 fastcgi_finish_request):
终端会立刻显示 I am a stubborn worker.,然后开始疯狂刷屏 Working... 0/30, 1/30…
Master 的日志里会显示 Worker 进程正在退出。
30 秒后,Worker 真的退出了。Nginx 会报 502 Bad Gateway。
为什么? 因为在 PHP 看来,这个脚本还没跑完。虽然 Master 发了信号,但 PHP 解释器正在忙着算数呢!Master 只能干等着它自己算完。
现象 B(调用了 fastcgi_finish_request):
修改脚本,在循环前加上 fastcgi_finish_request();。
现在,当你按下 kill -QUIT 时,PHP 会立刻切断与浏览器的连接。
Nginx 会立刻返回 200 OK。
但是,你在 /tmp/php_fpm_test.log 文件里会发现,脚本里的循环还在继续跑!日志还在不断追加!
Worker 进程依然存活,直到它算完这 30 秒的任务才退出。
这叫什么?这叫“断开连接,任务继续”。
第六幕:更深层的保护——资源泄漏与僵尸进程
Master 进程在重启时会非常小心,它害怕的是“僵尸进程”。
在 Linux 中,当一个子进程(Worker)结束,而父进程(Master)没有调用 wait 来回收它的尸体,这个进程就会变成僵尸。
Master 的保护机制代码(sapi/fpm/fpm/fpm_main.c):
while (1) {
// 1. 尝试回收所有已退出的 Worker
// WNOHANG 参数表示:如果没有子进程退出,立刻返回 0,不阻塞
pid_t reaped = waitpid(-1, &status, WNOHANG);
if (reaped > 0) {
// 找到一个退出的 Worker
// 标记它为 "dying" 或者直接从数组中移除
worker_pool->remove_worker(reaped);
// 立刻补充一个新的 Worker!
// 保证总数不变,服务不中断
fpm_spawn_request();
}
// 2. 检查是否有 Worker 被信号杀死了但没有被正常回收
if (kill(0, 0) == -1 && errno == ESRCH) {
// 进程不存在了
// ... 处理逻辑
}
// 3. 如果所有人都闲着,Master 才去睡觉,防止空转浪费 CPU
if (worker_pool->count == 0) {
sleep(1);
}
}
这就是为什么你重启 PHP-FPM 时,流量不会中断的原因! Master 就像一个不知疲倦的清道夫,清理完一个,立马招一个新的。
第七幕:关于 max_execution_time 的那个“老梗”
面试中经常有人问:“如果脚本执行时间超过了 max_execution_time,Master 发送信号会断开吗?”
答案是:不会直接断开。
max_execution_time 仅仅是一个软限制。PHP 解释器会每秒检查一次这个时间。如果超时了,PHP 会抛出 E_ERROR,脚本会立即终止。
但是!
如果信号是在这 30 秒间隔之间到达的呢?
PHP 的信号处理机制依赖于系统调用。如果一个脚本卡在 sleep(30) 或者 socket_read() 中,信号可以打断它。
如果脚本卡在 while(true) 循环里没有任何系统调用(纯粹是 CPU 计算),信号是无法打断的,除非使用 pcntl_alarm() 或者 PHP 的 while(time() < $limit) 手动检查。
总结一下这个坑:
- 网络请求卡住:信号可以打断,脚本可以优雅退出。
- 数据库查询卡住:信号可以打断,脚本可以优雅退出。
- 纯 CPU 死循环:信号可能打不断,Master 只能干瞪眼直到脚本跑完或超时。
第八幕:Master 的“便秘”时刻——SIGHUP 重载配置
除了停机,还有一种常见的中断叫 SIGHUP。通常用来重载配置文件(比如修改了 php-fpm.conf 里的 pm.max_children)。
这时候 Master 的处理逻辑极其复杂且谨慎:
- 不重启进程: Master 不会杀掉现有的 Worker。
- 信号广播: Master 向所有 Worker 发送
SIGUSR1。 - Worker 的动作:
- Worker 收到信号,继续处理完当前的请求。
- 请求结束后,Worker 不会退出。
- Worker 读取新的配置(比如
pm.max_children变大了)。 - Worker 重新检查自己的状态,看自己是否还需要留在池子里。
这里有个保护机制:
如果新的配置导致 Worker 数量超过了限制,或者配置参数错误,PHP-FPM 会在重启时拒绝加载新配置,或者拒绝新请求,直到配置文件修正。Master 会报错,但不会把现有的 Worker 骂死。
结语:敬畏每一个信号
好了,讲了这么多,我们到底学到了什么?
PHP-FPM 的 Master/Worker 模型在处理中断时,其实是一场精心编排的舞蹈。
- Master 是个老狐狸: 它不乱杀,它只发信号,它时刻盯着 Worker 的尸体,有人死了立马补位,保证你的网站对用户来说永远是 200 OK。
- Worker 是个守规矩的员工: 遇到信号,它先把活干完。如果有
fastcgi_finish_request(),它就把尾巴(HTTP 响应)留给你,自己先溜(断开连接);如果没有,它就老老实实把活干完再走。 - 系统调用是护身符: 只有在系统调用里,信号才能生效。如果你的代码写成了纯 CPU 忙等待,信号就是一张废纸。
所以,下次当你按 Ctrl+C 重启服务器时,看着日志里那些排队等待退出的 Worker,是不是觉得它们特别可爱?它们正在为了系统的稳定,忍痛割爱,把最后一个请求发完。
这就是 PHP-FPM,一个即使在“分手”时刻,也要保持优雅的 Web 服务进程。明白了吗?如果不明白,那可能是你的 Master 进程还在忙着重启,没空理你。
Happy Coding, and Handle Your Signals Gracefully!