PHP 扩展生命周期:MINIT、MSHUTDOWN、RINIT、RSHUTDOWN 的完整执行流程

很多 PHP 开发者写过扩展,但真正理解扩展生命周期的人并不多。为什么扩展里定义的全局变量在请求间会“串数据”?为什么在 MINIT 里连接数据库会出问题?这些坑的根源,都在 MINIT、MSHUTDOWN、RINIT、RSHUTDOWN 这四个钩子的执行时机和语义上。本文从 PHP 的 SAPI 启动模型出发,把整个流程拆开讲清楚。

一、PHP 的两层生命周期

要理解这四个钩子,先要理解 PHP 生命周期分为两层:

  1. 模块初始化层(Module Init):每个扩展被加载时执行一次,与请求无关。
  2. 请求初始化层(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_GLOBALSZEND_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 的完整执行流程

赞 (0) 打赏

评论 0

取消
  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址

觉得文章有用就打赏一下文章作者

支付宝扫一扫打赏

微信扫一扫打赏