這部分將說明PHP 5.3的新的記憶體回收機制(也就是GC)的特點。
每個php變數存在一個叫”zval”的變數容器中。一個zval變數容器,除了包含變數的類型和值,還包括兩個位元組的額外資訊。第一個是”is_ref”,是個bool值,用來標識這個變數是否是屬於引用集合(reference set)。通過這個位元組,php引擎才能把普通變數和引用變數區分開來,由於php允許使用者通過使用&來使用自訂引用,zval變數容器中還有一個內部引用計數機制,來最佳化記憶體使用量。第二個額外位元組是”refcount”,用以表示指向這個zval變數容器的變數(也稱符號即symbol)個數。所有的符號存在一個符號表中,其中每個符號都有範圍(scope),那些主指令碼(比如:通過瀏覽器請求的的指令碼)和每個函數或者方法也都有範圍。
當一個變數被賦常量值時,就會產生一個zval變數容器,如下例這樣:
Example #1 Creating a new zval container
<?php
$a = "new string";
?>
在上例中,新的變數a,是在當前範圍中產生的。並且產生了類型為 string 和值為new string的變數容器。在額外的兩個位元組資訊中,”is_ref”被預設設定為 FALSE,因為沒有任何自訂的引用產生。”refcount” 被設定為 1,因為這裡只有一個變數使用這個變數容器. 注意到當”refcount”的值是1時,”is_ref”的值總是FALSE. 如果你已經安裝了» Xdebug,你能通過調用函數 xdebug_debug_zval()顯示”refcount”和”is_ref”的值。
Example #2 Displaying zval information
<?php
xdebug_debug_zval('a');
?>
以上常式會輸出:
a: (refcount=1, is_ref=0)='new string'
把一個變數賦值給另一變數將增加引用次數(refcount).
Example #3 Increasing refcount of a zval
<?php
$a = "new string";
$b = $a;
xdebug_debug_zval( 'a' );
?>
以上常式會輸出:
a: (refcount=2, is_ref=0)='new string'
這時,引用次數是2,因為同一個變數容器被變數 a 和變數 b關聯.當沒必要時,php不會去複製已產生的變數容器。變數容器在”refcount“變成0時就被銷毀. 當任何關聯到某個變數容器的變數離開它的範圍(比如:函數執行結束),或者對變數調用了函數 unset()時,”refcount“就會減1,下面的例子就能說明:
Example #4 Decreasing zval refcount
<?php
$a = "new string";
$c = $b = $a;
xdebug_debug_zval( 'a' );
unset( $b, $c );
xdebug_debug_zval( 'a' );
?>
以上常式會輸出:
a: (refcount=3, is_ref=0)='new string'
a: (refcount=1, is_ref=0)='new string'
如果我們現在執行 unset($a);,包含類型和值的這個變數容器就會從記憶體中刪除。
複合類型(Compound Types)
當考慮像 array和object這樣的複合類型時,事情就稍微有點複雜. 與 標量(scalar)類型的值不同,array和 object類型的變數把它們的成員或屬性存在自己的符號表中。這意味著下面的例子將產生三個zval變數容器。
Example #5 Creating a array zval
<?php
$a = array( '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
)
這三個zval變數容器是: a,meaning和 number。增加和減少”refcount”的規則和上面提到的一樣. 下面, 我們在數組中再添加一個元素,並且把它的值設為數組中已存在元素的值:
Example #6 Adding already existing element to an array
<?php
$a = array( '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'
)
從以上的xdebug輸出資訊,我們看到原有的數組元素和新添加的數組元素關聯到同一個”refcount”2的zval變數容器. 儘管 Xdebug的輸出顯示兩個值為‘life’的 zval 變數容器,其實是同一個。 函數xdebug_debug_zval()不顯示這個資訊,但是你能通過顯示記憶體指標資訊來看到。
刪除數組中的一個元素,就是類似於從範圍中刪除一個變數. 刪除後,數組中的這個元素所在的容器的“refcount”值減少,同樣,當“refcount”為0時,這個變數容器就從記憶體中被刪除,下面又一個例子可以說明:
Example #7 Removing an element from an array
<?php
$a = array( '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將產生一個複製。
Example #8 Adding the array itself as an element of it self
<?php
$a = array( '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)=...
)
能看到陣列變數 (a) 同時也是這個數組的第二個元素(1) 指向的變數容器中“refcount”為 2。上面的輸出結果中的”…”說明發生了遞迴操作, 顯然在這種情況下意味著”…”指向原始數組。
跟剛剛一樣,對一個變數調用unset,將刪除這個符號,且它指向的變數容器中的引用次數也減1。所以,如果我們在執行完上面的代碼後,對變數$a調用unset, 那麼變數 $a 和數組元素 “1″ 所指向的變數容器的引用次數減1, 從”2″變成”1″. 下例可以說明:
Example #9 Unsetting $a
(refcount=1, is_ref=1)=array (
0 => (refcount=1, is_ref=0)='one',
1 => (refcount=1, is_ref=1)=...
)
清理變數容器的問題(Cleanup Problems)
儘管不再有某個範圍中的任何符號指向這個結構(就是變數容器),由於數組元素“1”仍然指向數組本身,所以這個容器不能被清除 。因為沒有另外的符號指向它,使用者沒有辦法清除這個結構,結果就會導致記憶體流失。慶幸的是,php將在請求結束時清除這個資料結構,但是在php清除之前,將耗費不少空間的記憶體。如果你要實現分析演算法,或者要做其他像一個子項目指向它的父元素這樣的事情,這種情況就會經常發生。當然,同樣的情況也會發生在對象上,實際上對象更有可能出現這種情況,因為對象總是隱式的被引用。
如果上面的情況發生僅僅一兩次倒沒什麼,但是如果出現幾千次,甚至幾十萬次的記憶體流失,這顯然是個大問題。在長時間啟動並執行指令碼,比如請求基本上不會結束的守護進程(deamons)或者單元測試中的大的套件(sets)中,在給 eZ 組件庫的模板組件做單元測試時,後者(指單元測試中的大的套件)就會出現問題.它將需要耗用2GB的記憶體,而一般的測試伺服器沒有這麼大的記憶體空間。
傳統上,像以前的 php 用到的引用計數記憶體機制,無法處理迴圈的引用記憶體流失。然而 5.3.0 PHP 使用文章» 引用計數系統中的同步周期回收(Concurrent Cycle Collection in Reference Counted Systems)中的同步演算法,來處理這個記憶體流失問題。
對演算法的完全說明有點超出這部分內容的範圍,將只介紹其中基礎部分。首先,我們先要建立一些基本規則,如果一個引用計數增加,它將繼續被使用,當然就不再在垃圾中。如果引用計數減少到零,所在變數容器將被清除(free)。就是說,僅僅在引用計數減少到非零值時,才會產生垃圾周期(garbage cycle)。其次,在一個垃圾周期中,通過檢查引用計數是否減1,並且檢查哪些變數容器的引用次數是零,來發現哪部分是垃圾。
為避免不得不檢查所有引用計數可能減少的垃圾周期,這個演算法把所有可能根(possible roots 都是zval變數容器),放在根緩衝區(root buffer)中(用紫色來標記,稱為疑似垃圾),這樣可以同時確保每個可能的垃圾根(possible garbage root)在緩衝區中只出現一次。僅僅在根緩衝區滿了時,才對緩衝區內部所有不同的變數容器執行記憶體回收操作。看上圖的步驟 A。
在步驟 B 中,類比刪除每個紫色變數。類比刪除時可能將不是紫色的普通變數引用數減”1″,如果某個普通變數引用計數變成0了,就對這個普通變數再做一次類比刪除。每個變數只能被類比刪除一次,類比刪除後標記為灰(原文說確保不會對同一個變數容器減兩次”1″,不對的吧)。
在步驟 C 中,類比恢複每個紫色變數。恢複是有條件的,當變數的引用計數大於0時才對其做類比恢複。同樣每個變數只能恢複一次,恢複後標記為黑,基本就是步驟 B 的逆運算。這樣剩下的一堆沒能恢複的就是該刪除的藍色節點了,在步驟 D 中遍曆出來真的刪除掉。
演算法中都是類比刪除、類比恢複、真的刪除,都使用簡單的遍曆即可(最典型的深搜遍曆)。複雜度為執行類比操作的節點數正相關,不只是紫色的那些疑似垃圾變數。
現在,你已經對這個演算法有了基本瞭解,我們回頭來看這個如何與PHP整合。預設的,PHP的記憶體回收機制是開啟的,然後有個 php.ini 設定允許你修改它:zend.enable_gc 。
當記憶體回收機制開啟時,每當根緩衝區存滿時,就會執行上面描述的迴圈尋找演算法。根緩衝區有固定的大小,可存10,000個可能根,當然你可以通過修改PHP源碼檔案Zend/zend_gc.c中的常量GC_ROOT_BUFFER_MAX_ENTRIES,然後重新編譯PHP,來修改這個10,000值。當記憶體回收機制關閉時,迴圈尋找演算法永不執行,然而,可能根將一直存在根緩衝區中,不管在配置中記憶體回收機制是否啟用。
當記憶體回收機制關閉時,如果根緩衝區存滿了可能根,更多的可能根顯然不會被記錄。那些沒被記錄的可能根,將不會被這個演算法來分析處理。如果他們是循環參考周期的一部分,將永不能被清除進而導致記憶體流失。
即使在記憶體回收機制不可用時,可能根也被記錄的原因是,相對於每次找到可能根後檢查記憶體回收機制是否開啟而言,記錄可能根的操作更快。不過記憶體回收和分析機制本身要耗不少時間。
除了修改配置zend.enable_gc ,也能通過分別調用gc_enable() 和 gc_disable()函數來開啟和關閉記憶體回收機制。調用這些函數,與修改配置項來開啟或關閉記憶體回收機制的效果是一樣的。即使在可能根緩衝區還沒滿時,也能強制執行循環回收。你能調用gc_collect_cycles()函數達到這個目的。這個函數將返回使用這個演算法回收的周期數。
允許開啟和關閉記憶體回收機制並且允許自主的初始化的原因,是由於你的應用程式的某部分可能是高時效性的。在這種情況下,你可能不想使用記憶體回收機制。當然,對你的應用程式的某部分關閉記憶體回收機制,是在冒著可能記憶體流失的風險,因為一些可能根也許存不進有限的根緩衝區。因此,就在你調用gc_disable()函數釋放記憶體之前,先調用gc_collect_cycles()函數可能比較明智。因為這將清除已存放在根緩衝區中的所有可能根,然後在記憶體回收機制被關閉時,可留下空緩衝區以有更多空間儲存可能根。
效能方面考慮的因素
在上一節我們已經簡單的提到:回收可能根有細微的效能上影響,但這是把PHP 5.2與PHP 5.3比較時才有的。儘管在PHP 5.2中,記錄可能根相對於完全不記錄可能根要慢些,而PHP 5.3中對 PHP run-time 的其他修改減少了這個效能損失。
這裡主要有兩個領域對效能有影響。第一個是記憶體佔用空間的節省,另一個是記憶體回收機制執行記憶體清理時的執行時間增加(run-time delay)。我們將研究這兩個領域。
記憶體佔用空間的節省
首先,實現記憶體回收機制的整個原因是為了,一旦先決條件滿足,通過清理循環參考的變數來節省記憶體佔用。在PHP執行中,一旦根緩衝區滿了或者調用gc_collect_cycles() 函數時,就會執行記憶體回收。在下圖中,顯示了下面指令碼分別在PHP 5.2 和 PHP 5.3環境下的記憶體佔用情況,其中排除了指令碼啟動時PHP本身佔用的基本記憶體。
Example #1 Memory usage example
<?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";
}
}
?>
在這個很理論性的例子中,我們建立了一個對象,這個對象中的一個屬性被設定為指回對象本身。在迴圈的下一個重複(iteration)中,當指令碼中的變數被重新複製時,就會發生典型性的記憶體流失。在這個例子中,兩個變數容器是泄漏的(對象容器和屬性容器),但是僅僅能找到一個可能根:就是被 unset的那個變數。在10,000次重複後(也就產生總共10,000個可能根),當根緩衝區滿時,就執行記憶體回收機制,並且釋放那些關聯的可能根的記憶體。這從PHP 5.3的鋸齒型記憶體佔用圖中很容易就能看到。每次執行完10,000次重複後,執行記憶體回收,並釋放相關的重複使用的引用變數。在這個例子中由於泄漏的資料結構非常簡單,所以記憶體回收機制本身不必做太多工作。從這個圖表中,你能看到 PHP 5.3的最大記憶體佔用大概是9 Mb,而PHP 5.2的記憶體佔用一直增加。
執行時間增加(Run-Time Slowdowns)
記憶體回收影響效能的第二個領域是它釋放已泄漏的記憶體耗費的時間。為了看到這個耗時時多少,我們稍微改變了上面的指令碼,有更多次數的重複並且刪除了迴圈中的記憶體佔用計算,第二個指令碼代碼如下:
Example #2 GC performance influences
<?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";
?>
我們將運行這個指令碼兩次,一次通過配置zend.enable_gc 開啟記憶體回收機制時,另一次是它關閉時。
Example #3 Running the above script
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
在我的機器上,第一個命令持續執行時間大概為10.7秒,而第二個命令耗費11.4秒。時間上增加了7%。然而,執行這個指令碼時記憶體佔用的峰值降低了98%,從931Mb 降到 10Mb。這個基準不是很科學,或者並不能代表真實應用程式的資料,但是它的確顯示了記憶體回收機制在記憶體佔用方面的好處。好訊息就是,對這個指令碼而言,在執行中出現更多的循環參考變數時,記憶體節省的更多的情況下,每次時間增加的百分比都是7%。
PHP內部 GC 統計資訊
在PHP內部,可以顯示更多的關於記憶體回收機制如何啟動並執行資訊。但是要顯示這些資訊,你需要先重新編譯PHP使benchmark和data-collecting code可用。你需要在按照你的意願運行./configure前,把環境變數CFLAGS設定成-DGC_BENCH=1。下面的命令串就是做這個事:
Example #4 Recompiling PHP to enable GC benchmarking
export CFLAGS=-DGC_BENCH=1
./config.nice
make clean
make
當你用新編譯的PHP二進位檔案來重新執行上面的例子代碼,在PHP執行結束後,你將看到下面的資訊:
Example #5 GC statistics
GC Statistics
-------------
Runs: 110
Collected: 2072204
Root buffer length: 0
Root buffer peak: 10000
Possible Remove from Marked
Root Buffered buffer grey
-------- -------- ----------- ------
ZVAL 7175487 1491291 1241690 3611871
ZOBJ 28506264 1527980 677581 1025731
主要的資訊統計在第一個塊。你能看到記憶體回收機制運行了110次,而且在這110次運行中,總共有超過兩百萬的記憶體配置被釋放。只要記憶體回收機制運行了至少一次,根緩衝區峰值(Root buffer peak)總是10000.
結論
通常,PHP中的記憶體回收機制,僅僅在迴圈回收演算法確實運行時會有時間消耗上的增加。但是在平常的(更小的)指令碼中應根本就沒有效能影響。
然而,在平常指令碼中有迴圈回收機制啟動並執行情況下,記憶體的節省將允許更多這種指令碼同時運行在你的伺服器上。因為總共使用的記憶體沒達到上限。
這種好處在長時間運行指令碼中尤其明顯,諸如長時間的測試套件或者daemon指令碼此類。同時,對通常比Web指令碼已耗用時間長的» PHP-GTK應用程式,新的記憶體回收機制,應該會大大改變一直以來認為記憶體流失問題難以解決的看法。