PHP核心探索:寫時複製COW機制

PHPChina-春陽發表於2016-09-19

寫時複製(Copy-on-Write,也縮寫為COW),顧名思義,就是在寫入時才真正複製一份記憶體進行修改。 COW最早應用在*nix系統中對執行緒與記憶體使用的優化,後面廣泛的被使用在各種程式語言中,如C++的STL等。 在PHP核心中,COW也是主要的記憶體優化手段。 在前面關於變數和記憶體的討論中,引用計數對變數的銷燬與回收中起著至關重要的標識作用。 引用計數存在的意義,就是為了使得COW可以正常運作,從而實現對記憶體的優化使用。

寫時複製的作用

經過上面的描述,大家可能會COW有了個主觀的印象,下面讓我們看一個小例子, 非常容易看到COW在記憶體使用優化方面的明顯作用:

<span style="font-size:14px;">$j = 1;
var_dump(memory_get_usage());
$tipi = array_fill(0, 100000, 'php-internal');
var_dump(memory_get_usage());
  
$tipi_copy = $tipi;
var_dump(memory_get_usage());
  
foreach($tipi_copy as $i){
    $j += count($i);
}
var_dump(memory_get_usage());
  
//-----執行結果-----
$ php t.php
int(630904)
int(10479840)
int(10479944)
int(10480040)</span>

上面的程式碼比較典型的突出了COW的作用,在一個陣列變數$tipi被賦值給$tipi_copy時, 記憶體的使用並沒有立刻增加一半,甚至在迴圈遍歷數 $tipi_copy時, 實際上遍歷的,仍是$tipi指向的同一塊記憶體。

也就是說,即使我們不使用引用,一個變數被賦值後,只要我們不改變變數的值 ,也與使用引用一樣。 進一步講,就算變數的值立刻被改變,新值的記憶體分配也會洽如其分。 據此我們很容易就可以想到一些COW可以非常有效的控制記憶體使用的場景, 如函式引數的傳遞,大陣列的複製等等。

在這個例子中,如果$tipi_copy的值發生了變化,$tipi的值是不應該發生變化的, 那麼,此時PHP核心又會如何去做呢?我們引入下面的示例:

//$tipi = array_fill(0, 3, 'php-internal'); 
//這裡不再使用array_fill來填充 ,為什麼?
$tipi[0] = 'php-internal';
$tipi[1] = 'php-internal';
$tipi[2] = 'php-internal';
var_dump(memory_get_usage());
  
$copy = $tipi;
xdebug_debug_zval('tipi', 'copy');
var_dump(memory_get_usage());
  
$copy[0] = 'php-internal';
xdebug_debug_zval('tipi', 'copy');
var_dump(memory_get_usage());
  
//-----執行結果-----
$ php t.php
int(629384)
tipi: (refcount=2, is_ref=0)=array (0 => (refcount=1, is_ref=0)='php-internal', 1 => (refcount=1, is_ref=0)='php-internal', 2 => (refcount=1, is_ref=0)='php-internal')
copy: (refcount=2, is_ref=0)=array (0 => (refcount=1, is_ref=0)='php-internal', 1 => (refcount=1, is_ref=0)='php-internal', 2 => (refcount=1, is_ref=0)='php-internal')
int(629512)
tipi: (refcount=1, is_ref=0)=array (0 => (refcount=1, is_ref=0)='php-internal', 1 => (refcount=2, is_ref=0)='php-internal', 2 => (refcount=2, is_ref=0)='php-internal')
copy: (refcount=1, is_ref=0)=array (0 => (refcount=1, is_ref=0)='php-internal', 1 => (refcount=2, is_ref=0)='php-internal', 2 => (refcount=2, is_ref=0)='php-internal')
int(630088)

從上面例子我們可以看出,當一個陣列整個被賦給一個變數時,只是將記憶體將記憶體地址賦值給變數。 當陣列的值被改變時,Zend核心重新申請了一塊記憶體,然後賦之以新值,但不影響其他值的記憶體狀態。 寫時複製的最小粒度,就是zval結構體, 而對於zval結構體組成的集合(如陣列和物件等),在需要複製記憶體時,將複雜物件分解為最小粒度來處理。 這樣做就使記憶體中複雜物件中某一部分做修改時,不必將該物件的所有元素全部“分離”出一份記憶體拷貝, 從而節省了記憶體的使用。

寫時複製的實現

由於記憶體塊沒有辦法標識自己被幾個指標同時使用, 僅僅通過記憶體本身並沒有辦法知道什麼時候應該進行復制工作, 這樣就需要一個變數來標識這塊記憶體是“被多少個變數名指標同時指向的”, 這個變數,就是前面關於變數的章節提到的:引用計數。

這裡有一個比較典型的例子:

 $foo = 1;
 xdebug_debug_zval('foo');
 $bar = $foo;
 xdebug_debug_zval('foo');
 $bar = 2;
 xdebug_debug_zval('foo');  
