Valgrind 检测协程内存泄漏的步骤

解读

国内一线互联网公司在面试 PHP 后端/全栈岗位时,常把「协程」与「内存泄漏」放在一起追问,目的是验证候选人是否真正在生产环境用 Swoole/协程写过高并发服务,而不仅停留在「听说」层面。
Valgrind 在 CLI 下默认只能感知「操作系统线程」的内存映射,而 Swoole 的协程本质是「用户态线程」,调度器在单个 OS 线程内反复切换栈空间,导致 Valgrind 的堆栈回溯、泄漏计数容易误报或漏报。因此,面试官想听的是:

  1. 你清楚 Valgrind 的局限性;
  2. 你有一套「让 Valgrind 看得见协程」的可落地方案;
  3. 你能把检测结果与 PHP 内部内存管理(ZendMM、emalloc)关联起来,快速定位到是哪段 PHP/C 扩展代码泄漏。

知识点

  1. Zend Memory Manager:PHP 默认启用 ZendMM,用自定义的 emalloc/efree 接管 glibc,导致 Valgrind 无法直接统计「PHP 层面」的泄漏。
  2. Swoole 协程栈:默认 8 KB 私有栈,由 swoole_coroutine 在创建时 mmap 分配,销毁时 munmap;若 C 扩展在协程栈上分配堆块却忘记释放,Valgrind 会报「still reachable」或「definitely lost」。
  3. Valgrind 的线程模型:Valgrind 把每个「可调度实体」当线程,需要显式告诉它「协程切换」等价于「线程切换」,否则堆栈回溯会错位。
  4. debug 构建:必须关闭 ZendMM、开启 Swoole 的 --enable-debug--enable-sanitizer 选项,让内存直接走 glibc,才能被 Memcheck 捕捉。
  5. suppressions 文件:PHP/Swoole 自身会留下大量「一次型」分配,需要提前生成 suppressions,避免噪音淹没真正的业务泄漏。

答案

步骤以 CentOS 7 + PHP 8.2 + Swoole 5.1 为例,其他发行版同理:

  1. 编译「可被检测」的 PHP

    ./configure --disable-all --enable-cli --enable-debug \
                --disable-zend-memory-manager-override \
                --enable-mysqlnd --enable-pdo
    make -j$(nproc) && make install
    

    关键:--disable-zend-memory-manager-override 让 PHP 直接调用 glibc malloc/free,Valgrind 才能追踪。

  2. 编译「带调试符号」的 Swoole

    phpize
    ./configure --enable-debug --enable-sanitizer --enable-openssl
    make clean && make -j$(nproc) && make install
    

    打开 --enable-debug 后,Swoole 会定义 SW_USE_VALGRIND 宏,主动调用 VALGRIND_MALLOCLIKE_BLOCK/VALGRIND_FREELIKE_BLOCK 告知 Valgrind 协程栈的生命周期。

  3. 生成 PHP/Swoole 基准 suppressions
    先跑一段「啥也不做」的脚本,收集系统自身泄漏:

    valgrind --leak-check=full --show-leak-kinds=all \
             --gen-suppressions=all --log-file=php.supp \
             php -r "echo 'hello';"
    

    手工编辑 php.supp,只保留「definitely lost」为 0 的区块,其余删除,得到 valgrind-php.supp

  4. 编写最小协程泄漏示例

    <?php
    // leak.php
    Swoole\Runtime::enableCoroutine();
    go(function () {
        $obj = new stdClass;
        $obj->buf = str_repeat('A', 1024*1024);   // 1 MB
        // 故意不释放 $obj,让 Valgrind 捕捉
        Co::sleep(0.001);
    });
    

    保存为 leak.php,确保脚本会正常退出,协程栈被销毁。

  5. 运行 Valgrind

    USE_ZEND_ALLOC=0 valgrind --tool=memcheck \
         --leak-check=full --show-leak-kinds=all \
         --suppressions=valgrind-php.supp \
         --log-file=leak.log \
         php leak.php
    

    环境变量 USE_ZEND_ALLOC=0 二次确保 ZendMM 被关闭。

  6. 解读报告
    打开 leak.log,搜索「definitely lost: 1,048,576 bytes in 1 blocks」,堆栈会指向 zend_allocswoole_coroutine_createphp_execute_script,结合符号表可定位到 leak.php 第 6 行。

  7. 修复与复测
    在协程末尾显式 unset($obj) 或缩短生命周期,再次执行步骤 5,确认「definitely lost」降为 0,即完成闭环。

拓展思考

  1. 线上无法重启:Valgrind 拖慢 20~50 倍,只能在灰度容器或压测克隆机执行;生产环境建议开启 Swoole 的 tracker 模块,用 co::stats() 观察协程栈峰值,再抽样做离线 Valgrind。
  2. 与 AddressSanitizer 互补:ASan 编译开销 < 2 倍,适合 CI 阶段拦截;Valgrind 适合「偶发+难复现」的泄漏,二者结合可覆盖 95% 场景。
  3. 扩展开发注意:若在 coroutine_c 层用 sw_malloc 分配,需要配对调用 sw_free,并加 VALGRIND_MALLOCLIKE_BLOCK 宏,否则同样会被误报。