1. 程式人生 > >深入理解PHP7核心之zval

深入理解PHP7核心之zval

PHP7已經發布, 如承諾, 我也要開始這個系列的文章的編寫, 主要想通過文章讓大家理解到PHP7的巨大效能提升背後到底我們做了什麼, 今天我想先和大家聊聊zval的變化. 在講zval變化的之前我們先來看看zval在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($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必須得改改了, 對吧?

現在的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上了.
PHP7 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值了.

標誌位

除了資料型別以外, 以前的經驗也告訴我們, 一個數據除了它的型別以外, 還應該有很多其他的屬性, 比如對於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);
}

總結

(這塊還沒想好怎麼說, 本來我是要引出Hashtable不再存在zval**, 從而引出引用型別的存在的必要性, 但是如果不先講Hashtable的結構, 這個引出貌似很突兀, 先這麼著吧, 以後再來修改)

到現在我們基本上把zval的變化概況介紹完畢, 抽象的來說, 其實在PHP7中的zval, 已經變成了一個值指標, 它要麼儲存著原始值, 要麼儲存著指向一個儲存原始值的指標. 也就是說現在的zval相當於PHP5的時候的zval *. 只不過相比於zval *, 直接儲存zval, 我們可以省掉一次指標解引用, 從而提高快取友好性.

其實PHP7的效能, 我們並沒有引入什麼新的技術模式, 不過就是主要來自, 持續不懈的降低記憶體佔用, 提高快取友好性, 降低執行的指令數的這些原則而來的, 可以說PHP7的重構就是這三個原則.