PHP7變量的內部實現(一)


PHP7變量的內部實現-part 1

本文翻譯自Nikita的文章,水平有限,如有錯誤,歡迎指正查看原文

受篇幅限制,這篇文章將分為兩個部分。本部分會講解PHP5和PHP7在zval結構體的差異,同時也會討論引用的實現。第二部分會深入探究一些數據類型如string和對象的實現。

PHP5中的zval

PHP5中zval結構體的定義如下:

typedef struct _zval_struct {
    zvalue_value value;
    zend_uint refcount__gc;
    zend_uchar type;
    zend_uchar is_ref__gc;
} zval;

可以看到,zval由value、type和一些額外的__gc信息組成。__gc與垃圾回收相關,我們稍后討論。value是一個共用體,可以存儲y一個zval各種可能的值。

typedef union _zvalue_value {
    long lval;                 // For booleans, integers and resources
    double dval;               // For floating point numbers
    struct {                   // For strings
        char *val;
        int len;
    } str;
    HashTable *ht;             // For arrays
    zend_object_value obj;     // For objects
    zend_ast *ast;             // For constant expressions
} zvalue_value;

C語言中,共用體的尺寸與它最大的成員尺寸相同,在某一時刻只能有一個成員處於活動狀態。共用體所有的成員都存儲在相同的內存,根據你訪問的成員不同,內容會被解釋成不同的類型。以上面的共用體為例,如果訪問lval,值將被解釋為一個有符號整型;而訪問dval將被解釋成雙精度浮點型。以此類推。

為了弄清結構體中哪個成員處於活動狀態,zval會存儲一個整型type來標識具體的數據類型。

#define IS_NULL     0      /* Doesn't use value */
#define IS_LONG     1      /* Uses lval */
#define IS_DOUBLE   2      /* Uses dval */
#define IS_BOOL     3      /* Uses lval with values 0 and 1 */
#define IS_ARRAY    4      /* Uses ht */
#define IS_OBJECT   5      /* Uses obj */
#define IS_STRING   6      /* Uses str */
#define IS_RESOURCE 7      /* Uses lval, which is the resource ID */

/* Special types used for late-binding of constants */
#define IS_CONSTANT 8
#define IS_CONSTANT_AST 9

PHP5中的引用計數

除少數例外,在PHP5中zval都是分配在堆內存的,PHP需要通過某種方式跟蹤哪些zval在被使用,哪些應該被釋放。為達到這個目的,引用計數被使用。引用計數即在結構體中用refcount__gc成員來記錄該結構體被“引用”了多少次。例如,在$a = $b = 42中,42被兩個變量引用,所以它的引用計數為2。如果引用計數變成0,則意味着該值沒被使用,可以被釋放。

需要注意的是引用計數的“引用”(即一個值被引用的次數)與“PHP引用”($a=&$b)毫無關系。在接下來的內容里,我會始終使用“引用”和“PHP引用”這兩個術語來釋疑這兩個概念。就當前來說,我們先把“PHP引用”放在一邊。

與引用計數密切相關的一個概念是“寫時復制”(copy on write):zval只能在其內容未被修改的時候才能在多個變量間共享。要實現修改,zval必選被復制(分離),而改動只能在復制出的zval上進行。

以下例子展示了寫時復制和zval銷毀。

$a = 42;   // $a         -> zval_1(type=IS_LONG, value=42, refcount=1)
$b = $a;   // $a, $b     -> zval_1(type=IS_LONG, value=42, refcount=2)
$c = $b;   // $a, $b, $c -> zval_1(type=IS_LONG, value=42, refcount=3)

// 下一行操作會導致zval分離
$a += 1;   // $b, $c -> zval_1(type=IS_LONG, value=42, refcount=2)
           // $a     -> zval_2(type=IS_LONG, value=43, refcount=1)

unset($b); // $c -> zval_1(type=IS_LONG, value=42, refcount=1)
           // $a -> zval_2(type=IS_LONG, value=43, refcount=1)

unset($c); // zval_1 被銷毀,因為其refcount=0
           // $a -> zval_2(type=IS_LONG, value=43, refcount=1)

引用計數有一個致命缺陷:它不能檢測和釋放循環引用。為解決這個問題,PHP額外使用了環收集器。當一個zval的引用計數減少的時候,它就有一定幾率是循環引用的一部分,該zval就被寫入到“根緩沖區”。當根緩沖區滿后,可能的引用環將被標記並收集,同時啟動垃圾回收。

