各位好,欢迎来到“PHP 内核深挖大会”的现场。我是你们今天的讲师,一个在代码里修过桥、铺过路、还试图在风暴中保护几万个 PHP 进程的资深工程师。
今天我们不聊 Laravel 的优雅,也不谈 Yii 的强壮,我们聊点硬核的、底层的东西。我们聊的是 PHP-FPM 的 Master/Worker 进程模型在处理系统中断时的物理保护。
听到这个题目,你们可能会想:“Master/Worker 嘛,不就是老大管老二,老大喊一声老二停,老二就停?”
哈哈,天真。在这个计算机世界里,老大喊一声,老二可能正忙着在键盘上敲“SELECT * FROM”,这时候突然断电或者收到信号,老二手里的“键盘”(系统调用)可能会飞出去,甚至导致整个服务器的“大脑”(内核)震荡。
所以,今天我们要讲的是:当系统发送一个“中断”信号(比如 SIGTERM 或 SIGHUP)时,PHP-FPM 是如何利用操作系统提供的物理和逻辑规则,像泰森打拳一样,既能结束战斗,又不伤到自己人的。
第一部分:角色介绍——谁是 Master,谁是 Worker?
为了理解保护机制,我们先得给这帮进程分角色。
想象一下,你经营着一家巨大的便利店,生意火爆到服务器快爆炸了。
- Master 进程: 他是店长。他坐在监控室里,手里拿着对讲机。他不需要收银,不需要理货。他的工作是指挥。哪个货架(端口)空了?哪个员工(Worker)累趴下了?或者现在要“闭店大吉”了?店长通过“信号”来发布命令。
- Worker 进程: 他是收银员和理货员。他们拿着扫描枪(Socket),忙着处理请求(比如 PHP 代码)。他们的工作很重,不仅要跟数据库“谈恋爱”,还要跟前端浏览器“握手”。
问题来了:
当你在服务器上敲下 kill -9 的时候,你是店长,你把信号发给了 Master。Master 收到信号,心想:“好的,我要关门了。” 但这时候,可能有 100 个 Worker 正在忙着结账。
如果 Master 直接把这些 Worker 杀掉,那前面 99 个结完账的顾客(请求)的钱都没收,或者正在转账的订单会变成“孤儿”,这可是严重的财务事故(服务不可用)。
所以,“物理保护”的核心目标就是:让 Master 的命令传达给 Worker 时,能够穿过干扰,既不丢失正在处理的请求,也不卡死整个系统。
第二部分:中断的本质——CPU 的“打岔”
要讲保护,得先明白什么是中断。
在计算机里,CPU 是核心。Worker 进程在执行 PHP 代码时,大部分时间是在“休息”状态,比如等待网络数据包到达。这时候,Worker 进程会调用 accept() 或 read() 系统调用,告诉 CPU:“老板,我去睡一觉,有数据来了叫我。”
这个“睡一觉”的过程,在内核眼里叫阻塞。
这时候,Master 收到了 SIGTERM 信号。
物理层面的瞬间发生了什么?
CPU 正在执行 Worker 的指令,突然,内核弹出一个消息:“嘿!Master 叫你停一下!”
CPU 暂停当前的工作,保存现场(把当前寄存器的值记下来),跳转到“中断处理程序”(也就是信号处理函数)。
这时候,Worker 之前发起的那个“睡一觉”的系统调用(比如 epoll_wait)就被打断了。操作系统会返回一个错误码:EINTR(Interrupted System Call),翻译成人话就是:“你刚才睡过头了,老板叫你,你醒醒。”
如果系统没有保护机制:
Worker 看到错误码 EINTR,心想:“哎呀,倒霉,操作失败了。” 然后直接 exit(1) 掉了。Master 收到 Worker 死了,赶紧启动一个新的。这就导致了请求中断、进程频繁重启、资源浪费。
PHP-FPM 的保护策略: 我们必须在代码里“捕获”这个错误,并且不能让 Worker 因为这个错误而崩溃。
第三部分:Master 的策略——指挥官的艺术
Master 进程其实很简单,它就像一个守门的保安。
- 监听信号: Master 启动时,会注册信号处理函数。
- 状态切换: 当收到
SIGTERM时,Master 不会立即发SIGKILL,而是设置一个全局标志位graceful_shutdown(优雅关闭)。 - 停止接受新兵: Master 会停止调用
listen()接受新的连接。此时,新的请求进不来。 - 广播命令: Master 把这个信号发给所有还活着的 Worker。
这里涉及到一个非常重要的 Linux 机制:信号量的阻塞与竞争。
Master 需要确保所有 Worker 都收到了信号。如果 Worker 正在运行非常复杂的算法(比如计算圆周率到小数点后一万位),Master 不能直接 kill,因为那样会造成数据丢失。
Master 的保护体现在“延迟执行”上。Master 收到信号 -> 标志位变更 -> 等待。它给 Worker 的时间窗口是有限的,通常设置为 timeout(比如 30 秒),如果 30 秒还没干完,Master 就会强制干掉。
第四部分:Worker 的物理保护——非阻塞与重试
这是今天的重头戏。Worker 进程在代码层面(底层 C 代码层面)是如何保护自己的?
让我们看看 PHP-FPM(更准确地说是 event 扩展,它是 PHP-FPM 的事件循环引擎)是如何处理 epoll_wait 的。
1. 非阻塞 I/O 的物理层保护
在 Linux 下,如果你想让一个 Socket 在等待数据时不阻塞整个进程,你必须在创建 Socket 时设置 O_NONBLOCK 标志。
这意味着,当 Worker 调用 epoll_wait() 时,如果内核没有数据,它会立刻返回,而不是傻傻地等待。
但是,epoll_wait 本身也是一个系统调用,它也是可以被中断的!
场景模拟:
- Worker 调用
epoll_wait,此时 CPU 进入内核态,准备等待事件。 - 信号来了。
- CPU 触发中断,跳走。
epoll_wait返回错误EINTR。- 保护机制启动: Worker 的代码检测到
errno == EINTR。
如果代码写得烂,看到 EINTR 就 exit;如果代码写得好(PHP-FPM 就是),它会重试。
2. 代码层面的物理保护
下面这段代码(伪 C 代码,模拟 PHP-FPM 底层 Worker 的核心循环)展示了这种物理保护:
// 这是一个极简的 PHP-FPM Worker 循环模型
#include <sys/epoll.h>
#include <signal.h>
#include <unistd.h>
// 全局变量:老大(Master)发来的信号标志
volatile sig_atomic_t shutdown_requested = 0;
// 信号处理函数:极其简短,只设置标志位
void sig_handler(int signum) {
shutdown_requested = 1;
}
int main() {
int epfd = epoll_create1(0);
int server_sock = /* ... 初始化 socket ... */;
// 注册信号处理
signal(SIGTERM, sig_handler);
// 1. 添加 socket 到 epoll 监听列表
struct epoll_event ev = { .events = EPOLLIN, .data.fd = server_sock };
epoll_ctl(epfd, EPOLL_CTL_ADD, server_sock, &ev);
while (!shutdown_requested) {
// 2. 核心保护机制:处理 EINTR
// 我们使用一个循环,如果系统调用被中断,就重试
int nfds;
do {
nfds = epoll_wait(epfd, events, MAX_EVENTS, 1000); // 1000ms 超时
} while (nfds == -1 && errno == EINTR);
// 如果是正常的错误(不是中断),或者 fd <= 0,跳过
if (nfds <= 0) continue;
// 3. 处理连接
for (int i = 0; i < nfds; ++i) {
if (events[i].data.fd == server_sock) {
// 接受新连接,也是非阻塞的
int client_sock = accept(server_sock, ...);
if (client_sock > 0) {
// 处理请求...
process_request(client_sock);
}
}
}
}
// 4. 优雅退出逻辑
// 关闭所有 socket,清理资源
close(epfd);
close(server_sock);
return 0;
}
解读这段代码中的物理保护:
volatile sig_atomic_t: 这是一个关键字,告诉编译器这个变量不能被优化。因为 CPU 可能在任何时刻(哪怕是在epoll_wait执行期间)随时打断指令流来执行信号处理程序。如果不加这个,编译器可能会优化掉对shutdown_requested的检查,认为它永远不会变。do { ... } while (errno == EINTR);: 这是物理保护的精髓。当信号发生时,epoll_wait被迫返回,errno被设置为EINTR。如果这里没有这个do-while循环,Worker 就会因为epoll_wait失败而退出了。有了这个循环,我们就相当于告诉操作系统:“我不管你怎么打断我,只要没收到关停信号,我就得继续等。”
第五部分:更深层的保护——异步信号安全
这可能是面试中最容易被忽视的细节:在信号处理程序内部,你绝对不能调用任何“非异步信号安全”的函数。
PHP-FPM 的信号处理程序非常小,它只做一件事:设置一个全局变量。
为什么?
假设信号发生时,Worker 正在执行 PHP 代码:
// PHP 代码
$x = fopen('/tmp/log.txt', 'w');
fwrite($x, "Hello");
如果信号处理程序里写了 unlink('/tmp/log.txt'),然后返回。此时,Worker 代码从 fwrite 处继续执行,它发现文件句柄可能已经被信号处理程序搞乱了(文件锁可能被释放,或者缓冲区被破坏)。
这就叫竞态条件。在物理层面,这就像两个人同时穿同一双鞋,鞋子会炸。
所以,PHP-FPM 的 Master 进程在信号处理函数中,遵循了严格的最小侵入原则。它只操作内存中的整型变量,这是 CPU 原子操作支持的,是绝对安全的。
而真正的“重头戏”——清理连接、关闭 Socket、通知 Master——都在 Worker 的主循环中,通过检查 shutdown_requested 标志位来完成。
第六部分:Master 如何“物理”唤醒 Worker?
如果 Worker 正在 epoll_wait 里睡觉,Master 发送信号,怎么唤醒它?
这就涉及到操作系统的调度器了。
- 中断触发: 信号产生,内核给 Worker 进程的 PCB(进程控制块)挂上一个“信号待处理”的标志。
- 调度器介入: 当
epoll_wait返回(无论是正常返回还是 EINTR),Worker 进程会恢复到用户态。此时,CPU 调度器会检查这个进程是否有待处理的信号。 - 信号处理: 如果有,CPU 会再次发生中断,跳转到信号处理函数。
- 返回主循环: 信号处理函数执行完毕,Worker 回到主循环的开头,检查
shutdown_requested。
这就形成了一个原子性的检查-响应闭环。
第七部分:SIGCHLD 与僵尸进程的“物理”博弈
除了停止服务,Master 还要处理子进程的死亡。这也是一种中断。
当 PHP-FPM 的 Worker 处理完请求退出时,它会变成僵尸进程(Zombie Process)。它不占 CPU,但占内存空间。如果堆积太多,Master 就没法启动新的 Worker。
Master 进程必须时刻监听 SIGCHLD 信号。
Master 的信号处理程序里通常不会直接 wait()(因为可能会阻塞,进而影响信号处理速度),而是利用 fork() 机制。Master 会创建一个子进程去调用 wait(),回收僵尸进程。
这里也有保护:如果 Master 正在处理 SIGTERM(关闭),Master 会暂时忽略 SIGCHLD,确保关闭流程不被卡在 wait() 上,保证 Master 能尽快完成关机任务。等 Master 关机完成后,它才会去清理那些可怜的僵尸进程。
总结(不,我们不说总结)
所以,PHP-FPM 的 Master/Worker 模型在处理中断时,并不是魔法,而是一套严谨的物理层契约。
- Master 是冷静的指挥官: 只改标志位,不乱动资源,给 Worker 优雅的时间。
- Worker 是听话的士兵: 穿着“非阻塞”的防弹衣,手里拿着“重试”的手雷。遇到
EINTR不慌张,而是回圈重试,直到确认收到关停令。 - 信号处理是极简的特战队: 只在内核态快速标记一下,绝不深入用户态的复杂逻辑,避免把世界搞崩。
这就是为什么当你执行 service php-fpm reload 时,服务器依然能响应请求,流量平滑过渡,而不是像服务器中暑一样突然挂掉。
下次当你看到 PHP-FPM 的配置文件里 process_control_timeout 调得很大,或者你在看 epoll_wait 的源码时,请记得:这一切的安稳,都是操作系统和程序员在信号风暴中用“非阻塞”和“重试”修筑的堤坝。
好了,今天的讲座到此结束。希望大家在以后写代码时,能对那些被系统调用打断的瞬间,多一份敬畏之心。下课!