淺談PHP5中垃圾回收演算法(Garbage Collection)的演化

pythontab發表於2014-10-21

前言

PHP是一門託管型語言,在PHP程式設計中程式設計師不需要手工處理記憶體資源的分配與釋放(使用C編寫PHP或Zend擴充套件除外),這就意味著PHP本身實現了垃圾回收機制(Garbage Collection)。現在如果去PHP官方網站(php.net)可以看到,目前PHP5的兩個分支版本PHP5.2和PHP5.3是分別更新的,這是因為許多專案仍然使用5.2版本的PHP,而5.3版本對5.2並不是完全相容。PHP5.3在PHP5.2的基礎上做了諸多改進,其中垃圾回收演算法就屬於一個比較大的改變。本文將分別討論PHP5.2和PHP5.3的垃圾回收機制,並討論這種演化和改進對於程式設計師編寫PHP的影響以及要注意的問題。

PHP變數及關聯記憶體物件的內部表示

垃圾回收說到底是對變數及其所關聯記憶體物件的操作,所以在討論PHP的垃圾回收機制之前,先簡要介紹PHP中變數及其記憶體物件的內部表示(其C原始碼中的表示)。

PHP官方文件中將PHP中的變數劃分為兩類:標量型別和複雜型別。標量型別包括布林型、整型、浮點型和字串;複雜型別包括陣列、物件和資源;還有一個NULL比較特殊,它不劃分為任何型別,而是單獨成為一類。

所有這些型別,在PHP內部統一用一個叫做zval的結構表示,在PHP原始碼中這個結構名稱為“_zval_struct”。zval的具體定義在PHP原始碼的“Zend/zend.h”檔案中,下面是相關程式碼的摘錄。

typedef union _zvalue_value {
    long lval;                  /* long value */
    double dval;                /* double value */
    struct {
        char *val;
        int len;
    } str;
    HashTable *ht;              /* hash table value */
    zend_object_value obj;
} zvalue_value;
 
struct _zval_struct {
    /* Variable information */
    zvalue_value value;     /* value */
    zend_uint refcount__gc;
    zend_uchar type;    /* active type */
    zend_uchar is_ref__gc;
};

其中聯合體“_zvalue_value”用於表示PHP中所有變數的值,這裡之所以使用union,是因為一個zval在一個時刻只能表示一種型別的變數。可以看到_zvalue_value中只有5個欄位,但是PHP中算上NULL有8種資料型別,那麼PHP內部是如何用5個欄位表示8種型別呢?這算是PHP設計比較巧妙的一個地方,它通過複用欄位達到了減少欄位的目的。例如,在PHP內部布林型、整型及資源(只要儲存資源的識別符號即可)都是通過lval欄位儲存的;dval用於儲存浮點型;str儲存字串;ht儲存陣列(注意PHP中的陣列其實是雜湊表);而obj儲存物件型別;如果所有欄位全部置為0或NULL則表示PHP中的NULL,這樣就達到了用5個欄位儲存8種型別的值。

而當前zval中的value(value的型別即是_zvalue_value)到底表示那種型別,則由“_zval_struct”中的type確定。_zval_struct即是zval在C語言中的具體實現,每個zval表示一個變數的記憶體物件。除了value和type,可以看到_zval_struct中還有兩個欄位refcount__gc和is_ref__gc,從其字尾就可以斷定這兩個傢伙與垃圾回收有關。沒錯,PHP的垃圾回收全靠這倆欄位了。其中refcount__gc表示當前有幾個變數引用此zval,而is_ref__gc表示當前zval是否被按引用引用,這話聽起來很拗口,這和PHP中zval的“Write-On-Copy”機制有關,由於這個話題不是本文重點,因此這裡不再詳述,讀者只需記住refcount__gc這個欄位的作用即可。

PHP5.2中的垃圾回收演算法——Reference Counting

