PHP內存泄漏看這一篇就夠了!

FPM 的黑魔法

首先,傳統的跑在 FPM 下的 PHP 代碼是沒有「內存泄漏」一說的,所謂的內存泄漏就是忘記釋放內存,致使進程佔用的物理內存(附1)持續增加,得益於 PHP 的短生命週期,PHP 內核有一個關鍵函數叫作php_request_shutdown此函數會在請求結束後,把請求期間申請的全部內存都釋放掉,這從根本上杜絕了內存泄漏,極大的提升了 PHPer 的開發效率,同時也會致使性能的降低,例如單例對象,不必每次請求都從新申請釋放這個單例對象的內存。(這也是Swoolecli方案的優點之一,由於 cli 請求結束不會清理內存)。php

Cli 下的內存泄漏

相信 PHPer 都碰見過這個報錯Fatal error: Allowed memory size of 134217728 bytes exhausted (tried to allocate 12288 bytes),是因爲向 PHP 申請的內存達到了上限致使的,在 FPM 下必定是由於此次 web 請求有大內存塊申請,例如 Sql 查詢返回一個超大結果集,但在 Cli 下報這個錯大機率是由於你的 PHP 代碼出現了內存泄漏。web

常見的泄漏姿式有:api

  • 向類的靜態屬性中追加數據,例如:
//不停的調用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 了-.-!

還有就是當咱們調用第三方的類庫的函數,要傳一個參數,這個參數是全局變量,我不知道這個第三方庫會不會給這個參數追加數據,一旦追加數據就會產生泄漏,同理別人給個人函數傳的參數我也不敢賦值,第三方函數的返回值有沒有全局變量我也不知道。swoole

綜上咱們須要一個檢測工具,相對於其餘語言 PHP 在這個領域是空白的,能夠說沒有這個工具整個 Cli 生態就沒法真正的發展起來,由於複雜的項目都會遇到泄漏問題。併發

Swoole Tracker能夠檢測泄漏問題,但它是一款商業產品,如今咱們決定重構這個工具,把內存泄漏檢測的功能(下文簡稱Leak工具)徹底免費給 PHP 社區使用,完善 PHP 生態,回饋社區,下面我將概述它的具體用法和工做原理。ide

Swoole Tracker 用法

Leak工具的實現原理是直接攔截系統底層的 emalloc,erealloc,以及 efree 調用,記錄一個巨大的指針表,emalloc/erealloc 的時候添加,efree 的時候刪除表中的記錄,若是請求結束,指針表中仍然有值就證實產生了內存泄漏,不只能發現 PHP 代碼的泄漏,擴展層甚至 PHP 語言層面的泄漏都能發現,從根本上杜絕泄漏問題。函數

使用方式很簡單:工具

extension=swoole_tracker.so
;總開關
apm.enable=1
;Leak檢測開關
apm.enable_malloc_hook=1
  • 在 Cli 模式下主業務邏輯必定是能夠抽象成循環體函數的,例如SwooleOnReceive函數,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日誌裏面。oop

查看泄漏結果

在 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版本。

附件:

image

相關文章
相關標籤/搜索