
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内部计数和操作系统层面的RSSResident Set Size不是一回事。有时候PHP自己觉得内存用得很省但操作系统看到这个进程占用的RSS很高因为PHP向系统申请的内存不会用完就立刻还给系统。这提醒我们理解PHP生命周期必须同时理解操作系统进程的生命周期。PHP在请求生命周期结束后做的一些清理只是释放了自己内部管理的资源但C语言层面的内存池、操作系统的页缓存、文件描述符表都有各自的延迟释放逻辑。这正是很多线上问题“明明PHP没有内存泄漏但RSS一直涨”的根源。3. 第一刀模块初始化MINIT——一栋大楼的“主体框架浇筑”3.1 MINIT到底做了什么当PHP-FPM的worker进程被fork出来或者CLI模式下PHP解释器启动时Zend引擎会调用所有已加载模块的“模块初始化”回调函数Module InitializationMINIT。这一步做的事情可以概括为让所有扩展准备好它们需要的全局资源和环境。我拿最常见的扩展举例。你在php.ini里启用extensionredis.soMINIT阶段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 每次请求都从“重置”开始RINITRequest 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在什么时机出现MSHUTDOWNModule 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内核专家但了解这些阶段划分、知道它们和操作系统的对应关系会让你的排障思路从根本上不一样。