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

c9s
c9s
Jan 12, 2017 · 14 min read

兩週前找機會實作了 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:

這邊的 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 的問題。

PHP Hacks

All about PHP Programming

Medium is an open platform where 170 million readers come to find insightful and dynamic thinking. Here, expert and undiscovered voices alike dive into the heart of any topic and bring new ideas to the surface. Learn more

Follow the writers, publications, and topics that matter to you, and you’ll see them on your homepage and in your inbox. Explore

If you have a story to tell, knowledge to share, or a perspective to offer — welcome home. It’s easy and free to post your thinking on any topic. Write on Medium

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store