深入了解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
類型, 這樣就導緻了大量的記憶體浪費.
第三, 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($array);
?>
當我們調用dummy的時候, 本來隻是簡單的一個傳值就行的地方, 但是因為$array曾經引用指派給了$b, 是以導緻$array變成了一個引用, 于是此處就會發生分離, 導緻數組複制, 進而極大的拖慢性能, 這裡有一個簡單的測試:
<?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上了
![](https://img.laitimes.com/img/_0nNw4CM6IyYiwiM6ICdiwiI0gTMx81dsQWZ4lmZf1GLlpXazVmcvwFciV2dsQXYtJ3bm9CX9s2RkBnVHFmb1clWvB3MaVnRtp1XlBXe0xCMy81dvRWYoNHLwEzX5xCMx8FesU2cfdGLwMzX0xiRGZkRGZ0Xy9GbvNGLpZTY1EmMZVDUSFTU4VFRR9Fd4VGdsYTMfVmepNHLrJXYtJXZ0F2dvwVZnFWbp1zczV2YvJHctM3cv1Ce-cmbw5SM4QzMxIDMzUGZ1YGOhVzNzYzX5IjMyETM3EzLcFTMyIDMy8CXn9Gbi9CXzV2Zh1WavwVbvNmLvR3YxUjLyM3Lc9CX6MHc0RHaiojIsJye.png)
以
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值了.
标志位
除了資料類型以外, 以前的經驗也告訴我們, 一個資料除了它的類型以外, 還應該有很多其他的屬性, 比如對于INTERNED STRING,它是一種在整個PHP請求期都存在的字元串(比如你寫在代碼中的字面量), 它不會被引用計數回收. 在5.4的版本中我們是通過預先申請一塊記憶體, 然後再這個記憶體中配置設定字元串, 最後用指針位址來比較, 如果一個字元串是屬于INTERNED STRING的記憶體範圍内, 就認為它是INTERNED STRING. 這樣做的缺點顯而易見, 就是當記憶體不夠的時候, 我們就沒有辦法配置設定INTERNED STRING了, 另外也非常醜陋, 是以如果一個字元串能有一些屬性定義則這個實作就可以變得很優雅.
還有, 比如現在我們對于
IS_LONG
,
IS_TRUE
等類型不再進行引用計數了, 那麼當我們拿到一個zval的時候如何判斷它需要不需要引用計數呢? 想當然的我們可能會說用:
if (Z_TYPE_P(zv) >= IS_STRING) {
//需要引用計數
}
但是你忘了, 還有INTERNED STRING的存在啊, 是以你也許要這麼寫了:
if (Z_TYPE_P(zv) >= IS_STRING && !IS_INTERNED(Z_STR_P(zv))) {
//需要引用計數
}
是不是已經讓你感覺到有點不對勁了? 嗯,别急, 還有呢, 我們還在5.6的時候引入了常量數組, 這個數組呢會存儲在Opcache的共享記憶體中, 它也不需要引用計數:
if (Z_TYPE_P(zv) >= IS_STRING && !IS_INTERNED(Z_STR_P(zv))
&& (Z_TYPE_P(zv) != IS_ARRAY || !Z_IS_IMMUTABLE(Z_ARRVAL(zv)))) {
//需要引用計數
}
你是不是也覺得這簡直太醜陋了, 簡直不能忍受這樣墨迹的代碼, 對吧?
是的,我們早想到了,回頭看之前的zval定義, 注意到
type_flags
了麼? 我們引入了一個标志位, 叫做
IS_TYPE_REFCOUNTED
, 它會儲存在
zval.u1.v.type_flags
中, 我們對于需要引用計數的類型就賦予這個标志, 是以上面的判斷就可以變得很優雅:
if (!(Z_TYPE_FLAGS(zv) & IS_TYPE_REFCOUNTED)) {
}
而對于INTERNED STRING來說, 這個
IS_STR_INTERNED
标志位應該是作用于字元串本身而不是zval的.
那麼類似這樣的标志位一共有多少呢?作用于zval的有:
IS_TYPE_CONSTANT //是常量類型
IS_TYPE_IMMUTABLE //不可變的類型, 比如存在共享記憶體的數組
IS_TYPE_REFCOUNTED //需要引用計數的類型
IS_TYPE_COLLECTABLE //可能包含循環引用的類型(IS_ARRAY, IS_OBJECT)
IS_TYPE_COPYABLE //可被複制的類型, 還記得我之前講的對象和資源的例外麼? 對象和資源就不是
IS_TYPE_SYMBOLTABLE //zval儲存的是全局符号表, 這個在我之前做了一個調整以後沒用了, 但還保留着相容,
//下個版本會去掉
作用于字元串的有:
IS_STR_PERSISTENT //是malloc配置設定記憶體的字元串
IS_STR_INTERNED //INTERNED STRING
IS_STR_PERMANENT //不可變的字元串, 用作哨兵作用
IS_STR_CONSTANT //代表常量的字元串
IS_STR_CONSTANT_UNQUALIFIED //帶有可能命名空間的常量字元串
作用于數組的有:
#define IS_ARRAY_IMMUTABLE //同IS_TYPE_IMMUTABLE
作用于對象的有:
IS_OBJ_APPLY_COUNT //遞歸保護
IS_OBJ_DESTRUCTOR_CALLED //析構函數已經調用
IS_OBJ_FREE_CALLED //清理函數已經調用
IS_OBJ_USE_GUARDS //魔術方法遞歸保護
IS_OBJ_HAS_GUARDS //是否有魔術方法遞歸保護标志
有了這些預留的标志位, 我們就會很友善的做一些以前不好做的事情, 就比如我自己的Taint擴充, 現在把一個字元串标記為污染的字元串就會變得無比簡單:
/* it's important that make sure
* this value is not used by Zend or
* any other extension agianst string */
#define IS_STR_TAINT_POSSIBLE (1<<7)
#define TAINT_MARK(str) (GC_FLAGS((str)) |= IS_STR_TAINT_POSSIBLE)
這個标記就會一直随着這個字元串的生存而存在的, 省掉了我之前的很多tricky的做法.
ZVAL預先配置設定
前面我們說過, PHP5的zval配置設定采用的是堆上配置設定記憶體, 也就是在PHP預案代碼中随處可見的MAKE_STD_ZVAL和ALLOC_ZVAL宏. 我們也知道了本來一個zval隻需要24個位元組, 但是算上gc_info, 其實配置設定了32個位元組, 再加上PHP自己的記憶體管理在配置設定記憶體的時候都會在記憶體前面保留一部分資訊:
typedef struct _zend_mm_block {
zend_mm_block_info info;
#if ZEND_DEBUG
unsigned int magic;
# ifdef ZTS
THREAD_T thread_id;
# endif
zend_mm_debug_info debug;
#elif ZEND_MM_HEAP_PROTECTION
zend_mm_debug_info debug;
#endif
} zend_mm_block;
進而導緻實際上我們隻需要24位元組的記憶體, 但最後竟然配置設定48個位元組之多.
然而大部分的zval, 尤其是擴充函數内的zval, 我們想想它接受的參數來自外部的zval, 它把傳回值傳回給return_value, 這個也是來自外部的zval, 而中間變量的zval完全可以采用棧上配置設定. 也就是說大部分的内部函數都不需要在堆上配置設定記憶體, 它需要的zval都可以來自外部.
于是當時我們做了一個大膽的想法, 所有的zval都不需要單獨申請.
而這個也很容易證明, PHP腳本中使用的zval, 要麼存在于符号表, 要麼就以臨時變量(
IS_TMP_VAR
)或者編譯變量(
IS_CV
)的形式存在. 前者存在于一個Hashtable中, 而在PHP7中Hashtable預設儲存的就是zval, 這部分的zval完全可以在Hashtable配置設定的時候一次性配置設定出來, 後面的存在于execute_data之後, 數量也在編譯時刻确定好了, 也可以随着execute_data一次性配置設定, 是以我們确實不再需要單獨在堆上申請zval了.
是以, 在PHP7開始, 我們移除了MAKE_STD_ZVAL/ALLOC_ZVAL宏, 不再支援存堆記憶體上申請zval. 函數内部使用的zval要麼來自外面輸入, 要麼使用在棧上配置設定的臨時zval.
在後來的實踐中, 總結出來的可能對于開發者來說最大的變化就是, 之前的一些内部函數, 通過一些操作獲得一些資訊, 然後配置設定一個zval, 傳回給調用者的情況:
static zval * php_internal_function() {
.....
str = external_function();
MAKE_STD_ZVAL(zv);
ZVAL_STRING(zv, str, 0);
return zv;
}
PHP_FUNCTION(test) {
RETURN_ZVAL(php_internal_function(), 1, 1);
}
要麼修改為, 這個zval由調用者傳遞:
static void php_internal_function(zval *zv) {
.....
str = external_function();
ZVAL_STRING(zv, str);
efree(str);
}
PHP_FUNCTION(test) {
php_internal_function(return_value);
}
要麼修改為, 這個函數傳回原始素材:
static char * php_internal_function() {
.....
str = external_function();
return str;
}
PHP_FUNCTION(test) {
str = php_internal_function();
RETURN_STRING(str);
efree(str);
}
總結