深入理解PHP7之zval
PHP7已經發布, 如承諾, 我也要開始這個系列的文章的編寫, 今天我想先和大家聊聊zval的變化. 在講zval變化的之前我們先來看看zval在PHP5下面是什么樣子
PHP5
zval回顧
在PHP5的時候, zval的定義如下:
struct _zval_struct { union { long lval; double dval; struct { char *val; int len; } str; HashTable *ht; zend_object_value obj; zend_ast *ast; } value; zend_uint refcount__gc; zend_uchar type; zend_uchar is_ref__gc; };
對PHP5內核有了解的同學應該對這個結構比較熟悉, 因為zval可以表示一切PHP中的數據類型, 所以它包含了一個type字段, 表示這個zval存儲的是什么類型的值, 常見的可能選項是IS_NULL, IS_LONG, IS_STRING, IS_ARRAY, IS_OBJECT等等.
根據type字段的值不同, 我們就要用不同的方式解讀value的值, 這個value是個聯合體, 比如對于type是IS_STRING, 那么我們應該用value.str來解讀zval.value字段, 而如果type是IS_LONG, 那么我們就要用value.lval來解讀.
另外, 我們知道PHP是用引用計數來做基本的垃圾回收的, 所以zval中有一個refcount__gc字段, 表示這個zval的引用數目, 但這里有一個要說明的, 在5.3以前, 這個字段的名字還叫做refcount, 5.3以后, 在引入新的垃圾回收算法來對付循環引用計數的時候, 作者加入了大量的宏來操作refcount, 為了能讓錯誤更快的顯現, 所以改名為refcount__gc, 迫使大家都使用宏來操作refcount.
類似的, 還有is_ref, 這個值表示了PHP中的一個類型是否是引用, 這里我們可以看到是不是引用是一個標志位.
這就是PHP5時代的zval, 在2013年我們做PHP5的opcache JIT的時候, 因為JIT在實際項目中表現不佳, 我們轉而意識到這個結構體的很多問題. 而PHPNG項目就是從改寫這個結構體而開始的.
存在的問題
PHP5的zval定義是隨著Zend Engine 2誕生的, 隨著時間的推移, 當時設計的局限性也越來越明顯:
首先這個結構體的大小是(在64位系統)24個字節, 我們仔細看這個zval.value聯合體, 其中zend_object_value是最大的長板, 它導致整個value需要16個字節, 這個應該是很容易可以優化掉的, 比如把它挪出來, 用個指針代替,因為畢竟IS_OBJECT也不是最最常用的類型.
第二, 這個結構體的每一個字段都有明確的含義定義, 沒有預留任何的自定義字段, 導致在PHP5時代做很多的優化的時候, 需要存儲一些和zval相關的信息的時候, 不得不采用其他結構體映射, 或者外部包裝后打補丁的方式來擴充zval, 比如5.3的時候新引入專門解決循環引用的GC, 它不得采用如下的比較hack的做法:
/* The following macroses override macroses from zend_alloc.h */ #undef ALLOC_ZVAL #define ALLOC_ZVAL(z) \ do { \ (z) = (zval*)emalloc(sizeof(zval_gc_info)); \ GC_ZVAL_INIT(z); \ } while (0)
它用zval_gc_info劫持了zval的分配:
typedef struct _zval_gc_info { zval z; union { gc_root_buffer *buffered; struct _zval_gc_info *next; } u; } zval_gc_info;
然后用zval_gc_info來擴充了zval, 所以實際上來說我們在PHP5時代申請一個zval其實真正的是分配了32個字節, 但其實GC只需要關心IS_ARRAY和IS_OBJECT類型, 這樣就導致了大量的內存浪費.
還比如我之前做的Taint擴展, 我需要對于給一些字符串存儲一些標記, zval里沒有任何地方可以使用, 所以我不得不采用非常手段:
Z_STRVAL_PP(ppzval) = erealloc(Z_STRVAL_PP(ppzval), Z_STRLEN_PP(ppzval) + 1 + PHP_TAINT_MAGIC_LENGTH); PHP_TAINT_MARK(*ppzval, PHP_TAINT_MAGIC_POSSIBLE);
就是把字符串的長度擴充一個int, 然后用magic number做標記寫到后面去, 這樣的做法安全性和穩定性在技術上都是沒有保障的
第三, PHP的zval大部分都是按值傳遞, 寫時拷貝的值, 但是有倆個例外, 就是對象和資源, 他們永遠都是按引用傳遞, 這樣就造成一個問題, 對象和資源在除了zval中的引用計數以外, 還需要一個全局的引用計數, 這樣才能保證內存可以回收. 所以在PHP5的時代, 以對象為例, 它有倆套引用計數, 一個是zval中的, 另外一個是obj自身的計數:
typedef struct _zend_object_store_bucket { zend_bool destructor_called; zend_bool valid; union _store_bucket { struct _store_object { void *object; zend_objects_store_dtor_t dtor; zend_objects_free_object_storage_t free_storage; zend_objects_store_clone_t clone; const zend_object_handlers *handlers; zend_uint refcount; gc_root_buffer *buffered; } obj; struct { int next; } free_list; } bucket; } zend_object_store_bucket;
除了上面提到的兩套引用以外, 如果我們要獲取一個object, 則我們需要通過如下方式:
EG(objects_store).object_buckets[Z_OBJ_HANDLE_P(z)].bucket.obj
經過漫長的多次內存讀取, 才能獲取到真正的objec對象本身. 效率可想而知.
這一切都是因為Zend引擎最初設計的時候, 并沒有考慮到后來的對象. 一個良好的設計, 一旦有了意外, 就會導致整個結構變得復雜, 維護性降低, 這是一個很好的例子.
第四, 我們知道PHP中, 大量的計算都是面向字符串的, 然而因為引用計數是作用在zval的, 那么就會導致如果要拷貝一個字符串類型的zval, 我們別無他法只能復制這個字符串. 當我們把一個zval的字符串作為key添加到一個數組里的時候, 我們別無他法只能復制這個字符串. 雖然在PHP5.4的時候, 我們引入了INTERNED STRING, 但是還是不能根本解決這個問題.
還比如, PHP中大量的結構體都是基于Hashtable實現的, 增刪改查Hashtable的操作占據了大量的CPU時間, 而字符串要查找首先要求它的Hash值, 理論上我們完全可以把一個字符串的Hash值計算好以后, 就存下來, 避免再次計算等等
第五, 這個是關于引用的, PHP5的時代, 我們采用寫時分離, 但是結合到引用這里就有了一個經典的性能問題:
<?php function dummy($array) {} $array = range(1, 100000); $b = &$array; dummy($b); ?>
當我們調用array_count的時候, 本來只是簡單的一個傳值就行的地方, 但是因為$b 是一個引用, 就必須發生分離, 導致數組復制, 從而極大的拖慢性能, 這里有一個簡單的測試:
<?php $array = range(1, 100000); function dummy($array) {} $i = 0; $start = microtime(true); while($i++ < 100) { dummy($array); } printf("Used %sS\n", microtime(true) - $start); $b = &$array; //注意這里, 假設我不小心把這個Array引用給了一個變量 $i = 0; $start = microtime(true); while($i++ < 100) { dummy($array); } printf("Used %sS\n", microtime(true) - $start); ?>
我們在5.6下運行這個例子, 得到如下結果:
$ php-5.6/sapi/cli/php /tmp/1.php Used 0.00045204162597656S Used 4.2051479816437S
相差1萬倍之多. 這就造成, 如果在一大段代碼中, 我不小心把一個變量變成了引用(比如foreach as &$v), 那么就有可能觸發到這個問題, 造成嚴重的性能問題, 然而卻又很難排查.
第六, 也是最重要的一個, 為什么說它重要呢? 因為這點促成了很大的性能提升, 我們習慣了在PHP5的時代調用MAKE_STD_ZVAL在堆內存上分配一個zval, 然后對他進行操作, 最后呢通過RETURN_ZVAL把這個zval的值"copy"給return_value, 然后又銷毀了這個zval, 比如pathinfo這個函數:
PHP_FUNCTION(pathinfo) { ..... MAKE_STD_ZVAL(tmp); array_init(tmp); .... if (opt == PHP_PATHINFO_ALL) { RETURN_ZVAL(tmp, 0, 1); } else { ..... }
這個tmp變量, 完全是一個臨時變量的作用, 我們又何必在堆內存分配它呢? MAKE_STD_ZVAL/ALLOC_ZVAL在PHP5的時候, 到處都有, 是一個非常常見的用法, 如果我們能把這個變量用棧分配, 那無論是內存分配, 還是緩存友好, 都是非常有利的
還有很多, 我就不一一詳細列舉了, 但是我相信你們也有了和我們當時一樣的想法, zval必須得改改了, 對吧?
PHP7
現在的zval
到了PHP7中, zval變成了如下的結構, 要說明的是, 這個是現在的結構, 已經和PHPNG時候有了一些不同了, 因為我們新增加了一些解釋 (聯合體的字段), 但是總體大小, 結構, 是和PHPNG的時候一致的:
struct _zval_struct { union { zend_long lval; /* long value */ double dval; /* double value */ zend_refcounted *counted; zend_string *str; zend_array *arr; zend_object *obj; zend_resource *res; zend_reference *ref; zend_ast_ref *ast; zval *zv; void *ptr; zend_class_entry *ce; zend_function *func; struct { uint32_t w1; uint32_t w2; } ww; } value; union { struct { ZEND_ENDIAN_LOHI_4( zend_uchar type, /* active type */ zend_uchar type_flags, zend_uchar const_flags, zend_uchar reserved) /* call info for EX(This) */ } v; uint32_t type_info; } u1; union { uint32_t var_flags; uint32_t next; /* hash collision chain */ uint32_t cache_slot; /* literal cache slot */ uint32_t lineno; /* line number (for ast nodes) */ uint32_t num_args; /* arguments number for EX(This) */ uint32_t fe_pos; /* foreach position */ uint32_t fe_iter_idx; /* foreach iterator index */ } u2; };
雖然看起來變得好大, 但其實你仔細看, 全部都是聯合體, 這個新的zval在64位環境下,現在只需要16個字節(2個指針size), 它主要分為倆個部分, value和擴充字段, 而擴充字段又分為u1和u2倆個部分, 其中u1是type info, u2是各種輔助字段.
其中value部分, 是一個size_t大小(一個指針大小), 可以保存一個指針, 或者一個long, 或者一個double.
而type info部分則保存了這個zval的類型. 擴充輔助字段則會在多個其他地方使用, 比如next, 就用在取代Hashtable中原來的拉鏈指針, 這部分會在以后介紹HashTable的時候再來詳解.
類型
PHP7中的zval的類型做了比較大的調整, 總體來說有如下17種類型:
/* regular data types */ #define IS_UNDEF 0 #define IS_NULL 1 #define IS_FALSE 2 #define IS_TRUE 3 #define IS_LONG 4 #define IS_DOUBLE 5 #define IS_STRING 6 #define IS_ARRAY 7 #define IS_OBJECT 8 #define IS_RESOURCE 9 #define IS_REFERENCE 10 /* constant expressions */ #define IS_CONSTANT 11 #define IS_CONSTANT_AST 12 /* fake types */ #define _IS_BOOL 13 #define IS_CALLABLE 14 /* internal types */ #define IS_INDIRECT 15 #define IS_PTR 17
其中PHP5的時候的IS_BOOL類型, 現在拆分成了IS_FALSE和IS_TRUE倆種類型. 而原來的引用是一個標志位, 現在的引用是一種新的類型.
對于IS_INDIRECT和IS_PTR來說, 這倆個類型是用在內部的保留類型, 用戶不會感知到, 這部分會在后續介紹HashTable的時候也一并介紹.
從PHP7開始, 對于在zval的value字段中能保存下的值, 就不再對他們進行引用計數了, 而是在拷貝的時候直接賦值, 這樣就省掉了大量的引用計數相關的操作, 這部分類型有:
IS_LONG IS_DOUBLE
當然對于那種根本沒有值, 只有類型的類型, 也不需要引用計數了:
IS_NULL IS_FALSE IS_TRUE
而對于復雜類型, 一個size_t保存不下的, 那么我們就用value來保存一個指針, 這個指針指向這個具體的值, 引用計數也隨之作用于這個值上, 而不在是作用于zval上了. 以IS_ARRAY為例:
struct _zend_array { zend_refcounted_h gc; union { struct { ZEND_ENDIAN_LOHI_4( zend_uchar flags, zend_uchar nApplyCount, zend_uchar nIteratorsCount, zend_uchar reserve) } v; uint32_t flags; } u; uint32_t nTableMask; Bucket *arData; uint32_t nNumUsed; uint32_t nNumOfElements; uint32_t nTableSize; uint32_t nInternalPointer; zend_long nNextFreeElement; dtor_func_t pDestructor; };
zval.value.arr將指向上面的這樣的一個結構體, 由它實際保存一個數組, 引用計數部分保存在zend_refcounted_h結構中:
typedef struct _zend_refcounted_h { uint32_t refcount; /* reference counter 32-bit */ union { struct { ZEND_ENDIAN_LOHI_3( zend_uchar type, zend_uchar flags, /* used for strings & objects */ uint16_t gc_info) /* keeps GC root number (or 0) and color */ } v; uint32_t type_info; } u; } zend_refcounted_h;
所有的復雜類型的定義, 開始的時候都是zend_refcounted_h結構, 這個結構里除了引用計數以外, 還有GC相關的結構. 從而在做GC回收的時候, GC不需要關心具體類型是什么, 所有的它都可以當做zend_refcounted*結構來處理.
另外有一個需要說明的就是大家可能會好奇的ZEND_ENDIAN_LOHI_4宏, 這個宏的作用是簡化賦值, 它會保證在大端或者小端的機器上, 它定義的字段都按照一樣順序排列存儲, 從而我們在賦值的時候, 不需要對它的字段分別賦值, 而是可以統一賦值, 比如對于上面的array結構為例, 就可以通過:
arr1.u.flags = arr2.u.flags;
一次完成相當于如下的賦值序列:
arr1.u.v.flags = arr2.u.v.flags; arr1.u.v.nApplyCount = arr2.u.v.nApplyCount; arr1.u.v.nIteratorsCount = arr2.u.v.nIteratorsCount; arr1.u.v.reserve = arr2.u.v.reserve;
還有一個可能會問題是, 為什么不把type類型放到zval類型的前面, 因為我們知道當我們去用一個zval的時候, 首先第一點肯定是先去獲取它的類型. 這里的一個原因是, 一個是倆者差別不大, 另外就是考慮到如果以后JIT的話, zval的類型如果能夠通過類型推導獲得, 就根本沒有必要去讀取它的type值了.
標志位
(待續)