PHP 面试细节:详细阐述 PHP-FPM 的 Master/Worker 模型在处理系统中断时的保护机制

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);
        }
    }
}

这里的保护机制是什么?

  1. 不完全关闭(Decoupling): Master 只发信号,不发 Kill。它让 Worker 自己决定什么时候停。
  2. 重生机制: 你看上面的 spawn_worker()。即使你在停机过程中有一个 Worker 卡住了,Master 也会立刻重启一个新的。这意味着你的服务 可用性(Availability) 在重启期间是不降级的。
  3. 互斥锁: 在重启配置或优雅停机时,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 的处理逻辑极其复杂且谨慎:

  1. 不重启进程: Master 不会杀掉现有的 Worker。
  2. 信号广播: Master 向所有 Worker 发送 SIGUSR1
  3. Worker 的动作:
    • Worker 收到信号,继续处理完当前的请求。
    • 请求结束后,Worker 不会退出。
    • Worker 读取新的配置(比如 pm.max_children 变大了)。
    • Worker 重新检查自己的状态,看自己是否还需要留在池子里。

这里有个保护机制:
如果新的配置导致 Worker 数量超过了限制,或者配置参数错误,PHP-FPM 会在重启时拒绝加载新配置,或者拒绝新请求,直到配置文件修正。Master 会报错,但不会把现有的 Worker 骂死。


结语:敬畏每一个信号

好了,讲了这么多,我们到底学到了什么?

PHP-FPM 的 Master/Worker 模型在处理中断时,其实是一场精心编排的舞蹈。

  1. Master 是个老狐狸: 它不乱杀,它只发信号,它时刻盯着 Worker 的尸体,有人死了立马补位,保证你的网站对用户来说永远是 200 OK。
  2. Worker 是个守规矩的员工: 遇到信号,它先把活干完。如果有 fastcgi_finish_request(),它就把尾巴(HTTP 响应)留给你,自己先溜(断开连接);如果没有,它就老老实实把活干完再走。
  3. 系统调用是护身符: 只有在系统调用里,信号才能生效。如果你的代码写成了纯 CPU 忙等待,信号就是一张废纸。

所以,下次当你按 Ctrl+C 重启服务器时,看着日志里那些排队等待退出的 Worker,是不是觉得它们特别可爱?它们正在为了系统的稳定,忍痛割爱,把最后一个请求发完。

这就是 PHP-FPM,一个即使在“分手”时刻,也要保持优雅的 Web 服务进程。明白了吗?如果不明白,那可能是你的 Master 进程还在忙着重启,没空理你。

Happy Coding, and Handle Your Signals Gracefully!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注