1. 从一个“牛”字说起:为什么非要把PHP的生命周期解剖一遍
如果你写过一段时间PHP,一定听过“PHP生命周期”这个说法。面试的时候被问过,看源码的时候见过,排查内存泄漏的时候翻过文档。但坦率地讲,很多人对这个概念的理解是模糊的——知道有“模块初始化”“请求初始化”这些名词,却不知道这些阶段在操作系统层面到底发生了什么,更不知道为什么这些阶段决定了PHP的性能、稳定性和架构选型。
我最早有“必须彻底搞懂”的冲动,是在一次线上的内存持续上涨事故里。一个用原生PHP写的常驻脚本,跑一段时间后内存占用直接翻了几倍,最后OOM被操作系统杀死。当时我翻了一晚上源码,才意识到问题的本质是:我把“一次请求”的生命周期误当成了“整个进程”的生命周期,代码里静态变量的使用方式彻底错了。从那以后我就觉得,PHP的生命周期不是考试知识点,而是每一个写PHP的人都应该掌握的“内功心法”。
这篇文章,我想用“庖丁解牛”的方式,把PHP的生命周期从头到尾拆开来看。不是停留在“有哪几个阶段”这种背诵层面的东西,而是把每个阶段和操作系统的进程管理、内存分配、文件描述符、信号处理对应起来,讲清楚为什么这么设计、有什么坑、怎么利用它。文中的结论大多来自我自己的源码阅读和线上排查经验,也会结合一些Linux系统下的实测行为来验证。适合的人包括:写PHP但没系统看过底层的人、准备PHP高级岗位面试的人、以及正在被内存泄漏和性能问题折磨的人。
2. 先建立全局视角:PHP生命周期是操作系统进程故事的“骨架”
2.1 一切的起点,是你的PHP进程怎么活起来
任何一门语言的运行时,最终都要活在操作系统提供的进程里。PHP也不例外。当你敲下php index.php的时候,操作系统做了一件极其朴素的事情:调用execve()系统调用,把PHP解释器这个可执行文件加载进内存,创建一个新的进程,然后从入口点开始执行代码。
但PHP通常不是这么用的。生产环境里,最常见的运行模式是PHP-FPM。FPM启动的时候,master进程会读取配置文件、初始化事件循环、然后fork出一批worker进程。worker进程就是真正执行PHP脚本的进程。这里有一个关键点:fork出来的worker进程一开始就有一份完整的内存拷贝,包括已经加载的PHP模块、已经初始化的全局变量。这就是后面很多生命周期行为的根源——一次fork,之后的每次请求都是在“复用”这个已经活着的进程。
我见过不少同事对“进程活着”这件事没有直观感受。其实你可以做一个简单的实验:/usr/sbin/php-fpm7.4 -F跑起来之后,用ps -ef看一下进程列表,会发现一个master和好几个worker长期存在。这些worker不会因为一次请求结束就退出,而是持续等待下一个请求的到来。这个“持续活着”的进程,就是PHP生命周期的真正载体。
2.2 生命周期不是一个圆,而是层层嵌套的“洋葱”
PHP的生命周期,严格来说不是一条线,而是多层嵌套的结构。最外层是“进程生命周期”,PHP解释器从启动到退出,整个过程只发生一次模块初始化(MINIT)和模块关闭(MSHUTDOWN)。中间层是“请求生命周期”,每一个HTTP请求或每一次脚本执行,都会经历请求初始化(RINIT)和请求关闭(RSHUTDOWN)。最内层是“脚本执行生命周期”,也就是你的PHP代码从头到尾执行的过程。
这三层的关系,可以类比为一个餐厅的经营周期:餐厅从装修开业到关门停业是“进程生命周期”(极其漫长),每天开门营业到晚上打烊是“请求生命周期”(高频重复),而顾客点菜到吃完离开,是“脚本执行生命周期”。装修只在开业做一次,开门的准备工作每天都要做,而做菜的过程每桌客人都不一样。
用一个表格来对比一下就清楚了:
| 生命周期层级 | 对应时机 | 发生频率 | 典型回调函数 |
|---|---|---|---|
| 进程生命周期 | PHP解释器启动到退出 | 进程存活期间仅一次 | MINIT / MSHUTDOWN |
| 请求生命周期 | 每个请求开始到结束 | 每个请求一次 | RINIT / RSHUTDOWN |
| 脚本执行生命周期 | PHP代码执行全过程 | 每个请求一次 | 无固定回调,由Zend引擎驱动 |
记住这个嵌套结构很重要。因为很多问题,比如“全局变量为什么会跨请求残留”“为什么长时间运行的Worker会越来越慢”,本质上都是因为混淆了这“三层的边界”。后面我拆解具体阶段的时候,你会更清楚地看到每一层各自负责什么。
2.3 为什么操作系统视角是关键
讲PHP生命周期,很容易只停留在PHP的源码层面。但如果你只看PHP自己的逻辑,会漏掉很多真相。举个例子:PHP的memory_limit是从哪里来的?它本质上是PHP在进程里维护的一个逻辑计数器,而不是操作系统直接给进程的内存上限。操作系统真正关心的是一个进程使用了多少物理内存、多少虚拟内存。PHP内部计数和操作系统层面的RSS(Resident Set Size)不是一回事。有时候PHP自己觉得内存用得很省,但操作系统看到这个进程占用的RSS很高,因为PHP向系统申请的内存不会用完就立刻还给系统。
这提醒我们:理解PHP生命周期,必须同时理解操作系统进程的生命周期。PHP在请求生命周期结束后做的一些清理,只是释放了自己内部管理的资源,但C语言层面的内存池、操作系统的页缓存、文件描述符表,都有各自的延迟释放逻辑。这正是很多线上问题“明明PHP没有内存泄漏,但RSS一直涨”的根源。
3. 第一刀:模块初始化(MINIT)——一栋大楼的“主体框架浇筑”
3.1 MINIT到底做了什么
当PHP-FPM的worker进程被fork出来,或者CLI模式下PHP解释器启动时,Zend引擎会调用所有已加载模块的“模块初始化”回调函数(Module Initialization,MINIT)。这一步做的事情,可以概括为:让所有扩展准备好它们需要的全局资源和环境。
我拿最常见的扩展举例。你在php.ini里启用extension=redis.so,MINIT阶段Redis扩展会做的事情大致包括:
- 注册自己的函数列表到Zend引擎的函数表里,这样你的PHP代码里才能调用
Redis::connect()这类方法。 - 分配模块级别的全局变量(如连接池、配置项缓存)。
- 注册INI设置项,比如
redis.timeout,这些设置项会在MINIT阶段被读取并解析。 - 注册类条目,让
Redis类可以被PHP代码实例化。
这个阶段,扩展读不到任何请求相关的数据,因为它还没有请求。它做的是“静态初始化”工作,类似于餐厅在正式开业之前,先把装修做好、设备安装好、菜单印好。
3.2 和操作系统的对应关系:加载动态库与全局内存分配
从操作系统的角度看,MINIT阶段对应的是动态链接库的加载过程和全局内存的分配过程。
当PHP加载redis.so,操作系统使用mmap()系统调用把共享库的代码段映射进进程的虚拟地址空间。如果多个PHP-FPM worker都是从同一个master fork出来的,它们在物理内存层面会共享这块只读的代码段,从而大大节省内存。这也是为什么PHP-FPM的内存占用看起来比每个worker单独启动一个解释器要低得多。
扩展在MINIT阶段分配的全局变量,会被放进进程的“数据段”或者堆区。这些内存在进程存活期间一直存在,不会被释放。举个例子,如果你在扩展里用pthread_once做了一些初始化,这些全局数据就会一直驻留到进程退出。所以,PHP扩展开发者如果在MINIT阶段分配了没有对应释放逻辑的资源,就等于给整个进程埋了一颗定时炸弹——进程活着越久,问题累积越多。
我见过一个印象很深的案例:一个内部扩展在MINIT阶段每次都会创建一个日志文件的文件句柄,但从不关闭。FPM worker每fork一次,就多一个句柄泄漏。跑上几天后,lsof -p <pid>能看到几百个文件句柄,最终进程达到系统文件描述符上限,新请求全部报“Too many open files”。
3.3 哪些代码会干扰MINIT
很多人以为MINIT阶段只跟扩展有关,跟自己写的PHP代码无关。这话基本对,但有一个例外:如果你的PHP代码是通过auto_prepend_file在请求开始前自动执行的,这部分代码依然属于请求生命周期,不会进入MINIT。另外,如果你用dl()函数动态加载扩展,这个操作发生在运行时,而且现代PHP基本已经废弃了dl()在生产环境的使用。
不过,真正值得警惕的是PHP-FPM的php.ini配置项解析。php.ini中有些配置项是PHP_INI_SYSTEM级别的,意思是它们只能在MINIT阶段或进程启动阶段被读取,不能在运行时通过ini_set()修改。比如memory_limit其实是PHP_INI_PERDIR,看起来可以在运行时修改,但extension_dir就不行。因为扩展的搜索路径必须在加载扩展之前确定。这类“只能在哪个阶段做哪些事”的限制,本质上就是生命周期在语言层面的体现。
4. 第二刀:请求初始化(RINIT)——每天开门的准备工作
4.1 每次请求都从“重置”开始
RINIT(Request Initialization)是每个请求开始时的初始化阶段。在PHP-FPM模式下,每次收到一个新的HTTP请求,或者CLI模式下每次执行一个脚本,Zend引擎都会执行RINIT回调。
RINIT阶段最核心的动作,是重新初始化与请求相关的数据。具体包括:
- 重置符号表和变量表,清空上次请求残留的变量。
- 初始化内存管理器的当前请求内存池。
- 调用各扩展的RINIT钩子,例如重新建立数据库连接(如果扩展配置了请求级连接)、重置状态缓存、重新生成随机数种子等。
- 设置错误处理环境和异常处理环境。
可以这样理解:MINIT是“餐厅开业前装修”,RINIT是“每天开门前的开门准备”——擦桌子、摆椅子、开灯,让这个空间准备好接待新一批客人。桌子上的残渣必须清干净,否则下一位客人没法坐。
4.2 扩展的RINIT为什么容易“埋雷”
扩展开发里最常见的错误之一,就是把本应在RINIT阶段做的事放到了MINIT阶段,或者反过来。这个错误之所以致命,是因为MINIT阶段的数据会在多个请求之间共享,而RINIT阶段的数据是每个请求独立的。
想象一个场景:某个扩展在MINIT阶段初始化了一个全局数组,用来缓存当前请求中的用户数据。正常情况下,这个数组应该在请求结束后清空。如果扩展忘记在RSHUTDOWN阶段清理,那么第二个请求进来时,会看到第一个请求残留的数据——这就是典型的“请求污染”。在PHP-FPM这种长驻进程的模式下,这种污染会直接导致数据错乱,而且因为不是每次都必现,排查起来极其痛苦。
我在读一些开源扩展的源码时,发现很多扩展都会在RINIT阶段做这样一件事:重置自己的静态变量。比如setcookie相关的扩展,需要在每个请求开始时把上次请求设置过的cookie头全部清空,否则上一次请求的cookie会残留到这次请求里。PHP内置的ext/session也会在RINIT阶段完成session模块的初始化准备,然后到了真正的脚本执行阶段,才根据PHP代码里的session_start()去实际读取和启动session。
4.3 与操作系统内存池的“衔接”
RINIT和操作系统层面的对应,主要体现在内存管理上。PHP内部有一个基于emalloc/efree的内存管理器,它会维护一个“请求内存池”。RINIT阶段,这个内存池会重新定位到“干净”的状态,准备接受这个新请求的内存分配请求。
这里有一个细节值得注意:PHP向操作系统申请内存,并不是每次malloc都直接调用一次系统调用。PHP的内存分配器(Zend MM)会向操作系统一次性申请一大块内存,然后自己在用户空间里做二次分配。这样做的好处是减少系统调用次数、降低锁竞争,坏处是——当PHP脚本临时申请了大量内存又释放后,这块大内存不一定立刻归还操作系统,而是留在进程里备用。这意味着你在CLI脚本里调用memory_get_peak_usage(),看到的只是PHP内部记录的高水位,操作系统看到RSS可能已经涨了很多。
RINIT阶段,PHP会把当前请求的内存池“重置”到起始位置,但这个重置是逻辑上的,并不是真的把内存归还给系统。从操作系统视角看,进程的虚拟内存空间已经扩大了,只是里面的内容被标记为“可复用”。这个机制本身是高效的,但如果你在长驻进程里观察到RSS持续上涨,就要注意是不是PHP之外的因素(比如C扩展、第三方库)绕过PHP内存管理器直接调用了系统的malloc。这类内存在PHP的请求结束时不会自动释放,必须由扩展自己负责,否则就会累积。
4.4 实操提醒:RINIT阶段常见的“时间陷阱”
还有一个实践上很容易踩的坑,是关于性能评估的。有些团队做性能测试,统计“每个请求的平均耗时”,会把耗时范围定义在“从收到请求到返回响应”。但在PHP-FPM里,这个范围其实比RINIT到RSHUTDOWN的范围要窄。RINIT阶段本身是计入请求耗时的,而且RINIT做的工作越多,每个请求的固定开销就越大。
我做过一次对比测试:同样一个业务逻辑,一个环境加载了20个扩展,另一个环境加载了50个扩展(业务代码相同)。简单压测下,50个扩展的QPS大约下降了15%到20%。原因很简单——每个请求都要执行所有扩展的RINIT钩子,扩展越多,固定开销越大。所以,生产环境里无用的扩展一定要禁用,这不是洁癖,是实打实的性能收益。
5. 第三刀:脚本执行(Execute)——宰牛过程最复杂的部分
5.1 从源码到opcode再到执行
RINIT结束后,PHP正式开始执行你的脚本。这一步的流程是大家比较熟悉的:词法分析(Lex)、语法分析(Parse)、生成AST(抽象语法树)、编译成opcode、Zend虚拟机执行opcode。
整个过程和操作系统之间的交互,可以这样理解:
- 词法和语法分析阶段,PHP需要读取源码文件。这依赖操作系统提供的文件读取能力,涉及打开文件描述符、读取文件内容。
- 编译阶段会生成opcode数组。opcode是PHP函数的指令级表示,类似CPU的汇编指令,但由Zend虚拟机解释执行。
- 执行阶段,Zend虚拟机一个接一个地执行opcode,每个opcode对应一个C函数,实现具体的操作,比如变量赋值、函数调用、内存分配等。
opcode这个概念在热词里频繁出现(php源码、php 8 phpstorm),说明很多人在学习PHP底层时注意到了它的重要性。简单说,你的PHP代码不会直接被CPU执行,而是先被翻译成opcode,再由Zend虚拟机执行。用操作系统类比,这有点像一个程序不在CPU上直接跑,而是在一个“虚拟CPU”上跑,这个虚拟CPU的指令集就是opcode集合。
5.2 变量生命周期与引用计数
脚本执行阶段,最核心的机制之一就是变量的生命周期管理。PHP的变量是“引用计数+写时复制”机制。每一个zval值结构里都有一个refcount字段,记录当前有多少变量名指向这个值。当refcount降为0,这个值就会被释放内存。写时复制则保证:当你把一个变量赋值给另一个变量时,PHP并不会立即拷贝一份数据,而是让两个变量共享同一个值结构,只有当其中一个变量被修改时,才会发生真正的拷贝。
这个机制在生命周期上有一个重要推论:变量的生命周期取决于引用计数何时归零。看似简单的规则,在复杂代码里很容易出错。举个具体场景:
function processLargeData() { $data = loadHugeArray(); // 假设这个数组占用100MB // 处理数据... } processLargeData();理论上,processLargeData函数执行完后,$data应该被释放。但如果loadHugeArray()内部把数组放进了某个静态变量或者全局容器里,引用计数就不会降到0,内存就不会被释放。这就是“内存泄漏”最常见的PHP层面的根源——不是忘了unset,而是变量仍然被某个“看不见的引用”持有。
遇到这类问题,我常用的排查工具是memory_get_peak_usage()分段打印,找出突然上涨的区间。另一个土办法,是开启PHP的opcache.enable_cli后连续执行同一个脚本,观察RSS是否每轮都在上涨。如果不涨,说明问题出在单次请求内的变量处理;如果每轮都涨,基本就是全局残留的问题。
5.3 函数调用栈与操作系统的栈帧
脚本执行时的函数调用,和操作系统层面的函数调用栈是有可类比性的。PHP源代码里每一个函数调用,在Zend虚拟机层面会创建一个新的执行数据(zend_execute_data),其中包含了函数的参数、局部变量、opcode指针等。这个结构相当于一层“栈帧”。
当递归调用过深、或者某个函数内部创建了大量局部变量时,Zend虚拟机会使用更多的内存来维护这些执行数据。极端情况下,如果函数递归无终止,最终会触发PHP的“最大嵌套级别”错误(一般会抛出xdebug.max_nesting_level相关的错误,或者Zend引擎保护性的“Stack overflow”错误)。这个保护机制在底层靠的就是对栈深度的监控。
从操作系统视角看,PHP的每个“栈帧”虽然是分配在进程的堆内存中的(PHP的虚拟机栈是动态分配的),但整个进程自身的C语言函数调用栈依然是固定的、有限的。当Zend虚拟机调用一个C函数(比如某个扩展的内部函数)时,会真实地在进程的栈上压栈。如果扩展代码写得不好,C语言层面发生深递归,就可能真的导致“Segmentation fault”——进程直接被操作系统杀掉。
这也是为什么我会一再强调“PHP扩展的错误经常导致整个FPM进程崩溃”而不是“报个warning就完事”——因为C代码里的栈溢出、非法内存访问,操作系统可不会跟你讲情面,直接发出SIGSEGV信号,进程就没了。理解这一点,你就知道为什么生产环境升级扩展版本之前一定要做充分的压力测试,而不是只验证业务功能。
5.4 opcache在生命周期里的角色
说到脚本执行,离不开opcache。PHP 5.5之后opcache成为官方扩展,它的作用是缓存编译后的opcode,避免每次请求都重新解析源码、重新编译。
opcache的工作方式,决定了它在进程生命周期里的特殊地位。opcache缓存的数据存储在共享内存里。在PHP-FPM模式下,master进程和所有worker进程通过共享内存访问同一份opcode缓存。这样,第一个请求把某个PHP文件编译成opcode放进共享内存,后续所有请求直接从缓存里取,编译开销就省下来了。
和生命周期相关的一个坑是:因为opcache里的缓存在进程存活期间一直有效,如果你修改了PHP文件,需要让opcache感知到文件内容的变化。opcache默认配置会检查文件的mtime(修改时间)。如果你的部署方式是直接覆盖更换文件,mtime会变,没问题。但如果你用了一些不更新mtime的部署工具(比如某些云厂商的对象存储挂载方式),opcache可能永远认为文件没变,导致线上代码更新不生效。遇到这种情况,可以在部署完代码后重启PHP-FPM,或者调用opcache_reset()强制清空缓存。
但从操作系统层面理解,还要再做一层引申:opcache使用的共享内存,本质上是System V或POSIX共享内存段。这些内存段是独立于进程堆的“第三方资源”。如果FPM进程异常退出(被kill -9),共享内存段可能不会立即销毁,下次启动时如果有残留的数据,可能引发“opcache已初始化”之类的报告。解决方式通常是ipcrm清理共享内存段,或者直接重启一次机器,就能彻底清干净。
6. 第四刀:请求关闭(RSHUTDOWN)——打烊前一定要关好门窗
6.1 RSHUTDOWN的职责,远比你想的多
脚本执行完,并不意味着“请求生命周期的结束”。Zend引擎紧接着会进入RSHUTDOWN阶段,执行请求关闭的清理工作。这个阶段的重要性,很多人低估了——它承担着以下几类工作:
- 调用所有扩展注册的RSHUTDOWN回调,让扩展有机会释放请求级别的资源,比如关闭数据库连接、刷新输出缓冲区、清理临时文件。
- 执行PHP代码里注册的
register_shutdown_function()回调。 - 处理未捕获的异常和未被处理的错误,执行最终的错误输出。
- 将输出缓冲区的内容刷给客户端(或CLI的stdout)。
- 释放所有请求级别的变量,执行内存回收。
可以这样说:RSHUTDOWN阶段是PHP生命周期里的“打烊检查”——确认所有灯都关了,水龙头都关了,门窗都锁好了。一旦这个环节处理不好,干净的餐厅会留下前一天晚上的一地鸡毛。
6.2 fclose、缓冲区和操作系统缓存的“时差”
RSHUTDOWN里一个很容易忽视的问题是缓冲区刷新和操作系统缓存之间的时差。
假设你的PHP代码里写了一个文件:
file_put_contents('/tmp/data.log', $content);PHP执行完这行代码,数据其实先进入了用户态的缓冲区。文件真正被写入磁盘,还需要依赖操作系统层面的刷盘机制。在RSHUTDOWN阶段,PHP会确保把输出缓冲区的内容交给操作系统(通过write系统调用),但操作系统何时把页缓存中的数据真正落盘,取决于内核的pdflush机制。这就是为什么你写了文件、读出来内容正确,但突然断电后文件内容丢了——操作系统还没把脏页写回磁盘。
这个知识点对日常PHP开发的作用是:如果你真的需要保证数据落盘(比如记录重要审计日志),不要只依赖于PHP层面的文件写入,更不要认为RSHUTDOWN阶段能弥补一切。必要时可以调用fflush()或直接让系统执行sync命令。当然,对绝大多数Web应用来说,这类“强持久化”需求很少,但理解这里面的“时差”,能避免你朝错误的方向排查数据丢失问题。
6.3 致命错误发生在RSHUTDOWN时会发生什么
有一种情况特别让人头疼:脚本执行完,PHP进入RSHUTDOWN阶段,这时候如果某个扩展的RSHUTDOWN回调抛出了致命错误,会发生什么?
答案是:Zend引擎会停止后续的清理流程,但进程本身不会立刻崩溃。在PHP-FPM下,这个worker进程会被标记为“不健康”,处理完当前请求后可能会被master进程回收并重新fork一个新的worker。这意味着,一个扩展在RSHUTDOWN阶段的崩溃,可能不会体现在业务报错里,但你会观察到FPM的worker进程频繁重启、php-fpm.log里出现WARNING级别的“child exit”日志。
我遇到过一次,是一个验证码扩展在RSHUTDOWN阶段尝试写入session时发生段错误。从业务上看,请求已经返回了图片和响应,但FPM日志里频繁出现“Segmentation fault”的记录。排查了一个晚上,最后用gdbattach到worker进程,加上catch signal SIGSEGV才定位到扩展代码里一处空指针解引用。所以说,RSHUTDOWN不是“清理完了就没事了”,它依然是进程生命周期中风险极高的阶段,尤其是那些持有了外部资源句柄的扩展。
7. 第五刀:进程关闭(MSHUTDOWN)——餐厅彻底关门的那天
7.1 MSHUTDOWN在什么时机出现
MSHUTDOWN(Module Shutdown)是整个进程生命周期中最后一个阶段。它只在以下情况出现:
- CLI脚本运行完,PHP解释器准备退出。
- PHP-FPM进程被停止,每个worker进程退出前。
fastcgi_finish_request()被调用后,FPM worker结束了处理,但整个进程还在——如果这时没有新的请求进来,worker一般不会主动走MSHUTDOWN,而是继续保持空闲状态。
和MINIT对应,MSHUTDOWN执行的是“反向的初始化”:每个扩展的模块关闭回调会释放MINIT阶段分配的资源——关闭全局配置文件、释放全局锁、清理共享内存、关闭日志句柄。
值得注意的是,MSHUTDOWN阶段的执行对PHP来说非常重要,因为它决定了资源能否被干净地归还给操作系统。如果扩展代码的作者在MSHUTDOWN里写了不正确的清理逻辑,轻则导致资源泄漏,重则导致进程退出时崩溃——这个问题对于CLI脚本的“每次运行退出”影响不大,但对于FPM场景,如果重启FPM时某些worker在MSHUTDOWN阶段崩溃,就可能出现“FPM重启后仍有残留进程”的现象。
7.2 操作系统视角:进程退出时,哪些被“自动清理”
MSHUTDOWN是PHP层面的清理,但即使不执行MSHUTDOWN,操作系统在进程退出时也会自动回收一些资源。这包括:
- 进程的虚拟内存空间:所有堆内存、栈内存都会被释放。
- 打开的文件描述符:进程持有的所有FD在进程退出时都会被内核关闭。
- 网络连接:进程占用的TCP连接会进入关闭流程。
- 锁和信号量:大部分POSIX锁在持有进程退出时会自动释放。
听起来很安全,对吧?其实不然。操作系统自动清理的资源,和应用程序逻辑级别的资源,不是一回事。举例来说,如果你在PHP代码里发起了HTTP请求,用了curl扩展,如果请求还没返回而进程退出了,操作系统确实会关闭socket,但远端服务器上可能还在处理一个“被半路丢弃”的请求。再比如,如果你的PHP通过MySQL扩展维持了一个事务,进程退出时操作系统会关闭socket,但MySQL服务端的事务不会自动回滚(取决于服务端的超时机制),这就会造成数据一致性问题。
所以我个人的习惯是:不要把MSHUTDOWN当作唯一的救命稻草,更不要指望“进程退了操作系统会兜底”。良好的应用设计,应该保证释放资源和清理逻辑在业务代码中明确执行,而不是依赖进程生命周期兜底。
7.3 从内核角度理解“僵尸进程”这个小麻烦
CLI模式下你可能遇见过“僵尸进程”(Zombie Process)。当一个PHP CLI脚本的子进程退出后,如果父进程没有调用wait()或waitpid()去回收子进程的退出状态,这个子进程就会变成一个僵尸进程,在进程表里占据一个条目,但不占用其他资源。
为什么会提到这个?因为PHP生命周期中,如果你在脚本里创建了pcntl_fork()子进程,并且子进程独立执行了一些任务后再退出,父进程如果忘了pcntl_waitpid()回收,子进程就会变成僵尸。这从严格意义上不是“PHP生命周期”的问题,而是“操作系统进程生命周期”的问题,但它确实发生在PHP脚本的执行生命周期里。
排查僵尸进程的方法很简单:ps -ef | grep defunct,看到<defunct>标记就是僵尸。解决方案是父进程注册pcntl_signal(SIGCHLD, ...)信号处理器,或者主动调用pcntl_waitpid()。这个问题在高并发的PHP常驻脚本里比较常见,因为父子进程的退出时机很随机,稍不注意就会漏掉回收。
8. 第六刀:长驻进程与生命周期之间的“相爱相杀”
8.1 PHP-FPM长驻模式直接放大了生命周期缺陷
坦白说,PHP生命周期里很多“坑”,在传统CLI“一次运行、立即退出”的模式下根本不会暴露。因为进程很快退出,操作系统自动回收了一切,PHP层面遗留的资源跟着进程一起烟消云散。但PHP-FPM把进程的生命周期拉得非常长,同一个worker进程可以连续服务成千上万个请求,这时候生命周期内任何一个环节的疏漏都会被不断放大。
典型问题包括:
- 某个偶然路径下未清理的全局变量,在下一个请求中被意外读取。
- 某个扩展在RSHUTDOWN阶段没有正确释放的内存,每个请求泄漏1KB,跑一天就是几十MB。
- 某个资源的文件描述符没有关闭,请求结束后FD仍然存在。跑几天,进程的FD数突破系统限制。
我有个朋友在维护一个老项目,线上FPM进程每天都得凌晨自动重启一次,否则第二天下午必现“502 Bad Gateway”。最初他们猜测是代码死循环、MySQL连接数被打满,排查了一圈都没找到原因。后来用strace -p <pid>配合lsof -p <pid>看,发现worker进程持有的TCP连接数在高并发下呈阶梯式上涨。最终定位到一个第三方SDK:它在RINIT阶段创建了一个内部HTTP客户端,但只在RSHUTDOWN阶段“置空”了变量,并没有真正关闭底层socket。问题浮出水面后,升级SDK版本并增加每日定期重启FPM的兜底策略,问题就解决了。
8.2 生命周期与内存管理器的“复用”逻辑
长驻场景下,另一个绕不开的话题是PHP内存管理器的“复用”逻辑。前面提过,Zend MM为了性能,会向操作系统一次性申请大块内存,然后在用户态进行二次分配和释放。这意味着:即使你的PHP代码已经释放了大数组的内存,Zend MM只是把这部分内存标记为“空闲”,留给后续请求复用,而不是立刻还给操作系统。
这个设计的直接后果是,你通过ps看到的RSS数值总是比memory_get_peak_usage()显示的峰值更高,而且高出一截。很多团队据此误判“PHP有内存泄漏”,其实只是“内存复用”的正常表现。判断是否真泄漏,不能用“单次请求结束后内存是否归零”来判断,而应该观察“长时间稳定运行后,RSS是否持续、不收敛地增长”。
我个人的判断标准是:压测场景下,把请求数打上去后,观察RSS会不会在某个水平线上稳定下来。如果稳定在某个区间震荡,则属于正常复用;如果随时间线性增长,且增长速度与请求数成正比,那就需要检查扩展或第三方库了。这个判断方式比单纯对比峰值内存要准确得多。
8.3 常驻模式下“清理”的艺术:定时重启并非解决办法
聊到长驻进程,很多团队的第一反应是“定期重启FPM”。坦白说,这是一种成本极低、见效极快的运维手段,但本质上是症状缓解,不是根因修复。你重启了进程之后,确实把内存泄漏的问题“清零”了,但如果根因没解决,下次泄漏会照旧发生,而且每次重启带来的连接断开会直接影响业务稳定性。
比较好的实践路径是:先用定期重启兜底,保证业务不挂;然后通过lsof、strace、memory_get_usage()分段采样等手段定位根因;最后在前端SDK或扩展层面修复。修复后,逐步拉长重启周期,比如从每天重启变成每周、每月,最终实现“无重启也稳定”。我在公司内部推动过类似流程,最终把一个原本每天重启的FPM集群改成了按需重启(只有发版才重启),核心工作就是分清“生命周期内该清理”与“进程级别该清理”的边界。
9. 庖丁解牛后的实战心得:生命周期的排查工具与避坑清单
9.1 用哪些工具观察PHP生命周期的“实况”
理论说再多,不如动手看一次。这里分享一套我非常依赖的排查工具箱:
strace -p <pid>:观察进程的系统调用序列。在RINIT阶段你会看到open()、read()、fcntl()等调用;执行阶段会看到mmap()、munmap()、write()等;如果你在RSHUTDOWN阶段看到大量close(),说明清理逻辑在正常工作。lsof -p <pid>:查看进程打开的文件描述符列表。如果请求结束后FD数量不回落到基准线,说明有描述符泄漏。/proc/<pid>/status里的VmRSS字段:这是进程的真实物理内存占用,对比PHP内部memory_get_peak_usage()的结果,能发现哪些内存是PHP不知道的“隐藏内存”。pmap -x <pid>:查看进程的虚拟内存映射,配合RINIT/RSHUTDOWN的前后对比,可以观察到那些“不在PHP控制范围内”的堆内存段是否持续扩张。- PHP内置的
runtime采样:在业务代码里分段记录microtime()和memory_get_usage(),辅助判断RINIT/RSHUTDOWN这些固定开销在总耗时里的占比。
这套组合拳,基本覆盖了“进程视图”“系统调用视图”“内存视图”“时间视图”四个维度。只要遇到寿命周期相关的问题,我一般会先从这四个层面各取一份数据,再做交叉对比。
9.2 常见问题速查表:从现象到根因
为了便于大家检索,我把实际工作中频率最高的几个生命周期问题整理成了表格。每个问题都按“现象 → 排查方向 → 常见根因”给出路径:
| 现象 | 排查方向 | 常见根因 |
|---|---|---|
| FPM worker RSS持续上涨且不回落 | 对比单请求前后RSS,观察请求频率是否与涨幅线性相关 | PHP内部变量残留引用;扩展绕过Zend MM直接malloc后未释放;文件句柄/网络连接未回收 |
| 新请求读到上一个请求的残留数据 | 在RINIT入口打印全局变量快照;检查静态变量初始化位置 | 静态变量在MINIT或模块级错误初始化;扩展的RINIT/RSHUTDOWN清理遗漏 |
| opcache更新代码无效 | 确认文件mtime是否更新;检查opcache配置的validate_timestamps | 部署工具未更新mtime;挂载文件系统不支持mtime;共享内存残留 |
| FPM worker进程频繁崩溃 | 查看php-fpm.log的child exit信息;用gdb抓SIGSEGV栈 | 扩展C代码的非法内存访问;RSHUTDOWN阶段的空指针解引用 |
| 长时间运行后出现“Too many open files” | 用lsof统计FD数量,定位增长源 | 扩展或业务代码未关闭文件/网络连接;高频调用不释放资源 |
| 子进程变僵尸 | ps查defunct进程;检查父进程是否waitpid | 父进程未处理SIGCHLD信号;fork循环过深 |
这张表里的内容,几乎每一个我都亲自踩过。建议收藏备用,遇到问题先对号入座,能节省大量排查时间。
9.3 生命周期设计层面的“心法”
最后聊点更高维度的东西。庖丁解牛讲的是“依乎天理”,顺着牛天生的肌理下刀,刀刃就不会磨损。PHP的生命周期也是如此——与其逆着它硬来,不如顺着它的设计做事。
有些常见的“逆天理”的行为,比如:在PHP-FPM长驻进程里使用static变量缓存大量请求数据;在MySQL连接用完时不关闭、指望进程退出自动回收;在扩展里把全局变量当成请求级存储;过度依赖register_shutdown_function()做关键逻辑(因为致命错误可能让它根本不执行)。
反过来,“顺天理”的做法是:把请求级别的变量交给PHP的引用计数和内存管理去处理;把进程级别的资源(连接池、配置缓存)控制在MINIT阶段初始化并妥善释放;把跨请求的上下文存储在外部服务(Redis、MySQL、Memcached)而不是进程内存里;把定期重启当作兜底手段而不是日常方案。
明白生命周期之后,你写代码的时候会自然地回答出这三个问题:我这段数据生命周期是多久?它应该属于哪一层?谁负责在哪个阶段释放它?很多人一辈子写PHP都没想过这三个问题,但想清楚它们,你会发现很多疑难杂症,解释起来就如庖丁解牛般游刃有余。
我个人在工作中最大的体会是:别等线上出问题了才去看生命周期,平时读扩展源码时顺手看看它的MINIT/RINIT/MSHUTDOWN回调,远比只读业务代码能学到更多东西。你不需要成为PHP内核专家,但了解这些阶段划分、知道它们和操作系统的对应关系,会让你的排障思路从根本上不一样。