兩週前找機會實作了 PHP7 的擴展 (Extension) — reducer — 一個用來在 PHP Runtime 將資料 Group By 的擴展。
大家都知道 PHP7 將 zval 結構徹底改版重構,得以改進記憶體的使用以及大量的效能改善。寫這篇文章前,筆者稍微查了一下,發現 laruence 已經在 GitHub 上寫了 深入理解PHP7之zval ,有很多細節已經寫在該篇文章中,本來想寫一大篇的雄心大志也就這樣少掉了一半。因此寫這篇文章主要是用來幫助初學者快速理解 PHP7 的 zval 結構以及使用策略。
若你還未認識 zval,這邊簡單說明一下,zval 其實就是你平常寫 PHP 使用的 $var 變數,Zend Engine 透過操作這個 zval 得以取值、轉型、數學運算、字串相加。
如果學過 C 語言應該就知道,變數若定義為整數 (int) 那就一輩子得為整數,不可突然換成浮點數 (float) 或字串 (string),然而 PHP 為了讓使用者可以在 $var 裡面存數字又存字串,就設計了 zval。 (上帝說要有 zval ,就有了 zval)
更進一步的說明,zval 是一個 C 語言的結構 (struct),你可以把它想像成一個容器 (container),為了要讓 zval 可以儲存數字、字串、浮點數,他規劃了一個 union
空間,用來放各種類型的資料,並透過一個類型欄位,來偵測現在存在 zval 裡面的數值為何種型態。
為了要簡化說明,這邊不額外解釋 PHP 5 的 zval 結構了,反正時間會沖淡一切,沒有人會在乎舊的軟體 WW
zval 的輪廓大概如下:
struct _zval_struct {
zend_value value;
union { ... } u1;
union { ... } u2;
}
其中 union u1
是用來儲存型別資訊,當我們使用像 Z_TYPE_P
這類的巨集時,就是這樣拿到型別資料 pz->u1.v.type
。 (在 PHP Source code 中我們通常使用 pz 變數名稱表示 pointer of zval。 平常我們在操作 zval ,就是先查看 zval 的型別,再決定要怎麼讀出 zend_value
中的資料。
而 u2
是用來儲存像 cache_slot, line number, access_flags 等等資訊,不過這邊可以先不用龜毛每個都搞清楚,日後隨著雷踩越多你就會懂了。 XD
補充說明一下,如果你的目的是為了寫 Extension,基本上你只需要了解 zval 的運作方式與 zval 相關的操作 Macro 即可。
讓我們進一步展開 zend_value
結構說明:
typedef union _zend_value {
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;
} zend_value;
如同看官上面看到的, zend_value
結構基本上就是一個部隊鍋,什麼都有,存什麼都行 long value, double value, string, array, object, reference, resource 一堆東西。
稍微注意一下你會看到 ww
這個神秘的東西不是用來存什麼數值類型用的,而是用來複製記憶體資料用的(兩個 32 位元的字節,共 8 bytes),好奇的朋友可以搜尋 ZVAL_COPY_VALUE_EX
這個巨集的定義。
請重新看一下上方的 zend_value
定義,各位看官有看出什麼端倪嗎?那就是在 zval 的各類數值型態中,只有 simple value 如 boolean, long value, double value 是 pass by value,而其他的型別是 pointer,意味著 pass by reference。
這邊牽扯到一個東西,就是 Zend Engine 的記憶體回收機制,凡是非 pass by value 的 zval 都必須為 reference countable,為了確認,我們檢視一下 zend_string
結構 :
struct _zend_string {
zend_refcounted_h gc;
zend_ulong h; /* hash value */
size_t len;
char val[1];
};
第一個欄位就是 zend_refcounted_h
,那 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 consistency)
} v;
uint32_t flags;
} u;
.... 略
}
同樣也是 zend_refcounted_h
欄位。
我們實在太好奇 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;
果然就是 reference counter 用的計數器欄位。這些需要 refcount 的類型,共用前四個 byte 作為 reference 的計數。
請注意,refcount 與 TypeInfo 裡的 IS_REFERENCE 使用的意義是不同的,IS_REFERENCE 是指變數的參照,如:
$var3 = & $var2;
而 reference counter 這個欄位影響著我們如何操作 zval 的生命週期,請讓我們繼續看下去。
PHP5 的 zval 生命週期
在 PHP5,要使用任何一個 zval 變數,我們都必須先申請一個 zval pointer,無法避免的,要寫成這樣:
zval *myval;
接著,為了要真的有一個記憶體空間來存放這個 zval 容器,得調用 ALLOC_ZVAL
:
ALLOC_ZVAL(myval);
而 ALLOC_ZVAL
背後做的事情就是呼叫 emalloc
來跟 VM 申請記憶體空間。
emalloc
是 Zend Engine 的記憶體管理提供的 API,VM 使用 malloc
跟系統預先調用備用的記憶體使用,避免反覆呼叫 system call 消耗效能。由 emalloc
調用來的記憶體,會在 HTTP Request 結束之後一起被釋放回去。
接著為了初始化 zval 的數值,必須呼叫 INIT_ZVAL
,而 INIT_ZVAL
基本上就是設定一個 Null 的 zval — 設定 refcount
為 1,並設定 zval 為 Null,並且設定 is_ref_gc
為 0 。
而這個瑣碎的事情又被建立了一個巨集來做這樣的事情: MAKE_STD_ZVAL
以及 ALLOC_INIT_ZVAL
,不過我們以後也不會用 PHP5 了,所以這個差異在這邊就不解釋了 XD
開玩笑的… 其實 MAKE_STD_ZVAL
基本上只設定 gc, refcount 等參數,並不設定 zval 的 type。
那 string 跟 array 怎麼辦呢?當然就是要再去調用一次 emalloc
來申請空間來放 string 或 hash table 囉。
而關於 zval 的資料操作,Zend Engine 提供了一系列的巨集:
#define Z_BVAL_P(zval_p) Z_BVAL(*zval_p)
#define Z_LVAL_P(zval_p) Z_LVAL(*zval_p)
#define Z_LVAL_PP(zval_pp) Z_LVAL(**zval_pp)
#define Z_BVAL_PP(zval_pp) Z_BVAL(**zval_pp)
#define Z_TYPE(zval) (zval).type
#define Z_TYPE_P(zval_p) Z_TYPE(*zval_p)
#define Z_TYPE_PP(zval_pp) Z_TYPE(**zval_pp)
上面的巨集很簡單,其中 _P
就是指 *zval
可以使用,而 _PP
則是指 **zval
使用。 Z_BVAL_P
代表對 *zval
讀出布林的數值,而Z_LVAL_PP
代表對 **zval
讀出長整數的數值。
而傳值呢?則是把 zval pointer 傳給 function ,又或者是傳空的 double pointer 給 function ,再由 function 本身將 double pointer 指向新的 zval 記憶體空間。
最後當這個 zval 不用了怎麼辦?VM 得調用 zval_ptr_dtor
或 zval_dtor
把 zval 裡面使用的記憶體空間釋放掉。
講到這邊,看官們應該就瞭了,PHP5 的每個 zval 的調用都很貴,且由於有時要用 double pointer (pointer to pointer) 有時又用 pointer (single pointer) 也因此造成了代碼的混亂。
PHP7 的 zval 生命週期
於是,PHP7 做了一個很大的改變 — 將所有的 zval 的記憶體調用改為使用 stack 做 allocation (zval 容器自己)。
也因此所有的 _PP 巨集在 PHP7 中被拿掉了,取而代之的,被鼓勵使用的方式是直接在 stack 上騰出 zval container 的空間,於是新的寫法鼓勵開發者這樣做:
zval myval;
設定數值
為了要簡單快速的建立 zval 的值,Zend Engine 提供了一系列好用的巨集:
ZVAL_LONG(zv, lv)
ZVAL_DOUBLE(zv, dv)
ZVAL_TRUE(zv)
ZVAL_FALSE(zv)
ZVAL_NULL(zv)
ZVAL_UNDEF(zv)
創建字串則可以使用:
ZVAL_STR(z, s)
ZVAL_NEW_STR(z, s)
ZVAL_STR_COPY(z, s)
陣列則可使用:
ZVAL_ARR(z, a)
ZVAL_NEW_ARR(z)
ZVAL_NEW_PERSISTENT_ARR(z)
其他巨集定義可參考 Zend/zend_types.h
。
傳值
在傳值的時候,PHP7 也鼓勵開發者直接寫這樣的函數,直接傳值:
zval process_something(zval myval) { ... }
這樣做有一個好處,即是 — 在 Function 中我們可以不調用 emalloc
來申請新的空間,這也意味著,我們不必在 zval 該釋放的地方撰寫釋放 zval 的函數,代碼於是變得很乾淨:
zval myval;
ZVAL_LONG(&myval, 10);
zval retval = process_something(myval);
可以注意到,我們不再需要呼叫 ALLOC_ZVAL
與 INIT_ZVAL
,直接在 stack 上申請 zval 來用即可。
而取得 retval
之後,我們也就不用特地把 retval
用 efree
釋放掉,而是 dtor 即可 (如果只是 simple type,則直接從 stack 上釋放掉)
安插以及 Copy On Write
在 PHP 代碼中,我們經常會有以下的操作
$var = $bar;
$array[] = $bar;
要知道 Zend Engine 中所有的變數 (zval) 都是 copy on write,在還未修改某一變數前,其實 $var
在 VM 內是直接參照 $bar
,直到 $var
被修改了,才會利用 SEPARATE_ZVAL
來將兩個 ZVAL 分離為兩個實體變數。
而當一個 zval 被安插到陣列裡時,為了不要讓 gc 直接將 $bar
回收,此時我們必須在 Extension 中,手動將其 reference counting 加一,如:
Z_ADDREF_P(bar);
當這個變數被銷毀時,則調用 Z_DELREF_P
來將 reference counting 減一。
變數數值銷毀
至於釋放變數呢?還記得上方我們提到的 Reference Counting? Zend Engine 提供了以下兩個常用的 Macro:
zval_dtor
zval_ptr_dtor
這邊的 dtor 其實就是 destructor 的縮寫。以下是這兩個巨集的定義:
#define zval_dtor(zvalue) zval_ptr_dtor_nogc(zvalue)#define zval_ptr_dtor(zval_ptr) _zval_ptr_dtor((zval_ptr) ZEND_FILE_LINE_CC)
這兩個巨集,只看名稱的話只差了一個字,但底層在做的範疇是差蠻多的。我們看看 zval_ptr_dtor 做了什麼,追到 i_zval_ptr_dtor
函數,看到他裡面做這件事:
if (Z_REFCOUNTED_P(zval_ptr)) {
if (!Z_DELREF_P(zval_ptr)) {
_zval_dtor_func(Z_COUNTED_P(zval_ptr) ZEND_FILE_LINE_RELAY_CC);
} else {
GC_ZVAL_CHECK_POSSIBLE_ROOT(zval_ptr);
}
}
這邊你可以看到 zval_ptr_dtor
會去檢查 zval 是否是 refcountable variable,如果是,則檢查 refcount,若降至為 0 則直接註銷掉 zval 內的資料,若高於 0 ,則呼叫 garbage collector 重新掃描 possible root。
而 zval_dtor
調用的是 no gc 沒有 garbage collection:
static zend_always_inline void _zval_ptr_dtor_nogc(zval *zval_ptr ZEND_FILE_LINE_DC)
{
if (Z_REFCOUNTED_P(zval_ptr) && !Z_DELREF_P(zval_ptr)) {
_zval_dtor_func(Z_COUNTED_P(zval_ptr) ZEND_FILE_LINE_RELAY_CC);
}
}
也因此,當 zval 為 array 時,生命週期如下:
zval mylist;
array_init(&mylist);
.... do something
zval_ptr_dtor(&mylist);
這邊稍微注意一下,array_init
雖然是舊時代的產物,現在雖然多了 ZVAL_NEW_ARR
可以使用,但 ZVAL_NEW_ARR
並不初始化 zval 內的 hash table,所以若要用 ZVAL_NEW_ARR,則記得要手動初始化 hash table:
ZVAL_NEW_ARR(&new_array);
zend_hash_init(
Z_ARRVAL(new_array),
zend_hash_num_elements(Z_ARRVAL_P(stream_array)),
NULL, ZVAL_PTR_DTOR, 0
);
而當你相當確定 zval 為 simple value 時,為了減少 costs 則可以這樣做:
zval myval;
ZVAL_LONG(&myval, 100);
...
zval_dtor(&myval);
但因為我們的型別是 long value,所以 zval_dtor
基本上不做什麼事,事實上如果你相當確定 myval 永遠都是 long value 又是在 stack 上申請來的空間,則不呼叫 zval_dtor
也無所謂。
而當我們的 myval 裡面儲存的是 string, array, object,zval_dtor
則會將 string, array, object 本身的空間釋放掉,最後留下 zval container 留在 stack 上,而這個 zval container 會在離開這個函式時,自動被釋放掉。
這邊也就暗示著一個策略:
- 當 zval 是從 user space 傳來的時候,我們不能假定型態為 simple value,因此呼叫
zval_ptr_dtor
是必要的。 - 當 zval 只是在 VM space 內使用,又不會被 user space 修改,則我們可以大膽使用
zval_dtor
或甚至不調用zval_dtor
結語
一旦 extension 代碼發生 memory leak ,要在幾千行代碼中尋找 root cause 是相當花時間的。也因此在開發 PHP7 Extension 時,需對 zval 的生命週期有實際的了解,避免開發時產生 memory leak 的問題。