很多 PHP 开发者写过扩展,但真正理解扩展生命周期的人并不多。为什么扩展里定义的全局变量在请求间会“串数据”?为什么在 MINIT 里连接数据库会出问题?这些坑的根源,都在 MINIT、MSHUTDOWN、RINIT、RSHUTDOWN 这四个钩子的执行时机和语义上。本文从 PHP 的 SAPI 启动模型出发,把整个流程拆开讲清楚。
一、PHP 的两层生命周期
要理解这四个钩子,先要理解 PHP 生命周期分为两层:
- 模块初始化层(Module Init):每个扩展被加载时执行一次,与请求无关。
- 请求初始化层(Request Init):每个 HTTP 请求(或 CLI 每次执行)执行一次。
对应到扩展的 zend_module_entry 结构:
struct _zend_module_entry {
unsigned short size;
unsigned int zend_api;
const char *name;
const struct _zend_module_dep *deps;
const struct _zend_function_entry *functions;
zend_result (*module_startup_func)(INIT_FUNC_ARGS); // MINIT
zend_result (*module_shutdown_func)(SHUTDOWN_FUNC_ARGS); // MSHUTDOWN
zend_result (*request_startup_func)(INIT_FUNC_ARGS); // RINIT
zend_result (*request_shutdown_func)(SHUTDOWN_FUNC_ARGS);// RSHUTDOWN
...
};
这四个函数指针就是本文的主角。它们的调用顺序并非随意,而是由 PHP 的启动模型严格决定的。
二、MINIT:模块初始化
执行时机:PHP 进程启动、扩展被载入时,调用一次。
对于 mod_php(Apache 模块)模式,MINIT 在 Apache 子进程 fork 之后、处理第一个请求之前执行。对于 PHP-FPM,MINIT 在 master 进程启动、worker fork 之前执行(取决于 php_fpm 的具体阶段,实际上在 FPM 初始化阶段就完成)。对于 CLI,每次执行脚本都会走一遍完整的模块初始化。
MINIT 的典型用途:
- 注册常量(
REGISTER_*_CONSTANT) - 注册类、接口、trait
- 注册全局函数
- 初始化与请求无关的全局资源(如全局配置、只读缓存)
- 注册资源类型(
zend_register_list_destructors_ex)
关键约束:MINIT 中不能依赖请求上下文。此时 SG(request_info) 尚未填充,$_SERVER、$_GET 都不存在。在 MINIT 里连接数据库、读取请求头,都是错误的。
PHP_MINIT_FUNCTION(myext) {
REGISTER_LONG_CONSTANT("MYEXT_VERSION", 100, CONST_CS | CONST_PERSISTENT);
return SUCCESS;
}
返回 SUCCESS 表示初始化成功,返回 FAILURE 会导致扩展加载失败。
三、MSHUTDOWN:模块关闭
执行时机:PHP 进程退出、扩展卸载时,调用一次。
对应 MINIT 的清理工作:释放 MINIT 中分配的持久化内存(pemalloc 分配的内存)、注销资源类型、释放全局句柄。
注意:MSHUTDOWN 只在进程正常退出时调用。如果进程被 kill -9,不会执行。因此不能把关键数据落盘逻辑放在这里。
四、RINIT:请求初始化
执行时机:每个请求开始时调用。
这是扩展开发中最容易出错的地方。RINIT 在每个请求都会执行,因此:
- 与请求相关的全局状态必须在这里重置,否则会跨请求泄漏。
- 使用
ZEND_INIT_MODULE_GLOBALS或ZEND_TSRMLS_CACHE初始化的全局变量,需要在 RINIT 中显式赋值。
ZEND_DECLARE_MODULE_GLOBALS(myext)
PHP_RINIT_FUNCTION(myext) {
#if defined(ZTS) && defined(COMPILE_DL_MYEXT)
ZEND_TSRMLS_CACHE_UPDATE();
#endif
MYEXT_G(request_count) = 0;
MYEXT_G(user_data) = NULL;
return SUCCESS;
}
典型错误:在 MINIT 里初始化请求相关的全局变量。由于 MINIT 只执行一次,第二个请求拿到的就是上一个请求遗留的数据——这就是所谓的“串数据”问题。在 mod_php 下尤其明显,因为同一个进程会处理成百上千个请求。
RINIT 中还常见注册请求级资源、初始化请求级缓存、设置 ini 相关的运行时状态。
五、RSHUTDOWN:请求关闭
执行时机:每个请求结束时调用,在输出发送完毕、请求内存池即将释放之前。
RSHUTDOWN 的职责:
- 释放 RINIT 中分配的请求级资源(
emalloc分配的内存理论上由内存池自动回收,但外部资源如文件句柄、socket 需要手动关闭) - 将请求级数据写回持久化存储
- 清理请求级全局状态
PHP_RSHUTDOWN_FUNCTION(myext) {
if (MYEXT_G(user_data)) {
// 释放请求级资源
efree(MYEXT_G(user_data));
MYEXT_G(user_data) = NULL;
}
return SUCCESS;
}
注意:RSHUTDOWN 中不应再调用可能触发用户代码的函数(如 zend_call_function),因为此时执行环境已经开始销毁,行为不可预期。
六、完整执行流程
把四个钩子串起来,一个典型的 PHP-FPM 生命周期如下:
FPM master 启动
└─ 加载扩展 → MINIT(每个扩展一次)
└─ fork worker 进程
├─ 请求 1 到达 → RINIT → 执行脚本 → RSHUTDOWN
├─ 请求 2 到达 → RINIT → 执行脚本 → RSHUTDOWN
├─ 请求 3 到达 → RINIT → 执行脚本 → RSHUTDOWN
└─ ...
└─ worker 退出 → MSHUTDOWN(每个扩展一次)
可以看到:MINIT/MSHUTDOWN 与进程同生命周期,RINIT/RSHUTDOWN 与请求同生命周期。一个进程内,MINIT 和 MSHUTDOWN 各一次,RINIT 和 RSHUTDOWN 各 N 次(N 为该 worker 处理的请求数)。
CLI 模式下,每次执行脚本都是一个完整周期:MINIT → RINIT → 执行 → RSHUTDOWN → MSHUTDOWN。
七、常见陷阱与最佳实践
陷阱一:全局变量跨请求污染。 所有请求相关的全局状态必须在 RINIT 中重置。使用 ZEND_BEGIN_MODULE_GLOBALS 宏声明的全局变量,其初始值只在 MINIT 时设置一次。
陷阱二:在 MINIT 中做请求级操作。 任何依赖 $_SERVER、请求头、用户输入的操作都不能放在 MINIT。
陷阱三:在 RSHUTDOWN 中做耗时操作。 RSHUTDOWN 阻塞请求结束,耗时操作会拖慢响应。日志落盘、统计上报应异步化。
最佳实践:
- MINIT 中只做与请求无关的一次性初始化,分配持久内存用
pemalloc。 - RINIT 中重置所有请求级状态,分配请求内存用
emalloc。 - RSHUTDOWN 中释放外部资源,避免调用用户代码。
- MSHUTDOWN 中释放持久资源,注意进程异常退出时不会执行。
八、小结
MINIT、MSHUTDOWN、RINIT、RSHUTDOWN 四个钩子构成了 PHP 扩展的生命周期骨架。理解它们的关键在于区分“进程级”和“请求级”两个维度:MINIT/MSHUTDOWN 管进程,RINIT/RSHUTDOWN 管请求。绝大多数扩展 bug——全局变量串数据、资源泄漏、初始化顺序错误——都源于把这两层搞混。写扩展时,先问自己一句:这个操作是每个进程一次,还是每个请求一次?答案自然就清晰了。
未经允许不得转载:任鹏个人博客 » PHP 扩展生命周期:MINIT、MSHUTDOWN、RINIT、RSHUTDOWN 的完整执行流程

