扩展编译与内存泄漏

解读

国内一线/二线互联网公司 PHP 面试中,「扩展编译」与「内存泄漏」往往被放在同一题里考察,目的有三:

  1. 验证候选人是否真正动手写过 C 扩展,还是只停留在“会调用扩展”层面;
  2. 检查候选人是否具备 Linux 下 GCC、Makefile、gdb、Valgrind 等基础定位能力;
  3. 观察候选人能否把“C 层资源管理”与“PHP 层生命周期”对应起来,避免线上出现随着请求数上涨内存暴涨、进程被 OOM Kill 的故障。

因此,回答时要同时覆盖“编译流程”与“泄漏定位”两条线,并给出可落地的排查命令与代码级修复示例。

知识点

  1. PHP 扩展编译三件套:phpize、configure、make && make install
  2. config.m4 常用宏:PHP_ARG_WITH、PHP_ADD_LIBRARY、PHP_NEW_EXTENSION
  3. 扩展的内存管理宏:emalloc/efree、pemalloc/pefree、safe_emalloc
  4. 请求生命周期:MINIT → RINIT → 执行 → RSHUTDOWN → MSHUTDOWN
  5. Zend 内存管理器(ZendMM)与跟踪选项:USE_ZEND_ALLOC=0、valgrind --leak-check=full
  6. 常见泄漏场景:重复 zend_string_init 未释放、持久化链表未在 MSHUTDOWN 清理、自定义资源未注册正确的 dtor
  7. 国内线上排障工具链:Valgrind、perf、jemalloc 堆采样、Alibaba 的 dragonfly 调试扩展、Tideways/Xhprof 内存剖析
  8. 编码规范:PHP 内核 C 代码必须遵循 PHP Coding Standard(K&R 派生),否则合并补丁会被拒

答案

【扩展编译】

  1. 源码准备
    假设公司扩展名称为 myext,目录结构:
    myext/
    ├── config.m4
    ├── myext.c
    ├── php_myext.h
    └── tests/
    
  2. 编写最小 config.m4
    PHP_ARG_WITH(myext, for myext support,
    [  --with-myext             Include myext support])
    
    if test "$PHP_MYEXT" != "no"; then
      PHP_NEW_EXTENSION(myext, myext.c, $ext_shared)
    fi
    
  3. 编译安装
    $ phpize
    $ ./configure --with-myext
    $ make && make install
    
    安装成功后会提示扩展 so 路径,如 /usr/local/php/lib/php/extensions/no-debug-non-zts-20210902/myext.so
  4. 载入验证
    在 php.ini 追加 extension=myext,执行 php -m | grep myext 出现即 OK。

【内存泄漏定位】

  1. 构造可重复请求的 CLI 脚本 leak.php,循环调用待测函数 10 000 次,保证泄漏量可观测。
  2. 关闭 ZendMM,使用系统 malloc 方便 Valgrind 精确定位:
    $ USE_ZEND_ALLOC=0 valgrind --leak-check=full --log-file=val.log php leak.php
    
  3. 查看 val.log,若出现 definitely lost: 1,200,000 bytes in 10,000 blocks,记录堆栈最顶层为 myext.c:myext_concat_strings
  4. 回到源码,发现:
    PHP_FUNCTION(myext_concat_strings)
    {
        char *s1, *s2;
        size_t s1_len, s2_len;
        if (zend_parse_parameters(ZEND_NUM_ARGS(), "ss", &s1, &s1_len, &s2, &s2_len) == FAILURE) {
            return;
        }
        zend_string *zs = zend_string_alloc(s1_len + s2_len, 0);
        memcpy(ZSTR_VAL(zs), s1, s1_len);
        memcpy(ZSTR_VAL(zs) + s1_len, s2, s2_len);
        ZSTR_VAL(zs)[s1_len + s2_len] = '\0';
        RETURN_STR(zs);   // 正确做法
        // 漏写 RETURN_STR(zs); 导致 zs 没有任何 zval 指向,泄漏
    }
    
    修复:确保每块动态内存都有唯一所有者,并在合适位置 RETURN/RETVAL 或 efree。
  5. 对于持久化内存(pemalloc),一定在 MSHUTDOWN 或 GSHUTDOWN 阶段 pefree,并置空指针。
  6. 回归验证:再次 Valgrind,确认 definitely lost: 0 bytes

【加分项】

  • 如果扩展需要常驻链表,可使用 zend_llist,内置析构函数回调,避免手写链表忘记释放。
  • 线上不方便 Valgrind 时,可打开 jemalloc 的 profiling,通过 jeprof 生成 pdf 火焰图,快速定位泄漏热点。

拓展思考

  1. 多线程 CLI 模式下的泄漏:PHP 多数运行在 ZTS(Zend Thread Safety)环境,线程局部存储(TSRM)资源未释放同样会被 Valgrind 判定为 leak,需要注册 tsrm_ls 析构。
  2. 内存与性能权衡:频繁 emalloc/efree 小块内存会造成 heap fragmentation,可考虑一次性预分配内存池,使用自定义 memory slab。
  3. 国内云原生场景:Sidecar 模式跑在 128 MiB 容器的 php-fpm,泄漏 20 MiB 即会被 K8s OOMKill,扩展上线前必须做“内存上限测试”,即通过 wrk 压测 5 分钟,观察 RSS 是否收敛。
  4. 未来 PHP 8.4 的“Arena allocator”与“Copy-on-write string”可能改变字符串内存模型,写扩展时要关注 UPGRADING.INTERNALS,避免硬拷贝 ZendString。
  5. 安全合规:金融、支付公司对内存泄漏零容忍,上线流程要求“0 字节 definitely lost”报告截屏随代码 review 一起归档,面试时可主动提及此类流程,展示对国内合规要求的理解。