為了支持這個環收集器,實際使用了如下的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和一個指針-注意u是一個共用體,也就是說實際上只有一個指針,它可能指向兩種不同的類型。buffered指針用來存儲zval在根緩沖區中的引用位置,如果zval在環收集器運行之前就被銷毀(這是非常可能的),那么該指針將會從根緩沖區移除。next指針在收集器銷毀值的時候會被用到,但是我不會深入講解這一點。

改進的動機

先討論一下基於64位系統的內存占用。首先,zvalue_value共用體占用16個字節,因為它的str和obj成員都那么大。整個zval結構體一共24個字節(由於內存對齊[padding]),而zval_gc_info是32字節。除此之外,在堆分配的過程中,又增加了16字節的分配開銷。由此一個zval就占用48字節--盡管該zval可能在多個地方都被用到。

現在我們就可以分析下這種zval實現方式低效的地方。考慮用zval存儲整數的情況,整數占用8個字節,另外類型標示是必需的,它本身占用一個字節,但是由於內存對齊,實際上就要加上8個字節。

這16字節是我們真正“需要”的空間(近似的),此外,為了處理引用計數和垃圾回收,我們增加了16字節;由於分配開銷又增加了另外16字節。更不用提還要處理分配和后續的釋放,這都是很昂貴的操作。

由此引發了一個問題:一個簡單的整數真的需要存儲為一個有引用計數、可垃圾回收,並且是堆分配的值嗎?答案當然是不需要,這樣做是沒道理的。

以下概述了PHP5中zval實現方式的一些主要問題:

  • zval(幾乎)總是需要堆分配。
  • zval總是會被引用計數且攜帶環收集信息,即使是在共享值不划算(比如整數)和不能形成引用環的情況下。
  • 當處理對象和資源時,直接對zval進行引用計數會導致雙重計數。原因會在下一部分討論。
  • 某些情況會引入很多的間接操作。比如為了訪問一個對象,一共要進行4次指針跳轉。這也將在下一篇中分析。
  • 直接對zval進行引用計數意味着值只能在zval間共享。比如我們不能在zval和哈希表key之間共享一個字符串(不將哈希表key用zval變量存放)。

PHP7中的zval

通過以上討論,我們引進了PHP7新的zval實現。最根本的改變是zval不再是堆分配且它自身不再存儲引用計數。相反的,對zval指向的任何復雜類型值(如字符串、數組、對象),這些值將自己存儲引用計數。這有以下優點:

  • 簡單值不需要分配且不用引用計數。
  • 不再有雙重引用計數。對對象來說,只有在對象本身存在引用計數。
  • 由於引用計數保存在值中,這個可以獨立於zval結構而被復用。同一個字符串能同時被zval和哈希表key引用。
  • 間接操作少了很多,也就是說在獲取一個值的時候需要跳轉的指針數量變少了。

新的zval定義如下:

struct _zval_struct {
    zend_value value;
    union {
        struct {
            ZEND_ENDIAN_LOHI_4(
                zend_uchar type,
                zend_uchar type_flags,
                zend_uchar const_flags,
                zend_uchar reserved)
        } 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;
};

第一個成員跟之前類似,也是一個value共同體。第二個成員是個整數,用來存儲類型信息,它被一個共用體分隔成獨立的字節空間(可忽略ZEND_ENDIAN_LOHI_4宏,它是用來保證在不同字節序平台上布局的一致性)。這個子結構中type(它跟之前類似)和type_flags比較重要,我將稍后討論他們。

此時有一個小問題:value成員占8字節空間,由於結構體內存對齊,即使增加一個字節也會讓zval內存增長到16字節。然而很明顯我們不需要8個字節來僅僅存放類型信息。這就是為什么此zval包含了一個額外的u2共用體,它默認情況下是沒被占用的,但是卻可以根據需要存儲4字節的數據。這個共用體中不同的成員用來實現該額外數據片段不同的用途。

PHP7中的value共用體看起來略有不同:

typedef union _zend_value {
    zend_long         lval;
    double            dval;
    zend_refcounted  *counted;
    zend_string      *str;
    zend_array       *arr;
    zend_object      *obj;
    zend_resource    *res;
    zend_reference   *ref;
    zend_ast_ref     *ast;

    // Ignore these for now, they are special
    zval             *zv;
    void             *ptr;
    zend_class_entry *ce;
    zend_function    *func;
    struct {
        ZEND_ENDIAN_LOHI(
            uint32_t w1,
            uint32_t w2)
    } ww;
} zend_value;

首先要注意到這個共用體占用8字節而不是16字節。它僅僅會直接存儲整數(lval)和雙精度浮點數(dval),對其它類型它都會存儲對應指針。所有的指針類型(除了什么代碼中標記為特殊的)都會引用計數並且有一個通用的頭部,定義為zend_refcounted:

struct _zend_refcounted {
    uint32_t refcount;
    union {
        struct {
            ZEND_ENDIAN_LOHI_3(
                zend_uchar    type,
                zend_uchar    flags,
                uint16_t      gc_info)
        } v;
        uint32_t type_info;
    } u;
};

不用說這個結構會包含引用計數。另外,它還包含type、flags和gc_info。type是復制的zval的type,它使得GC在不存儲zval的情況下就能區分不同的引用計數結構。根據類型的不同,flags有不同的使用目的,這些會在下一部分按類型分別討論。

gc_info等同於老zval中的buffered成員。不同的是它存儲了在根緩沖區中的索引,來代替之前的指針。因為跟緩沖區尺寸固定(10000個元素),用16字節的數子而不是64位的指針就足夠了。gc_info還含有該節點的“顏色”信息,這在垃圾回收中用來標記節點。

zval內存管理

我已經提到zval不再是單獨的堆分配。然而很明顯它仍然需要被存在某個地方,那么這是怎么實現的呢?盡管zval大多數時候仍是堆分配數據結構的一部分,不過它們是直接嵌入到這些數據結構中的。比如哈希表就會直接內置zval而不是存放一個指向另一zval的指針。函數的編譯變量表或者對象的屬性表會直接保存為一個擁有連續內存的zval數組,而不再存儲指向散落各處zval的指針。因此當前的zval存儲通常都會少了一層的間接引用,也就是說現在的zval相當於之前的zval*。

當一個zval在新的地方被引用時,按照之前的方式,就意味着要復制zavl*並增加它的引用計數。現在則需要復制zval的內容,同時如果該zval指向的值用到引用計數的話則還要增加該值的引用計數。

PHP是如何知道一個值是否用到引用計數的呢?這不能僅僅依靠類型來判斷,因為有些類型比如字符串和數組並不總是引用計數的。相反的,會根據構成zval的type_info的一個字節來判斷是否引用計數。另外還有其它幾個字節編碼了該類型的一些特征。

#define IS_TYPE_CONSTANT            (1<<0)   /* special */
#define IS_TYPE_IMMUTABLE           (1<<1)   /* special */
#define IS_TYPE_REFCOUNTED          (1<<2)
#define IS_TYPE_COLLECTABLE         (1<<3)
#define IS_TYPE_COPYABLE            (1<<4)
#define IS_TYPE_SYMBOLTABLE         (1<<5)   /* special */

一個類型能擁有的三個主要特征是引用計數、可回收和可復制。引用計數的含義已討論過,可回收意味着該zval可能參與循環引用。舉例來說,字符串(通常)是引用計數的,但是卻沒法用字符串構造一個引用環。

可復制性決定了在為一個變量創建“副本”的時候它的值是否需要執行拷貝。副本是硬拷貝,比如復制指向數組的zval時,就不是簡單的增加數組的引用計數,而是要創建該數組的一個新的獨立拷貝。然而對對象和資源這些類型來說,復制應該僅僅增加引用計數--這些類型就是所謂的不可復制。這與對象和資源在進行傳遞時的語義相符(當前不是引用傳遞)。

以下表格展示了不同類型和它們所用的標識。“簡單類型”指整數和布爾值這類不需要用指針指向一個單獨結構的類型。同時還用一列展示了“不可變”標記,它用來標記不可變數組,這將在下一部分詳細討論。

