PHP7 VM 中 zval 的結構與生命週期

兩週前找機會實作了 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_dtorzval_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_ZVALINIT_ZVAL ,直接在 stack 上申請 zval 來用即可。

而取得 retval 之後,我們也就不用特地把 retvalefree 釋放掉,而是 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 會在離開這個函式時,自動被釋放掉。

這邊也就暗示著一個策略:

  1. 當 zval 是從 user space 傳來的時候,我們不能假定型態為 simple value,因此呼叫 zval_ptr_dtor 是必要的。
  2. 當 zval 只是在 VM space 內使用,又不會被 user space 修改,則我們可以大膽使用 zval_dtor 或甚至不調用 zval_dtor

結語

一旦 extension 代碼發生 memory leak ,要在幾千行代碼中尋找 root cause 是相當花時間的。也因此在開發 PHP7 Extension 時,需對 zval 的生命週期有實際的了解,避免開發時產生 memory leak 的問題。