PHP5.2中使用的記憶體回收演算法是大名鼎鼎的Reference Counting,這個演算法中文翻譯叫做“引用計數”,其思想非常直觀和簡潔:為每個記憶體物件分配一個計數器,當一個記憶體物件建立時計數器初始化為1(因此此時總是有一個變數引用此物件),以後每有一個新變數引用此記憶體物件,則計數器加1,而每當減少一個引用此記憶體物件的變數則計數器減1,當垃圾回收機制運作的時候,將所有計數器為0的記憶體物件銷燬並回收其佔用的記憶體。而PHP中記憶體物件就是zval,而計數器就是refcount__gc。

例如下面一段PHP程式碼演示了PHP5.2計數器的工作原理(計數器值通過xdebug得到):

<?php
 
$val1 = 100; //zval(val1).refcount_gc = 1;
$val2 = $val1; //zval(val1).refcount_gc = 2,zval(val2).refcount_gc = 2(因為是Write on copy,當前val2與val1共同引用一個zval)
$val2 = 200; //zval(val1).refcount_gc = 1,zval(val2).refcount_gc = 1(此處val2新建了一個zval)
unset($val1); //zval(val1).refcount_gc = 0($val1引用的zval再也不可用,會被GC回收)
 
?>
Reference Counting簡單直觀,實現方便,但卻存在一個致命的缺陷,就是容易造成記憶體洩露。很多朋友可能已經意識到了,如果存在迴圈引用,那麼Reference Counting就可能導致記憶體洩露。例如下面的程式碼:
<?php
$a = array();
$a[] = & $a;
unset($a);
 
?>

這段程式碼首先建立了陣列a,然後讓a的第一個元素按引用指向a,這時a的zval的refcount就變為2,然後我們銷燬變數a,此時a最初指向的zval的refcount為1,但是我們再也沒有辦法對其進行操作,因為其形成了一個迴圈自引用,如下圖所示:

image

其中灰色部分表示已經不復存在。由於a之前指向的zval的refcount為1(被其HashTable的第一個元素引用),這個zval就不會被GC銷燬,這部分記憶體就洩露了。

這裡特別要指出的是,PHP是通過符號表(Symbol Table)儲存變數符號的,全域性有一個符號表,而每個複雜型別如陣列或物件有自己的符號表,因此上面程式碼中,a和a[0]是兩個符號,但是a儲存在全域性符號表中,而a[0]儲存在陣列本身的符號表中,且這裡a和a[0]引用同一個zval(當然符號a後來被銷燬了)。希望讀者朋友注意分清符號(Symbol)的zval的關係。

在PHP只用於做動態頁面指令碼時,這種洩露也許不是很要緊,因為動態頁面指令碼的生命週期很短,PHP會保證當指令碼執行完畢後,釋放其所有資源。但是PHP發展到目前已經不僅僅用作動態頁面指令碼這麼簡單,如果將PHP用在生命週期較長的場景中,例如自動化測試指令碼或deamon程式,那麼經過多次迴圈後積累下來的記憶體洩露可能就會很嚴重。這並不是我在聳人聽聞,我曾經實習過的一個公司就通過PHP寫的deamon程式來與資料儲存伺服器互動。

由於Reference Counting的這個缺陷,PHP5.3改進了垃圾回收演算法。

PHP5.3中的垃圾回收演算法——Concurrent Cycle Collection in Reference Counted Systems

PHP5.3的垃圾回收演算法仍然以引用計數為基礎,但是不再是使用簡單計數作為回收準則,而是使用了一種同步回收演算法,這個演算法由IBM的工程師在論文Concurrent Cycle Collection in Reference Counted Systems中提出。

這個演算法可謂相當複雜,從論文29頁的數量我想大家也能看出來,所以我不打算(也沒有能力)完整論述此演算法,有興趣的朋友可以閱讀上面的提到的論文(強烈推薦,這篇論文非常精彩)。

我在這裡,只能大體描述一下此演算法的基本思想。

首先PHP會分配一個固定大小的“根緩衝區”,這個緩衝區用於存放固定數量的zval,這個數量預設是10,000,如果需要修改則需要修改原始碼Zend/zend_gc.c中的常量GC_ROOT_BUFFER_MAX_ENTRIES然後重新編譯。