                | refcounted | collectable | copyable | immutable
----------------+------------+-------------+----------+----------
simple types    |            |             |          |
string          |      x     |             |     x    |
interned string |            |             |          |
array           |      x     |      x      |     x    |
immutable array |            |             |          |     x
object          |      x     |      x      |          |
resource        |      x     |             |          |
reference       |      x     |             |          |

來看一下在實際中zval管理是如何工作的。先基於上文PHP5的例子來討論一下整型實現:

$a = 42;   // $a = zval_1(type=IS_LONG, value=42)

$b = $a;   // $a = zval_1(type=IS_LONG, value=42)
           // $b = zval_2(type=IS_LONG, value=42)

$a += 1;   // $a = zval_1(type=IS_LONG, value=43)
           // $b = zval_2(type=IS_LONG, value=42)

unset($a); // $a = zval_1(type=IS_UNDEF)
           // $b = zval_2(type=IS_LONG, value=42)

這個例子挺無趣的。簡單來說就是整型不會再被共用,這些變量都有單獨的zval。不要忘了zval不再需要單獨分配,它們是內嵌的,我通過把->換成=來表示這種變化。unset一個變量會把對應zval的type設置為IS_UNDEF。現在來考慮一下當涉及復雜類型時的情況,這種案例有趣的多。

$a = [];   // $a = zval_1(type=IS_ARRAY) -> zend_array_1(refcount=1, value=[])

$b = $a;   // $a = zval_1(type=IS_ARRAY) -> zend_array_1(refcount=2, value=[])
           // $b = zval_2(type=IS_ARRAY) ---^

// zval在這里發生了分離
$a[] = 1   // $a = zval_1(type=IS_ARRAY) -> zend_array_2(refcount=1, value=[1])
           // $b = zval_2(type=IS_ARRAY) -> zend_array_1(refcount=1, value=[])

unset($a); // $a = zval_1(type=IS_UNDEF) and zend_array_2 is destroyed
           // $b = zval_2(type=IS_ARRAY) -> zend_array_1(refcount=1, value=[])

本例中每個變量依然有單獨的zval(內嵌的),但是這些zval都指向了同一個zend_array(引用計數的)結構。同PHP5一樣,當發生修改時,數組需要被復制。

類型

看一下PHP7是如何支持各種數據類型的:

// 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

// internal types
#define IS_INDIRECT                 15
#define IS_PTR                      17

這個列表跟PHP5類似,但有一些內容增加:

  • IS_UNDEF類型替代了之前的NULL zval指針(注意與IS_NULL zval區分),比如在上面引用計數的例子中,變量被unset時,zval的類型就被置為IS_UNDEF。
  • IS_BOOL類型被細分成了IS_FALSE和IS_TRUE。由此布爾變量的值就被編碼在類型中,這就使得一些基於類型檢查的優化成為可能。這個改變對用戶層是透明的,仍然有一個“布爾”類型。
  • 在zval上,PHP引用不再使用is_ref標識,而是用IS_REFERENCE類型。下一部分將會討論。
  • IS_INDIRECT和IS_PTR是特殊的內部類型。

IS_LONG目前存儲的是zend_long類型的值,而不是一個普通的C語言long整數。原因是在64位windows(LLP64)上,long型只有32位,於是在windows上PHP5的IS_LONG總是32位的。在64位操作系統上,即使你使用的是windows,PHP7都允許你使用64位的數字。

zend_refcounted類型相關的細節將在下一部分討論,現在我們先看一下PHP引用的實現。

引用

PHP7處理PHP引用(&)的方式與PHP5完全不同(我可以告訴你這個改變是PHP7最大的bug來源之一)。PHP5中引用的實現如下:

通常,寫時復制(COW)機制意味着在修改之前,zval要先進行分離,以保證不會把其它共用該zval的變量給一起修改了。這與值傳遞的語義相符。

對PHP引用來說,就不是這種情況了。如果一個值是引用,那么修改的時候就希望其它變量也同步被修改。PHP5用is_ref來判斷一個值是不是PHP引用,以及在修改的時候是否要執行分離操作。看一個例子:

$a = [];  // $a     -> zval_1(type=IS_ARRAY, refcount=1, is_ref=0) -> HashTable_1(value=[])
$b =& $a; // $a, $b -> zval_1(type=IS_ARRAY, refcount=2, is_ref=1) -> HashTable_1(value=[])

$b[] = 1; // $a = $b = zval_1(type=IS_ARRAY, refcount=2, is_ref=1) -> HashTable_1(value=[1])

這種設計一個很重大的問題就是不能在普通變量和PHP引用之前共享一個值。考慮如下情形:

$a = [];  // $a         -> zval_1(type=IS_ARRAY, refcount=1, is_ref=0) -> HashTable_1(value=[])
$b = $a;  // $a, $b     -> zval_1(type=IS_ARRAY, refcount=2, is_ref=0) -> HashTable_1(value=[])
$c = $b   // $a, $b, $c -> zval_1(type=IS_ARRAY, refcount=3, is_ref=0) -> HashTable_1(value=[])

$d =& $c; // $a, $b -> zval_1(type=IS_ARRAY, refcount=2, is_ref=0) -> HashTable_1(value=[])
          // $c, $d -> zval_1(type=IS_ARRAY, refcount=2, is_ref=1) -> HashTable_2(value=[])
          // $d是$c的引用, 但不是$a和$b的引用,所以zval要復制。
          //現在就有了相同的zval,一個is_ref=0,一個is_ref=1

$d[] = 1; // $a, $b -> zval_1(type=IS_ARRAY, refcount=2, is_ref=0) -> HashTable_1(value=[])
          // $c, $d -> zval_1(type=IS_ARRAY, refcount=2, is_ref=1) -> HashTable_2(value=[1])
          // 由於有兩個獨立的zval $d[] = 1 不會修改到$a和$b.

這種行為就導致使用PHP引用通常比普通變量更慢。下面的例子就有這個問題:

$array = range(0, 1000000);
$ref =& $array;
var_dump(count($array)); // <-- 這里發生zval分離

因為count()的參數是按值傳遞的,而$array是一個引用變量,在把它傳遞給count()時,會對該數組執行完整的復制。如果$array不是引用,它的值就可以共用,在傳遞的時候就不會發生復制。

現在來看下PHP7中引用的實現。由於zval不再是獨立分配,不再可能使用PHP5一樣的方式。轉而增加了IS_REFERENCEl類型,它的值是如下的zend_reference結構:

struct _zend_reference {
    zend_refcounted   gc;
    zval              val;
};

所以zend_reference本質上只是一個有引用計數的zval。在一個引用集合中所有的變量都會保存一份IS_REFERENCEl類型的zval,並且指向同一個zend_reference實例。val跟其他zval類似,特別是它可以共享其指向的復雜值。比如數組可以在普通變量和引用變量之間共享。

還是上面的示例代碼,來看一下在PHP7下的情形。為了簡潔性,我不會再寫變量的zval,只展示它們指向的值。

$a = [];  // $a                                     -> zend_array_1(refcount=1, value=[])
$b =& $a; // $a, $b -> zend_reference_1(refcount=2) -> zend_array_1(refcount=1, value=[])

$b[] = 1; // $a, $b -> zend_reference_1(refcount=2) -> zend_array_1(refcount=1, value=[1])

引用賦值會創建一個zend_reference,該引用的引用計數是2(有兩個變量用到了這個引用),但是值本身的引用計數是1(只有一個zend_reference指向了該值)。再考慮下引用變量和普通變量混合的情況:

$a = [];  // $a         -> zend_array_1(refcount=1, value=[])
$b = $a;  // $a, $b,    -> zend_array_1(refcount=2, value=[])
$c = $b   // $a, $b, $c -> zend_array_1(refcount=3, value=[])

$d =& $c; // $a, $b                                 -> zend_array_1(refcount=3, value=[])
          // $c, $d -> zend_reference_1(refcount=2) ---^
          // 注意所有的變量共享同一個zend_array, 即使有的是引用,有的不是。

$d[] = 1; // $a, $b                                 -> zend_array_1(refcount=2, value=[])
          // $c, $d -> zend_reference_1(refcount=2) -> zend_array_2(refcount=1, value=[1])
          // 只有當賦值發生的時候,zend_array才會復制,即寫時分離。

與PHP5一個重要的不同是所有的變量都能共享同一個數組,即使有的是引用變量有的不是。只有當進行修改的時候才會發生分離。這意味着在PHP7中把一個很大的引用數組傳遞給count()是安全的,因為不會復制。但是引用仍然會比普通變量慢,因為需要分配zend_reference結構(以及由此產生的間接操作),而且機器碼處理起來也不會很快。

總結

總的來說,PHP7主要的改變是zval不再是獨立的堆分配且其本身不再存儲引用計數。轉而是它們指向的復雜類型的值(如字符串、數組、對象)會存儲引用計數。這通常會帶來更少的內存分配、間接操作和內存使用。

下一部分將會討論其它復雜類型。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM