【翻譯】PHP 垃圾回收機制

DarthMinion發表於2019-07-12

之所以做這個翻譯,是因為官網的中文文件和英文原始文件有些不一致,另外,把英文翻譯過來看,好像可以有更好地理解。
若發現錯誤,請隨時指出。
若有建議,請隨時提出。
感謝支援!

引用計數器的基本概念

PHP 變數是存放於一個叫做 "zval" 的容器中。zval 容器包含了變數的型別和值以及附加的兩位 (bit) 資訊。第一個叫做 "is_ref" 是個 bool 值,表示變數是否為 "reference set" 的一部分。通過這個位資訊,PHP 引擎即可區分該變數是普通變數還是引用。由於 PHP 允許使用者 (user-land) 通過 & 操作符來建立引用,zval 容器也包含了一個內部的引用計數機制用來優化記憶體使用。第二個位資訊叫做 "refcount",它包含指向這個 zval 容器的變數名稱 (也叫符號 (symbols) ) 個數。所有的符號都存放在符號表 (symbol table) 中,其中,每個符號都有自己的作用域 (scope)。對於主指令碼 (例: 被瀏覽器請求的指令碼) 和每個函式或方法也都有作用域。

當一個變數被賦常量值時,就會生成一個 zval 變數容器,像這樣:

$a = "new string";

這個例子中,一個新的符號名 a 在當前作用域被建立了,也有一個新的變數容器被建立了,其型別是 string,值是 "new string"。"is_ref" 位預設是 FALSE,因為還沒有引用被使用者建立。"refcount" 為 1 因為只有一個符號在使用這個變數容器。注意,如果 "refcount" 為 1,"is_ref" 只會為 FALSE。如果你安裝了 Xdebug,亦可以呼叫 xdebug_debug_zval() 方法來展示資訊。

$a = "new string";
xdebug_debug_zval('a');

上面的例子將輸出:

a: (refcount=1, is_ref=0)='new string'

將這個變數賦值給另一個變數會增加 "refcount"。

$a = "new string";
$b = $a;
xdebug_debug_zval( 'a' );

上面的例子將輸出:

a: (refcount=2, is_ref=0)='new string'

現在 "refcount" 是 2,因為同一個變數容器被關聯 (linked) 到了 a 和 b。PHP 足夠聰明,在非必要時不會去拷貝實際的變數容器。容器變數在 "refcount" 減至 0 時被銷燬。當有關聯的變數容器的符號離開了作用域 (如: 函式結束時) 或者被取消賦值 (如: 被 unset() 時) 時,"refcount" 會減 1。下面的例子就能說明:

$a = "new string";
$c = $b = $a;
xdebug_debug_zval( 'a' );
$b = 42;
xdebug_debug_zval( 'a' );
unset( $c );
xdebug_debug_zval( 'a' );

上面的例子將輸出:

a: (refcount=3, is_ref=0)='new string'
a: (refcount=2, is_ref=0)='new string' // when $b assigned by a new value
a: (refcount=1, is_ref=0)='new string' // when $c is unset()

如果我們現在呼叫 unset($a);,容器變數(包括型別和值)將從記憶體中被移除。

複合型別

對於複合型別而言,比如陣列和物件,事情會變得稍微複雜些。和標量 (scalar) 值相反,陣列和物件將自己的成員存放在自己的符號表中。這意味著下面的示例建立了 3 個 zval 容器:

$a = ['meaning' => 'life','number' => 42];
xdebug_debug_zval( 'a' );

上面的例子將輸出類似這樣的東西:

a: (refcount=1, is_ref=0)=array (
   'meaning' => (refcount=1, is_ref=0)='life',
   'number' => (refcount=1, is_ref=0)=42
)

或者是這樣的圖示:

image

3 個 zval 容器分別是: a,meaning 和 number。相似的規則同樣可用來減少 "recounts"。下面,我們新增另一個元素到陣列中,並將值設定為一個已存在元素的內容:

$a = ['meaning' => 'life','number' => 42];
$a['life'] = $a['meaning'];
xdebug_debug_zval( 'a' );

上面的例子將輸出類似這樣的東西:

a: (refcount=1, is_ref=0)=array (
   'meaning' => (refcount=2, is_ref=0)='life',
   'number' => (refcount=1, is_ref=0)=42,
   'life' => (refcount=2, is_ref=0)='life'
)

或者是這樣的圖示:

image

從上述 Xdebug 輸出中,我們可以看出新老陣列元素現在指向了一個 "refcount" 為 2 的zval 容器。雖然 Xdebug 的輸出中有兩個值為 "life" 的 zval 容器,但是他們其實同一個。雖然 xdebug_debug_zval() 函式不會說明這個,但你可以通過檢視記憶體指標來分辨。

從陣列中移除元素就像是將符號從作用域中移除一樣。移除後,陣列元素所指向的容器的 "refcount" 會被減少。同樣,當 "refcount" 減至 0 時,變數容器將會從記憶體中移除。下面的例子可以說明:

$a = ['meaning' => 'life', 'number' => 42];
$a['life'] = $a['meaning'];
unset( $a['meaning'], $a['number'] );
xdebug_debug_zval( 'a' );

上面的例子將輸出類似這樣的東西:

a: (refcount=1, is_ref=0)=array (
   'life' => (refcount=1, is_ref=0)='life'
)

現在,如果我們將陣列本身作為一個元素新增到陣列中,事情就變得有趣了,我們將在下面的例子中這麼做,而且會悄悄地新增一個引用操作符,不然 PHP 會建立一個拷貝:

$a = ['one'];
$a[] = &$a;
xdebug_debug_zval( 'a' );

上面的例子將輸出類似這樣的東西:

a: (refcount=2, is_ref=1)=array (
   0 => (refcount=1, is_ref=0)='one',
   1 => (refcount=2, is_ref=1)=...
)

或者是這樣的圖示:

image

可以看出陣列變數 (a) 和 第二個元素 (1) 現在都指向了一個 "refcount" 為 2 的變數容器。上面的 "..." 表示傳送了遞迴,當然,在這裡意味著指回了起源陣列。

和之前一樣,對一個變數進行 unset() 會移除其符號,其指向的變數容器的引用數 (reference count) 將被減 1。所以如果我們在上述程式碼後面 unset 變數 $a,那麼 $a 和 元素 (1) 所指向的變數容器的引用數就會減 1,由 "2" 變為 "1"。可以這樣呈現:

(refcount=1, is_ref=1)=array (
   0 => (refcount=1, is_ref=0)='one',
   1 => (refcount=1, is_ref=1)=...
)

或者是這樣的圖示:

image

清理問題

雖然在任何作用域中都沒有符號指向這個結構了,但是它無法被清理掉,因為陣列元素 "1" 仍然指向自己本身。由於沒有額外的符號指向它,所以對於使用者來講,是無法清理掉這個結構的,於是你就遇上了記憶體洩露。幸運的是,PHP 會在請求結束時清理掉這個資料結構,但在這之前,將會佔去寶貴的記憶體空間。如果你在實現解析演算法或者其他東西時將子元素指回了父元素,這種情況就會經常發生。當然,同樣的情況也會發生在物件身上,而且可能性更高,因為物件總是隱式地被引用。

這樣的情況發生一兩次倒也不是問題,但如果發生上千次或者幾十萬次的記憶體流失,這明顯就成問題了。這樣的問題往往發生在長時間執行的指令碼中,比如守護程式 (請求基本上永遠不會結束) 或者大量的單元測試。後者,在對 eZ Components 庫的 Template 組建做單元測試時,有時會需要使用超過 2GB 的記憶體,而測試服務也許無法滿足,這便是問題。

回收週期

從傳統上講,PHP 以往使用的引用計數記憶體機制,無法定位迴圈引用記憶體洩露,然而自 5.3.0 起,PHP 通過實現 引用計數系統中的併發週期回收(Concurrent Cycle Collection in Reference Counted Systems) 的同步演算法來解決了這個問題。

雖然對演算法的完全說明有點超出這部分內容的範圍,但基本的解釋是有的。首先我們要建立一些基本原則。如果 "refcount" 增加了,zval 容器仍在被使用,所以這不是垃圾。如果 "refcount" 被減少了,並且被減至 0,則 zval 可以被釋放。這意味著,只有當 "refcount" 被減少至非零時,垃圾週期 (garbage cycles) 才可以被產生。其次,在一次垃圾週期中,是有可能通過判斷 "refcount" 是否可以被減 1,以及哪些 zval 的 "refcount" 是 0 的方式,來發現垃圾的。

image

為避免發生檢查所有 refcount 可能減少的垃圾週期,該演算法把所有可能的root (possible roots),即 zval 放進 "root buffer" (以紫色示意) 中。同時也確保每個可能是垃圾的 root 在 root buffer 中只出現一次。只有當 root buffer 達到飽和,回收機制才會對裡面所有不同的 zval 啟動。詳見上圖步驟 A。

在步驟 B 中,演算法針對所有可能的 root 執行一次深度優先搜尋,找到 zval 後對其 refcount 減 1,並確保不會在同一個 zval 上重複執行 (以灰色示意)。在步驟 C 中,演算法再次對每個 root 節點進行深度優先搜尋,再次檢索每個 zval 的 refcount 值。如果發現 refcount 為 0,zval 則被標記成 "白色" (藍色部分)。如果 refcount 大於 0,則演算法將從此處執行深度優先搜尋並回滾 refcount 減 1 操作,並將這些 zval 重新標記為 "黑色"。在最後的步驟 D 中,演算法遍歷整個root buffer,從中移除 zval root,同時檢索出之前步驟中被標記為 "白色" 的 zval。每個被標記為 "白色" 的 zval 都將被釋放。

現在你對演算法是如何工作已經有了基本的認識,我們回過頭來看它是如何與PHP整合的。預設情況下,PHP 的垃圾回收機制 (garbage collector) 是開啟的。然而 php.ini 配置檔案允許你做出修改: zend.enable_gc

當 GC 開啟時,一旦 root buffer 達到飽和,上述的迴圈查詢演算法就會被執行。root buffer 固定可存放 10,000 個 root (雖然你可以通過修改位於 PHP 原始碼 Zend/zend_gc.c 中的 GC_ROOT_BUFFER_MAX_ENTRIES 常量,然後重編譯 PHP 來改變這個數值)。當 GC 關閉時,迴圈查詢演算法將不會啟動。然而,可能的 root 將永遠記錄在 root buffer 裡,不管是否在配置中開啟了 GC。

如果在 GC 關閉的情況下, root buffer 達到飽和,後續的可能的 root 就不會被記錄下來。那些無法被記錄的可能的 root 將永遠無法被演算法分析。如果它們存在迴圈引用,他們講永遠無法被清理掉,並造成記憶體洩露。

為什麼在GC關閉的情況下,還是會有 root 被記錄呢?是因為記錄這些 root 要比在每次找到root時判斷GC是否開啟更快。然而,垃圾回收與分析機制本身可能會消耗相當長的時間。

除了修改 zend.enable_gc 配置,同樣也可以通過呼叫 gc_enable() 或 gc_disable() 來控制垃圾回收機制的開與關。呼叫這些函式和修改配置是等效的。這同樣也可以用來強制控制垃圾回收即便 root buffer 尚未飽和。你可以使用 gc_collect_cycles() 函式實現。該函式將返回被演算法收集的週期數量。

允許開啟和關閉垃圾回收機制並且允許自主初始化的原因,是由於你的應用程式的某部分可能是高時效性的。在這種情況下,你可能不想使用垃圾回收機制。當然,對你的應用程式的某部分關閉垃圾回收機制,是在冒著可能發生記憶體洩漏的風險,因為一些可能 root 也許存不進有限的 root buffer。因此,就在你呼叫 gc_disable() 函式釋放記憶體之前,先呼叫 gc_collect_cycles() 函式可能比較明智。因為這將清除已存放在 root buffer 中的所有可能 root,然後在垃圾回收機制被關閉時,可留下空 buffer 以有更多空間儲存可能 root。

相關文章