乐趣区

关于php:PHP内存泄漏看这一篇就够了

FPM 的黑魔法

首先,传统的跑在 FPM 下的 PHP 代码是没有 “内存透露” 一说的,所谓的内存透露就是遗记开释内存,导致过程占用的 物理内存 (附 1) 继续 增长,得益于 PHP 的短生命周期,PHP 内核有一个要害函数叫做 php_request_shutdown 此函数会在申请完结后,把申请期间申请的所有内存都开释掉,这从根本上杜绝了内存透露,极大的进步了 PHPer 的开发效率,同时也会导致性能的降落,例如单例对象,没必要每次申请都从新申请开释这个单例对象的内存。(这也是 Swoolecli计划的劣势之一,因为 cli 申请完结不会清理内存)。

Cli 下的内存透露

置信 PHPer 都遇见过这个报错Fatal error: Allowed memory size of 134217728 bytes exhausted (tried to allocate 12288 bytes),是因为向 PHP 申请的内存达到了下限导致的,在 FPM 下肯定是因为这次 web 申请有大内存块申请,例如 Sql 查问返回一个超大后果集,但在 Cli 下报这个错大概率是因为你的 PHP 代码呈现了内存透露。

常见的透露姿态有:

  • 向类的动态属性中追加数据,例如:
// 不停的调用 foo() 内存就会始终涨
function foo(){ClassA::$pro[] = "the big string";
}
  • 向 $GLOBAL 全局变量中追加数据,例如:
// 不停的调用 foo() 内存就会始终涨
function foo(){$GLOBAL['arr'][] = "the big string";}
  • 向函数的动态变量中追加数据,例如:
// 不停的调用 foo() 内存就会始终涨
function foo(){static $arr = [];
        $arr[] = "the big string";}

咱们须要检测工具

有的同学可能会说很简略嘛,把追加的变量在申请完结后 unset() 掉就能够了。但实在场景远没有你想的那么简略:

  • 例一:
function foo()
{$obj = new ClassA(); //foo 函数完结后将主动开释 $obj 对象
    $obj->pro[] = str_repeat("big string", 1024);
}

while (1) {foo();
    sleep(1);
}

上述代码 Cli 运行起来会透露吗?肉眼来看必定不会透露,因为 foo() 函数完结后 $obj 是栈上的对象主动开释掉了,但答案是可能透露也可能没透露,这取决于 ClassA 的定义:

class classA
{
    public $pro;
    public function __construct()
    {$this->pro = &$GLOBALS['arr']; //pro 是其余变量的援用
    }
}

如果 ClassA 的定义是下面的样子,那么这个例子就是透露的!!

  • 例二:
class Test
{
    public $pro = null;
    function run()
    {
        $var = "Im global var now";// 此处 $var 是长生命周期。$http = new \Swoole\Http\Server("0.0.0.0", 9501, SWOOLE_BASE);
        $http->on("request", function($req, $resp) {
            // 此处没有给类的动态属性赋值,没有给全局变量赋值,// 也没有给函数的动态变量赋值,然而这里是透露的,因为 $this 变成长生命周期了。$this->pro[] = str_repeat("big string", 1024);
            $resp->end("hello world");
        });
        $http->start();
        echo "run done\n"; // 输入不了
        // 这个函数永远不会完结,局部变量也变成了 "全局变量"
    }

}
(new Test())->run();

new Test()的本意尽管是创立一个长期的对象,然而 run() 办法触发了 server->start() 办法,代码将不向下执行,run()函数完结不了,run()函数的局部变量 $var 和长期对象自身都能够视为全局变量了,给其追加数据都是透露的!!

  • 例三:

因为 php_request_shutdown 的存在,很多 PHP 扩大其实是有内存透露的(emalloc 后没有 efree),然而在 FPM 下是能够失常运行的,而这些扩大放到 Cli 下就会有内存透露问题,如果没有工具,Cli 下遇到扩大的透露问题,那也只能 gg 了 -.-!

还有就是当咱们调用第三方的类库的函数,要传一个参数,这个参数是全局变量,我不晓得这个第三方库会不会给这个参数追加数据,一旦追加数据就会产生透露,同理他人给我的函数传的参数我也不敢赋值,第三方函数的返回值有没有全局变量我也不晓得。

综上咱们须要一个检测工具,绝对于其余语言 PHP 在这个畛域是空白的,能够说没有这个工具整个 Cli 生态就无奈真正的倒退起来,因为简单的我的项目都会遇到透露问题。

Swoole Tracker 能够检测透露问题,但它是一款商业产品,当初咱们决定重构这个工具,把内存透露检测的性能(下文简称Leak 工具)完全免费给 PHP 社区应用,欠缺 PHP 生态,回馈社区,上面我将概述它的具体用法和工作原理。

Swoole Tracker 用法

Leak 工具 的实现原理是间接拦挡零碎底层的 emalloc,erealloc,以及 efree 调用,记录一个微小的指针表,emalloc/erealloc 的时候增加,efree 的时候删除表中的记录,如果申请完结,指针表中依然有值就证实产生了内存透露,不仅能发现 PHP 代码的透露,扩大层甚至 PHP 语言层面的透露都能发现,从根本上杜绝透露问题。

应用形式很简略:

  • 返回官网下载最新的 tracker(3.0+) 扩大。
  • php.ini 退出以下配置:
extension=swoole_tracker.so
; 总开关
apm.enable=1
;Leak 检测开关
apm.enable_malloc_hook=1
  • 在 Cli 模式下主业务逻辑肯定是能够形象成循环体函数的,例如 Swoole 的 OnReceive 函数,workerman 的 OnMessage 函数,以及上文例一中的 foo() 函数,在循环体主函数 (下文简称 主函数 ) 最开始加上 trackerHookMalloc() 调用即可:
function foo()
{trackerHookMalloc(); // 标记主函数,开始 hook malloc
    $obj = new ClassA();
    $obj->pro[] = str_repeat("big string", 1024);
}

while (1) {foo();
    sleep(1);
}

每次调用 主函数 完结后(第一次调用不会被记录),都会生成一个透露的信息到 /tmp/trackerleak 日志外面。

查看透露后果

在 Cli 命令行调用 trackerAnalyzeLeak() 函数即可剖析透露日志,生成透露报告,能够间接 php -r "trackerAnalyzeLeak();" 即可。

上面是透露报告的格局:

  • 没有内存透露的状况:

[16916 (Loop 5)] ✅ Nice!! No Leak Were Detected In This Loop

其中 16916 示意过程 id,Loop 5示意第 5 次调用 主函数 生成的透露信息

  • 有确定的内存透露:

[24265 (Loop 8)] /Users/guoxinhua/tests/mem_leak/http_server.php:125 => <span style=”color:red”>[12928]</span>
[24265 (Loop 8)] /Users/guoxinhua/tests/mem_leak/http_server.php:129 => <span style=”color:red”>[12928]</span>
[24265 (Loop 8)] ❌ This Loop TotalLeak: <span style=”color:red”>[25216]</span>

示意第 8 次调用 http_server.php 的 125 行和 129 行,别离透露了 12928 字节内存,总共透露了 25216 字节内存。

通过调用 trackerCleanLeak() 能够革除透露日志,从新开始。

技术个性(技术难点)

  • 反对持续增长检测:

设想一个场景,第一次申请运行 主函数 的时候申请 10 字节内存,而后申请完结前开释掉,而后第二次申请申请了 100 字节,申请完结再开释掉,尽管每次都能正确的开释内存然而每次又都申请更多的内存,最终导致内存爆掉,Leak 工具 反对这种检测,如果某一行代码有 N 次(默认 5 次) 这种行为就会报"可疑的内存透露",格局如下:

<span style=”color:#b0b05f”>The Possible Leak As Malloc Size Keep Growth:</span>
/Users/guoxinhua/tests/mem_leak/hook_malloc_incri.php:39 => <span style=”color:red”> Growth Times : [8]; Growth Size : [2304]</span>

示意 39 行有 8 次 malloc size 的增长,总共增长了 2304 字节。

  • 反对跨 loop 剖析:
//Swoole Http Server 的 OnRequest 回调
$http->on("request", function($request, $response) {trackerHookMalloc();

    if(isset(classA::$leak['tmp'])){unset(classA::$leak['tmp']);// 每一次 loop 都开释上一次 loop 申请的内存
        }

    classA::$leak['tmp'] = str_repeat("big string", 1024);// 申请内存 并在本次 loop 完结后不开释
    $response->end("hello world");
});

依照失常的检测透露的实践,上述代码每次都会检测出透露,因为每次都给 classA::$leak['tmp'] 赋值并在 Loop 完结也没有开释,但理论业务代码常常这样写,并且此代码也是不会产生透露的,因为本次 Loop 的透露会在下次开释掉,Leak 工具 跨相邻 2 个Loop 进行剖析,主动对冲下面这种状况的透露信息,如果是跨多个 Loop 的开释,会以如下格局输入:

[28316 (Loop 2)] /Users/guoxinhua/tests/mem_leak/hook_efree_pre_loop.php:37 => <span style=”color:red”>[-12288]</span>
<span style=”color:#5f92b0″>Free Pre (Loop 0) : /Users/guoxinhua/tests/mem_leak/hook_efree_pre_loop.php:42 => [12288]</span>
[28316 (Loop 2)] /Users/guoxinhua/tests/mem_leak/hook_efree_pre_loop.php:42 => <span style=”color:red”>[12288]</span>
[28316 (Loop 2)] ✅ Nice!! No Leak Were Detected In This Loop

上述信息示意 Loop 2 开释了 Loop 0 的 12288 字节内存,而后 Loop 2 又申请了 12288 字节内存,总体来说本次 Loop 跑下来没有内存透露。

  • 反对循环援用状况:

首先简略的介绍一下循环援用问题:

function foo()
{$o = new classA();
    $o->pro[] = $o;
    //foo 完结后 $o 无奈开释,因为本人援用了本人,即循环援用
}

while (1) {foo();
    sleep(1);
}

因为循环援用,下面的代码每次运行 foo() 内存都会增长,然而这个代码的确没有内存透露的,因为增长到肯定水平 PHP 会开启同步垃圾回收,把这种循环援用的内存都开释掉。

然而这给 Leak 工具 带来了麻烦,因为 $o 的变量是提早开释的,foo()完结后会报透露,而这种写法又的确不是透露。

Swoole TrackerLeak 工具 会自动识别下面的状况,会马上开释循环援用的内存,不会造成误报。

如果你发现你的过程内存始终涨,开启了 Tracker 的透露检测,通过 memory_get_usage(false); 打印发现内存不涨了,那么证实你的利用存在循环援用,并且原本就没有内存透露问题。

  • 反对子协程统计:
function loop()
{trackerHookMalloc();
      classA::$leak[] = str_repeat("big string", 1024);// 申请内存
    go(function() {echo co::getcid() . "child\n";
        go(function() {echo co::getcid()."child2\n";
          classA::$leak = [];// 开释内存});
    });
}

Co\run(function(){while (1) {loop();
        sleep(1);
    }
});

上述代码申请的内存会在第二个子协程外面开释,Leak 工具 会自动识别协程环境,会在所有子协程都完结后才统计汇总,所以上述代码不会有误报状况。

  • 反对 defer,context:
$http->on("request", function($request, $response) {trackerHookMalloc();

    $context = Co::getContext();
    $context['data'] = str_repeat("big string", 1024);//context 会在协程完结主动开释
    classA::$leak[] = str_repeat("big string1", 1024);
    defer(function() {classA::$leak = [];// 注册 defer 开释内存
    });
    $response->end("hello world");
});

Leak 工具 会自动识别协程环境,如果存在 defer 和 context,会在 defer 执行完结和 context 开释之后再统计汇总,所以上述代码不会有误报状况,当然如果下面没有注册 defer 也会正确的报告透露信息。

  • 反对旁路函数烦扰排除:

例如一个过程由 主函数 响应申请(OnRequest 等),而后还有个定时器在运行(旁路函数),咱们心愿检测的是主循环函数的透露状况,而当主循环函数执行到一半的时候定时器函数执行了, 并申请了内存,而后又切回到主循环函数,此时会误报,Leak 工具 会反对辨认出旁路函数而后不收集旁路函数的 malloc 数据。

除了上述这些,Leak 工具 还反对 internd string 抓取等等,在此不再开展。

留神

  • 前几次 Loop 的透露信息不必管,因为大部分我的项目都有一些初始化的缓存是不开释的。
  • 检测期间尽量不要有并发。
  • 因为开启透露检测后性能会十分差,不要在 php.ini 中开启 apm.enable_malloc_hook = 1 压测。
  • 和 Swoole Tracker2.x 的查看透露原理不一样,不能一起用。
  • 一个过程只能有一个中央调用 trackerHookMalloc() 函数。
  • Swoole4.5.3因为底层 api 有问题,Leak 工具 无奈失常工作,请降级到最新版 Swoole 或者降级 Swoole 版本。

附件:

  • 收费公开课 – 如何正确查看过程内存占用

退出移动版