從內核層面解析PHP的聲明週期、變量zval、引用計數。
《Extending and Embedding PHP》讀後總結
1.PHP的生命週期
不論是使用cli指令行還是使用webserver(apache、nginx)形式解釋執行php程序。最終的進程模型都可以歸爲三類:
- 單進程(cli):指令行執行php就是開啓一個php進程,執行完php程序後控制權返回給shell。
- 多進程(nginx+php-fpm):php-fpm實際處理php請求的只是worker進程,在單個進程空間中並不會創建線程去處理多個請求,因此我們將它的進程模型歸類爲多進程。(apache也有多行程的模式)
- 多線程(apache):這裏的多線程指的是同一個進程中還會創建多個子進程來處理多個請求,這些請求共享該進程的內存與配置。(apache有單進程多線程、多進程多線程的模式)
因爲php本身並沒有主進程的概念,因此它的聲明週期均是以單進程
爲維度討論。
- 進程啓動時:執行所有擴展的MINIT方法進行初始化操作
- 接收到請求時:執行所有擴展中的RINIT方法進行請求的初始化操作
- 解釋執行PHP
- 處理完請求時:執行所有擴展中的RSHUTDOWN方法執行回收操作
- 進程結束時:執行所有擴展中的MSHUTDOWN方法執行回收操作
因此如果是多進程的模型,啓動時,每一個進程都會進行上述的初始化操作,因此每一個進程中的數據是完全隔離的。
這裏就可以明確的看出爲什麼PHP本身不支持db等各類連接池。如果是在php代碼層面實現連接池,那麼請求處理完畢連接池就直接釋放了,沒有任何意義。如果是在擴展層面實現連接池,因爲PHP並沒有公用的內存空間,那麼連接池頂多是單進程內多請求共享(php-mysqli的pconnet就是這種模式),那麼當開啓例如500個PHP-FPM進程時候,相當於有500個連接池,反而可能會大量消耗可用數據庫連接。
關於線程安全,默認是關閉線程安全模式。在正常的多進程模型下也不需要開啓線程安全模式,因爲數據本身就是進程間隔離的,不存在線程安全問題。開啓反而會造成無意義的額外開銷。
2.變量的實現
php中弱類型的實現是因爲php底層所有的變量均爲一個zval的結構體。
typedef struct _zval_struct {
zval_value value;
zend_uint refcount;
zend_uchar type;
zend_uchar is_ref;
} zval;
- zval_value是一個union類型,包含了所有類型。
typedef union _zvalue_value {
long lval;
double dval;
struct {
char *var;
int len;
} str;
HashTable *ht;
zend_object_value obj;
} zvalue_value;
- refcount 引用計數,用於垃圾回收。
- type 標記了value的真實類型,擴展開發中可以使用類型宏進行判斷。
- is_ref 簡單的標記值,變量本身是否是引用。
3.變量的存儲
當創建一個的變量的時候,zend引擎會將將這個zval的指針存儲到一個內部map(符號表)中。
struct _zend_execution_globals {
...
HashTable symbol_table;
HashTable *active_symbol_table;
...
};
- symbol_table,代表了php腳本的全局作用域。
- active_symbol_table,當用戶側的一個函數、方法被調用時,會分配一個新的符號表用於該生命週期,並定義爲激活的符號表,定的變量名則存儲在對應空間的符號表中。key爲定義的變量名,value爲指向zval的指針zval *
<?php $foo = 'bar'; ?>
/*上面php創建變量的C實現*/
{
zval *fooval;
MAKE_STD_ZVAL(fooval); //zend引擎宏命令,創建一個空zval
ZVAL_STRING(fooval, "bar", 1); //zend引擎宏命令,創建一個字符串,會自動設置字符串本身+長度,最後一個參數爲1時,會分配新的內存並cp字符串,如果爲0則簡單的指向字符串已有的地址。
ZEND_SET_SYMBOL(EG(active_symbol_table), "foo", fooval); //設置符號表
}
4.引用計數
Zend引擎內部對於內存的管理做過了許多優化,雖然在用戶側,PHP的表現一直是在非引用的情況下都是以值傳遞的,但是實際上在內部,大部分情況都是傳遞的指針,通過引用計數+是否是引用來確定是否需要cp內容。
<?php
$a = 'Hello World';
$b = $a;
?>
在用戶側我們會想當然的理解爲:
- 申請了一個12個字節的內存,用於存放Hello World+NULL結尾。
- 變量a指向該內存。
- 複製該內存的內容,並將變量b指向複製後的新地址。
但是實際上Zend引擎並沒有copy字符串,而只是將那個Hello World的zval結構體中的refcount+1。
那麼爲什麼我們改變$b的字符串的值並不會改變$a的值呢?那麼是因爲Zend引擎使用了寫時拷貝
。
<?php
$a = 'Hello World';
$b = $a;
$b = 'aaa';
?>
這時Zend引擎的操作是:
- 判斷zval的refcount如果小於2,不需要隔離,直接使用該值。
- 如果不是,則先對zval進行一次淺拷貝+一次深拷貝,在符號表刪除b與該zval的關聯,此時zval的refcount已經變成了1。將拷貝出來的zval的refcount設置成1,再設置進符號表與b建立關聯。
<?php
$a = 'Hello World';
$b = &$a;
$b = 'aaa';
?>
如果是引用呢?此時在用戶側我們知道$a的值也應該變成aaa。實際在引擎層面很簡單,因爲原本內部就是通過指針指向的同一個地址,並不需要特殊處理,只需要在判斷zval的refcount的值的時候,多判斷一下zval的is_ref是否是1,如果是那麼直接返回該值就可以了。
zval *get_var_and_separate(char *varname, int varname_len TSRMLS_DC) {
zval **varval, *varcopy;
if (zend_hash_find(EG(active_symbol_table),
varname, varname_len + 1, (void**)&varval) == FAILURE) {
/* 變量不存在 */
return NULL;
}
if ( (*varval)->is_ref || (*varval)->refcount < 2) {
/* 變量名只有⼀個引用或是引用, 不需要隔離 */
return *varval;
}
/* 其他情況, 對zval *做⼀一次淺拷貝 */
MAKE_STD_ZVAL(varcopy);
varcopy = *varval;
/* 對zval *進行⼀一次深拷貝 */
zval_copy_ctor(varcopy);
/* 破壞varname和varval之間的關係, 這⼀一步會將varval的引用計數減小1 */
zend_hash_del(EG(active_symbol_table), varname, varname_len + 1);
/* 初始化新創建的值的引用計數, 併爲新創建的值和varname建立關聯 */
varcopy->refcount = 1;
varcopy->is_ref = 0;
zend_hash_add(EG(active_symbol_table), varname, varname_len + 1,
/* 返回新的zval * */
return varcopy;
}