由上文我們可以知道,一個zval如果有引用,要麼被全域性符號表中的符號引用,要麼被其它表示複雜型別的zval中的符號引用。因此在zval中存在一些可能根(root)。這裡我們暫且不討論PHP是如何發現這些可能根的,這是個很複雜的問題,總之PHP有辦法發現這些可能根zval並將它們投入根緩衝區。

當根緩衝區滿額時,PHP就會執行垃圾回收,此回收演算法如下:

1、對每個根緩衝區中的根zval按照深度優先遍歷演算法遍歷所有能遍歷到的zval,並將每個zval的refcount減1,同時為了避免對同一zval多次減1(因為可能不同的根能遍歷到同一個zval),每次對某個zval減1後就對其標記為“已減”。

2、再次對每個緩衝區中的根zval深度優先遍歷,如果某個zval的refcount不為0,則對其加1,否則保持其為0。

3、清空根緩衝區中的所有根(注意是把這些zval從緩衝區中清除而不是銷燬它們),然後銷燬所有refcount為0的zval,並收回其記憶體。

如果不能完全理解也沒有關係,只需記住PHP5.3的垃圾回收演算法有以下幾點特性:

1、並不是每次refcount減少時都進入回收週期,只有根緩衝區滿額後在開始垃圾回收。

2、可以解決迴圈引用問題。

3、可以總將記憶體洩露保持在一個閾值以下。

PHP5.2與PHP5.3垃圾回收演算法的效能比較

由於我目前條件所限,我就不重新設計試驗了,而是直接引用PHP Manual中的實驗,關於兩者的效能比較請參考PHP Manual中的相關章節:http://www.php.net/manual/en/features.gc.performance-considerations.php。

首先是記憶體洩露試驗,下面直接引用PHP Manual中的實驗程式碼和試驗結果圖:

<?php
class Foo
{
    public $var = '3.1415962654';
}
 
$baseMemory = memory_get_usage();
 
for ( $i = 0; $i <= 100000; $i++ )
{
    $a = new Foo;
    $a->self = $a;
    if ( $i % 500 === 0 )
    {
        echo sprintf( '%8d: ', $i ), memory_get_usage() - $baseMemory, "\n";
    }
}
?>

PHP記憶體洩露試驗

PHP記憶體洩露試驗

可以看到在可能引發累積性記憶體洩露的場景下,PHP5.2發生持續累積性記憶體洩露,而PHP5.3則總能將記憶體洩露控制在一個閾值以下(與根緩衝區大小有關)。

另外是關於效能方面的對比:

<?php
class Foo
{
    public $var = '3.1415962654';
}
 
for ( $i = 0; $i <= 1000000; $i++ )
{
    $a = new Foo;
    $a->self = $a;
}
 
echo memory_get_peak_usage(), "\n";
?>

這個指令碼執行1000000次迴圈,使得延遲時間足夠進行對比。

然後使用CLI方式分別在開啟記憶體回收和關閉記憶體回收的的情況下執行此指令碼:

time php -dzend.enable_gc=0 -dmemory_limit=-1 -n example2.php
# and
time php -dzend.enable_gc=1 -dmemory_limit=-1 -n example2.php

在我的機器環境下,執行時間分別為6.4s和7.2s,可以看到PHP5.3的垃圾回收機制會慢一些,但是影響並不大。

與垃圾回收演算法相關的PHP配置

可以通過修改php.ini中的zend.enable_gc來開啟或關閉PHP的垃圾回收機制,也可以通過呼叫gc_enable()或gc_disable()開啟或關閉PHP的垃圾回收機制。在PHP5.3中即使關閉了垃圾回收機制,PHP仍然會記錄可能根到根緩衝區,只是當根緩衝區滿額時,PHP不會自動執行垃圾回收,當然,任何時候您都可以通過手工呼叫gc_collect_cycles()函式強制執行記憶體回收。


相關文章