變數在 PHP7 內部的實現(二)

Scholer's Blog發表於2015-12-24

本文第一部分和第二均翻譯自Nikita Popov(nikic,PHP 官方開發組成員,柏林科技大學的學生) 的部落格。為了更符合漢語的閱讀習慣,文中並不會逐字逐句的翻譯。

要理解本文,你應該對 PHP5 中變數的實現有了一些瞭解,本文重點在於解釋 PHP7 中 zval 的變化。

第一部分講了 PHP5 和 PHP7 中關於變數最基礎的實現和變化。這裡再重複一下,主要的變化就是 zval 不再單獨分配記憶體,不自己儲存引用計數。整型浮點型等簡單型別直接儲存在 zval 中。複雜型別則通過指標指向一個獨立的結構體。

複雜的 zval 資料值有一個共同的頭,其結構由 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;
};

這個頭儲存有 refcount(引用計數),值的型別 type 和迴圈回收的相關資訊 gc_info 以及型別標誌位 flags

接下來會對每種複雜型別的實現單獨進行分析並和 PHP5 的實現進行比較。引用雖然也屬於複雜型別,但是上一部分已經介紹過了,這裡就不再贅述。另外這裡也不會講到資源型別(因為作者覺得資源型別沒什麼好講的)。

字串

PHP7 中定義了一個新的結構體 zend_string 用於儲存字串變數:

struct _zend_string {
    zend_refcounted   gc;
    zend_ulong        h;        /* hash value */
    size_t            len;
    char              val[1];
};

除了引用計數的頭以外,字串還包含雜湊快取 h,字串長度 len 以及字串的值 val。雜湊快取的存在是為了防止使用字串做為 hashtable 的 key 在查詢時需要重複計算其雜湊值,所以這個在使用之前就對其進行初始化。

如果你對 C 語言瞭解的不是很深入的話,可能會覺得 val 的定義有些奇怪:這個宣告只有一個元素,但是顯然我們想儲存的字串償付肯定大於一個字元的長度。這裡其實使用的是結構體的一個『黑』方法:在宣告陣列時只定義一個元素,但是實際建立 zend_string 時再分配足夠的記憶體來儲存整個字串。這樣我們還是可以通過 val 訪問完整的字串。

當然這屬於非常規的實現手段,因為我們實際的讀和寫的內容都超過了單字元陣列的邊界。但是 C 語言編譯器卻不知道你是這麼做的。雖然 C99 也曾明確規定過支援『柔性陣列』,但是感謝我們的好朋友微軟,沒人能在不同的平臺上保證 C99 的一致性(所以這種手段是為了解決 Windows 平臺下柔性陣列的支援問題)。

新的字串型別的結構比原生的 C 字串更方便使用:第一是因為直接儲存了字串的長度,這樣就不用每次使用時都去計算。第二是字串也有引用計數的頭,這樣也就可以在不同的地方共享字串本身而無需使用 zval。一個經常使用的地方就是共享 hashtable 的 key。

但是新的字串型別也有一個很不好的地方:雖然可以很方便的從 zend_string 中取出 C 字串(使用 str->val 即可),但反過來,如果將 C 字串變成 zend_string 就需要先分配 zend_string 需要的記憶體,再將字串複製到 zend_string 中。這在實際使用的過程中並不是很方便。

字串也有一些特有的標誌(儲存在 GC 的標誌位中的):

#define IS_STR_PERSISTENT           (1/* allocated using malloc */
#define IS_STR_INTERNED             (1/* interned string */
#define IS_STR_PERMANENT            (1/* interned string surviving request boundary */

持久化的字串需要的記憶體直接從系統本身分配而不是 zend 記憶體管理器(ZMM),這樣它就可以一直存在而不是隻在單次請求中有效。給這種特殊的分配打上標記便於 zval 使用持久化字串。在 PHP5 中並不是這樣處理的,是在使用前複製一份到 ZMM 中。

保留字元(interned strings)有點特殊,它會一直存在直到請求結束時才銷燬,所以也就無需進行引用計數。保留字串也不可重複(duplicate),所以在建立新的保留字元時也會先檢查是否有同樣字元的已經存在。所有 PHP 原始碼中不可變的字串都是保留字元(包括字串常量、變數名函式名等)。持久化字串也是請求開始之前已經建立好的保留字元。但普通的保留字元在請求結束後會銷燬,持久化字串卻始終存在。

如果使用了 opcache 的話保留字元會被儲存在共享記憶體(SHM)中這樣就可以在所有 PHP 程式質檢共享。這種情況下持久化字串也就沒有存在的意義了,因為保留字元也是不會被銷燬的。

陣列

因為之前的文章有講過新的陣列實現,所以這裡就不再詳細描述了。雖然最近有些變化導致之前的描述不是十分準確了,但是基本的概念還是一致的。

這裡要說的是之前的文章中沒有提到的陣列相關的概念:不可變陣列。其本質上和保留字元類似:沒有引用計數且在請求結束之前一直存在(也可能在請求結束之後還存在)。

因為某些記憶體管理方便的原因,不可變陣列只會在開啟 opcache 時會使用到。我們來看看實際使用的例子,先看以下的指令碼:

for ($i = 0; $i  1000000; ++$i) {
    $array[] = ['foo'];
}
var_dump(memory_get_usage());

開啟 opcache 時,以上程式碼會使用 32MB 的記憶體,不開啟的情況下因為 $array 每個元素都會複製一份 ['foo'] ,所以需要 390MB。這裡會進行完整的複製而不是增加引用計數值的原因是防止 zend 虛擬機器操作符執行的時候出現共享記憶體出錯的情況。我希望不使用 opcache 時記憶體暴增的問題以後能得到改善。

PHP5 中的物件

在瞭解 PHP7 中的物件實現直線我們先看一下 PHP5 的並且看一下有什麼效率上的問題。PHP5 中的 zval 會儲存一個 zend_object_value 結構,其定義如下:

typedef struct _zend_object_value {
    zend_object_handle handle;
    const zend_object_handlers *handlers;
} zend_object_value;

handle 是物件的唯一 ID,可以用於查詢物件資料。handles 是儲存物件各種屬性方法的虛擬函式表指標。通常情況下 PHP 物件都有著同樣的 handler 表,但是 PHP 擴充套件建立的物件也可以通過操作符過載等方式對其行為自定義。

物件控制程式碼(handler)是作為索引用於『物件儲存』,物件儲存本身是一個儲存容器(bucket)的陣列,bucket 定義如下:

typedef struct _zend_object_store_bucket {
    zend_bool destructor_called;
    zend_bool valid;
    zend_uchar apply_count;
    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;

這個結構體包含了很多東西。前三個成員只是些普通的後設資料(物件的解構函式是否被呼叫過、bucke 是否被使用過以及物件被遞迴呼叫過多少次)。接下來的聯合體用於區分 bucket 是處於使用中的狀態還是空閒狀態。上面的結構中最重要的是 struct _store_object 子結構體:

第一個成員 object 是指向實際物件(也就是物件最終儲存的位置)的指標。物件實際並不是直接嵌入到物件儲存的 bucket 中的,因為物件不是定長的。物件指標下面是三個用於管理物件銷燬、釋放與克隆的操作控制程式碼(handler)。這裡要注意的是 PHP 銷燬和釋放物件是不同的步驟,前者在某些情況下有可能會被跳過(不完全釋放)。克隆操作實際上幾乎幾乎不會被用到,因為這裡包含的操作不是普通物件本身的一部分,所以(任何時候)他們在每個物件中他們都會被單獨複製(duplicate)一份而不是共享。

這些物件儲存操作控制程式碼後面是一個普通的物件 handlers 指標。儲存這幾個資料是因為有時候可能會在 zval 未知的情況下銷燬物件(通常情況下這些操作都是針對 zval 進行的)。

bucket 也包含了 refcount 的欄位,不過這種行為在 PHP5 中顯得有些奇怪,因為 zval 本身已經儲存了引用計數。為什麼還需要一個多餘的計數呢?問題在於雖然通常情況下 zval 的『複製』行為都是簡單的增加引用計數即可,但是偶爾也會有深度複製的情況出現,比如建立一個全新的 zval 但是儲存同樣的 zend_object_value。這種情況下兩個不同的 zval 就用到了同一個物件儲存的 bucket,所以 bucket 自身也需要進行引用計數。這種『雙重計數』的方式是 PHP5 的實現內在的問題。GC 根緩衝區中的 buffered 指標也是由於同樣的原因才需要進行完全複製(duplicate)。

現在看看物件儲存中指標指向的實際的 object 的結構,通常情況下使用者層面的物件定義如下:

typedef struct _zend_object {
    zend_class_entry *ce;
    HashTable *properties;
    zval **properties_table;
    HashTable *guards;
} zend_object;

zend_class_entry 指標指向的是物件實現的類原型。接下來的兩個元素是使用不同的方式儲存物件屬性。動態屬性(執行時新增的而不是在類中定義的)全部存在 properties 中,不過只是屬性名和值的簡單匹配。

不過這裡有針對已經宣告的屬性的一個優化:編譯期間每個屬性都會被指定一個索引並且屬性本身是儲存在 properties_table 的索引中。屬性名稱和索引的匹配儲存在類原型的 hashtable 中。這樣就可以防止每個物件使用的記憶體超過 hashtable 的上限,並且屬性的索引會在執行時有多處快取。

guards 的雜湊表是用於實現魔術方法的遞迴行為的,比如 __get,這裡我們不深入討論。

除了上文提到過的雙重計數的問題,這種實現還有一個問題是一個最小的只有一個屬性的物件也需要 136 個位元組的記憶體(這還不算 zval 需要的記憶體)。而且中間存在很多間接訪問動作:比如要從物件 zval 中取出一個元素,先需要取出物件儲存 bucket,然後是 zend object,然後才能通過指標找到物件屬性表和 zval。這樣這裡至少就有 4 層間接訪問(並且實際使用中可能最少需要七層)。

PHP7 中的物件

PHP7 的實現中試圖解決上面這些問題,包括去掉雙重引用計數、減少記憶體使用以及間接訪問。新的 zend_object 結構體如下:

struct _zend_object {
    zend_refcounted   gc;
    uint32_t          handle;
    zend_class_entry *ce;
    const zend_object_handlers *handlers;
    HashTable        *properties;
    zval              properties_table[1];
};

可以看到現在這個結構體幾乎就是一個物件的全部內容了:zend_object_value 已經被替換成一個直接指向物件和物件儲存的指標,雖然沒有完全移除,但已經是很大的提升了。

除了 PHP7 中慣用的 zend_refcounted 頭以外,handle 和 物件的 handlers 現在也被放到了 zend_object 中。這裡的 properties_table 同樣用到了 C 結構體的小技巧,這樣 zend_object 和屬性表就會得到一整塊記憶體。當然,現在屬性表是直接嵌入到 zval 中的而不是指標。

現在物件結構體中沒有了 guards 表,現在如果需要的話這個欄位的值會被儲存在 properties_table 的第一位中,也就是使用 __get 等方法的時候。不過如果沒有使用魔術方法的話,guards 表會被省略。

dtorfree_storageclone 三個操作控制程式碼之前是儲存在物件操作 bucket 中,現在直接存在 handlers 表中,其結構體定義如下:

struct _zend_object_handlers {
    /* offset of real object header (usually zero) */
    int                                     offset;
    /* general object functions */
    zend_object_free_obj_t                  free_obj;
    zend_object_dtor_obj_t                  dtor_obj;
    zend_object_clone_obj_t                 clone_obj;
    /* individual object functions */
    // ... rest is about the same in PHP 5
};

handler 表的第一個成員是 offset,很顯然這不是一個操作控制程式碼。這個 offset 是現在的實現中必須存在的,因為雖然內部的物件總是嵌入到標準的 zend_object 中,但是也總會有新增一些成員進去的需求。在 PHP5 中解決這個問題的方法是新增一些內容到標準的物件後面:

struct custom_object {
    zend_object std;
    uint32_t something;
    // ...
};

這樣如果你可以輕易的將 zend_object* 新增到 struct custom_object* 中。這也是 C 語言中常用的結構體繼承的做法。但是在 PHP7 中這種實現會有一個問題:因為 zend_object 在儲存屬性表時用了結構體 hack 的技巧,zend_object 尾部儲存的 PHP 屬性會覆蓋掉後續新增進去的內部成員。所以 PHP7 的實現中會把自己新增的成員新增到標準物件結構的前面:

struct custom_object {
    uint32_t something;
    // ...
    zend_object std;
};

不過這樣也就意味著現在無法直接在 zend_object*struct custom_object* 進行簡單的轉換了,因為兩者都一個偏移分割開了。所以這個偏移量就需要被儲存在物件 handler 表中的第一個元素中,這樣在編譯時通過 offsetof() 巨集就能確定具體的偏移值。

也許你會好奇既然現在已經直接(在 zend_value 中)儲存了 zend_object 的指標,那現在就不需要再到物件儲存中去查詢物件了,為什麼 PHP7 的物件者還保留著 handle 欄位呢?

這是因為現在物件儲存仍然存在,雖然得到了極大的簡化,所以保留 handle 仍然是有必要的。現在它只是一個指向物件的指標陣列。當物件被建立時,會有一個指標插入到物件儲存中並且其索引會儲存在 handle 中,當物件被釋放時,索引也會被移除。

那麼為什麼現在還需要物件儲存呢?因為在請求結束的階段會在存在某個節點,在這之後再去執行使用者程式碼並且取指標資料時就不安全了。為了避免這種情況出現 PHP 會在更早的節點上執行所有物件的解構函式並且之後就不再有此類操作,所以就需要一個活躍物件的列表。

並且 handle 對於除錯也是很有用的,它讓每個物件都有了一個唯一的 ID,這樣就很容易區分兩個物件是同一個還是隻是有相同的內容。雖然 HHVM 沒有物件儲存的概念,但它也存了物件的 handle。

和 PHP5 相比,現在的實現中只有一個引用計數(zval 自身不計數),並且記憶體的使用量有了很大的縮減:40 個位元組用於基礎物件,每個屬性需要 16 個位元組,並且這還是算了 zval 之後的。間接訪問的情況也有了顯著的改善,因為現在中間層的結構體要麼被去掉了,要麼就是直接嵌入的,所以現在讀取一個屬性只有一層訪問而不再是四層。

間接 zval

到現在我們已經基本提到過了所有正常的 zval 型別,但是也有一對特殊型別用於某些特定的情況的,其中之一就是 PHP7 新新增的 IS_INDIRECT

間接 zval 指的就是其真正的值是儲存在其他地方的。注意這個 IS_REFERENCE 型別是不同的,間接 zval 是直接指向另外一個 zval 而不是像 zend_reference 結構體一樣嵌入 zval。

為了理解在什麼時候會出現這種情況,我們來看一下 PHP 中變數的實現(實際上物件屬性的儲存也是一樣的情況)。

所有在編譯過程中已知的變數都會被指定一個索引並且其值會被存在編譯變數(CV)表的相應位置中。但是 PHP 也允許你動態的引用變數,不管是區域性變數還是全域性變數(比如 $GLOBALS),只要出現這種情況,PHP 就會為指令碼或者函式建立一個符號表,這其中包含了變數名和它們的值之間的對映關係。

但是問題在於:怎麼樣才能實現兩個表的同時訪問呢?我們需要在 CV 表中能夠訪問普通變數,也需要能在符號表中訪問編譯變數。在 PHP5 中 CV 表用了雙重指標 zval**,通常這些指標指向中間的 zval* 的表,zval* 最終指向的才是實際的 zval:

+------ CV_ptr_ptr[0]
| +---- CV_ptr_ptr[1]
| | +-- CV_ptr_ptr[2]
| | |
| | +-> CV_ptr[0] --> some zval
| +---> CV_ptr[1] --> some zval
+-----> CV_ptr[2] --> some zval

當需要使用符號表時儲存 zval* 的中間表其實是沒有用到的而 zval** 指標會被更新到 hashtable buckets 的響應位置中。我們假定有 $a$b$c 三個變數,下面是簡單的示意圖:

CV_ptr_ptr[0] --> SymbolTable["a"].pDataPtr --> some zval
CV_ptr_ptr[1] --> SymbolTable["b"].pDataPtr --> some zval
CV_ptr_ptr[2] --> SymbolTable["c"].pDataPtr --> some zval

但是 PHP7 的用法中已經沒有這個問題了,因為 PHP7 中的 hashtable 大小發生變化時 hashtable bucket 就失效了。所以 PHP7 用了一個相反的策略:為了訪問 CV 表中儲存的變數,符號表中儲存 INDIRECT 來指向 CV 表。CV 表在符號表的生命週期內不會重新分配,所以也就不會存在有無效指標的問題了。

所以加入你有一個函式並且在 CV 表中有 $a$b$c,同時還有一個動態分配的變數 $d,符號表的結構看起來大概就是這個樣子:

SymbolTable["a"].value = INDIRECT --> CV[0] = LONG 42
SymbolTable["b"].value = INDIRECT --> CV[1] = DOUBLE 42.0
SymbolTable["c"].value = INDIRECT --> CV[2] = STRING --> zend_string("42")
SymbolTable["d"].value = ARRAY --> zend_array([4, 2])

間接 zval 也可以是一個指向 IS_UNDEF 型別 zval 的指標,當 hashtable 沒有和它關聯的 key 時就會出現這種情況。所以當使用 unset($a)CV[0] 的型別標記為 UNDEF 時就會判定符號表不存在鍵值為 a 的資料。

常量和 AST

還有兩個需要說一下的在 PHP5 和 PHP7 中都存在的特殊型別 IS_CONSTANTIS_CONSTANT_AST。要了解他們我們還是先看以下的例子:

function test($a = ANSWER,
              $b = ANSWER * ANSWER) {
    return $a + $b;
}

define('ANSWER', 42);
var_dump(test()); // int(42 + 42 * 42)·

test() 函式的兩個引數的預設值都是由常量 ANSWER構成,但是函式宣告時常量的值尚未定義。常量的具體值只有通過 define() 定義時才知道。

由於以上問題的存在,引數和屬性的預設值、常量以及其他接受『靜態表示式』的東西都支援『延時繫結』直到首次使用時。

常量(或者類的靜態屬性)這些需要『延時繫結』的資料就是最常需要用到 IS_CONSTANT 型別 zval 的地方。如果這個值是表示式,就會使用 IS_CONSTANT_AST 型別的 zval 指向表示式的抽象語法樹(AST)。

到這裡我們就結束了對 PHP7 中變數實現的分析。後面我可能還會寫兩篇文章來介紹一些虛擬機器優化、新的命名約定以及一些編譯器基礎結構的優化的內容(這是作者原話)。

譯者注:兩篇文章篇幅較長,翻譯中可能有疏漏或不正確的地方,如果發現了請及時指正。

相關文章