//-----執行結果-----
foo: (refcount=1, is_ref=0)=1
foo: (refcount=2, is_ref=0)=1
foo: (refcount=1, is_ref=0)=1

經過前文對變數的章節,我們可以理解當$foo被賦值時,$foo變數的引用計數為1。 當$foo的值被賦給$bar時,PHP並沒有將記憶體直接複製一份交給$bar, 而是直接把$foo和$bar指向同一個地址。這時,我們可以看到refcount=2; 最後,我們更改了$bar的值,這時如果兩個變數再指向同一個記憶體地址的話, 其值就會同時改變,於是,PHP核心這時將記憶體複製出來一份,並將其值寫為2 ,(這個操作也稱為分離操作), 同時維護原$foo變數的引用計數:refcount=1。

上面小例子中的xdebug_debug_zval()是xdebug擴充套件中的一個函式,用於輸出變數在zend內部的引用資訊。 如果你沒有安裝xdebug擴充套件,也可以使用debug_zval_dump()來代替。 參考:http://www.php.net/manual/zh/function.debug-zval-dump.php

寫時複製應用的場景很多,最常見是賦值和函式傳參。 在上面的例子中,就使用了zend_assign_to_variable()函式(Zend/zend_execute.c) 對變數的賦值進行了各種判斷和處理。 其中最終處理程式碼如下:

if (PZVAL_IS_REF(value) && Z_REFCOUNT_P(value) > 0) {
    ALLOC_ZVAL(variable_ptr);
    *variable_ptr_ptr = variable_ptr;
    *variable_ptr = *value;
    Z_SET_REFCOUNT_P(variable_ptr, 1);
    zval_copy_ctor(variable_ptr);
} else {
    *variable_ptr_ptr = value;
    Z_ADDREF_P(value);
}

從這段程式碼可以看出,如果要進行操作的值已經是引用型別(如已經被&操作符操作過), 則直接重新分配記憶體,否則只是將value的地址賦與變數,同時將值的zval_value.refcount進行加1操作。

如果大家看過前面的章節, 應該對變數儲存的結構體zval(Zend/zend.h)還有印象:

typedef struct _zval_struct zval;
...
struct _zval_struct {
    /* Variable information */
    zvalue_value value;     /* value */
    zend_uint refcount__gc;
    zend_uchar type;    /* active type */
    zend_uchar is_ref__gc;
};

PHP對值的寫時複製的操作,主要依賴於兩個引數:refcount__gc與is_ref__gc。 如果是引用型別,則直接進行“分離”操作,即時分配記憶體, 否則會寫時複製,也就是在修改其值的時候才進行記憶體的重新分配。

寫時複製的規則比較繁瑣,什麼情況會導致寫時複製及分離,是有非常多種情況的。 在這裡只是舉一個簡單的例子幫助大家理解,後續會在附錄中列舉PHP中所有寫時複製的相關規則。

寫時複製的矛盾,PHP中不推薦使用&操作符的部分解釋

上面是一個比較典型的例子,但現實中的PHP實現經過各種權衡, 甚至有時對一個特性的支援與否,是互相矛盾且難以取捨的。 比如,unset()看上去是用來把變數釋放,然後把記憶體標記於空閒的。 可是,在下面的例子中,unset並沒有使記憶體池的記憶體增加:

<?php
$nowamagic = 10;
$o_o  = &$nowamagic;
unset($o_o);
echo $nowamagic;
?>

理論上$o_o是$nowamagic的引用,這兩者應該指向同一塊記憶體,其中一個被標識為回收, 另一個也應該被回收才是。但這是不可能的,因為記憶體本身並不知道都有哪些指標 指向了自已。在C中,o_o這時的值應該是無法預料的, 但PHP不想把這種維護變數引用的工作交給使用者,於是, 使用了折中的方法,unset()此時只會把nowamagic變數名從hashtable中去掉, 而記憶體值的引用計數減1。實際的記憶體使用完全沒有變化。

試想,如果$nowamagic是一個非常大的陣列,或者是一個資源型的變數。 這種情形絕對是我們不想看到的。

上面這個例子我們還可以理解,如果每個這種類似操作都要使用者來關心。 那PHP就是變換了語法的C了。而下面的這個例子,與其說是語言特性, 倒不如說是更像BUG多一些。(事實上對此在PHP官方的郵件組裡有也爭論)

<?php
$foo ['love'] = 1;
$bar  = &$foo['love'];
$tipi = $foo;
$tipi['love'] = '2';
echo $foo['love'];
?>

這個例子最後會輸出 2 , 大家會非常驚訝於$nowamagic怎麼會影響到$foo, 這完全是兩個不同的變數麼!至少我們希望是這樣。

最後,不推薦大家使用 & ,讓PHP自己決定什麼時候該使用引用好了, 除非你知道自己在做什麼。




相關文章