この記事の前半と後半は、Nikita Popov (nikic、PHP 公式開発チームのメンバー、ベルリン工科大学の学生) のブログから翻訳されたものです。中国語の読書習慣との一貫性を高めるため、テキストは一語一語翻訳されません。
この記事を理解するには、PHP5 の変数の実装についてある程度理解している必要があります。この記事の焦点は、PHP7 の zval の変更点を説明することです。
詳細な説明が膨大になるため、この記事は 2 つの部分に分かれます。最初の部分では、主に、zval(zend value) の実装が PHP5 と PHP7 の間でどのように異なるか、および参照される実装について説明します。 2 番目の部分では、個々の型 (文字列、オブジェクト) の詳細を分析します。
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 には値と型が含まれます。 __gc サフィックスが付いた 2 つの A フィールド。 value は共用体であり、さまざまなタイプの値を格納するために使用されます。
typedef union _zvalue_value { long lval; // 用于 bool 类型、整型和资源类型 double dval; // 用于浮点类型 struct { // 用于字符串 char *val; int len; } str; HashTable *ht; // 用于数组 zend_object_value obj; // 用于对象 zend_ast *ast; // 用于常量表达式(PHP5.6 才有)} zvalue_value;
C 言語共用体の特徴は、一度に 1 つのメンバーのみが有効で、割り当てられるメモリーは必要なメモリのほとんどが一致します (メモリの配置も考慮する必要があります)。すべてのメンバーはメモリ内の同じ場所に保存され、必要に応じて異なる値が保存されます。 lval が必要な場合は符号付き整数を格納し、dval が必要な場合は倍精度浮動小数点数を格納します。
現在ユニオンに格納されているデータ型は、整数でマークされた type フィールドに記録されることに注意してください: PHP5 では
#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 では、zval メモリはヒープとは別に割り当てられます (いくつかの例外を除き)。PHP は、どの zval が使用中で、どの zval を解放する必要があるかを知る必要があります。したがって、これには参照カウントを使用する必要があります。zval の refcount__gc の値は、zval 自体が参照される回数を保存するために使用されます。たとえば、$a = $b = 42 ステートメントでは、42 は 2 つの変数によって参照されます。その参照カウントは 2 です。参照カウントが 0 になった場合は、変数が使用されなくなったことを意味し、メモリを解放できます。
ここで言及する参照カウントは、PHP コード内の参照 (& を使用) を指すのではなく、変数が使用される回数を指すことに注意してください。後者の 2 つを同時に表示する必要がある場合は、2 つの概念を区別するために「PHP リファレンス」と「リファレンス」が使用されます。PHP の部分はここでは無視されます。
参照カウントに密接に関連する概念は「コピーオンライト」です。複数の参照の場合、参照の 1 つが zval の値を変更した場合にのみ zaval が共有されます。 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 is destroyed, because refcount=0 // $a -> zval_2(type=IS_LONG, value=43, refcount=1)
参照カウントには致命的な問題があります。参照カウントをチェックできません。そしてループ Reference (メモリ使用量) を解放します。この問題を解決するために、PHP ではリサイクル方式が採用されています。 zval のカウントが 1 つデクリメントされると、それはループの一部である可能性があり、zval は「ルート バッファ」に書き込まれます。バッファーがいっぱいになると、潜在的なサイクルがマークされ、リサイクルされます。
リサイクルをサポートする必要があるため、実際に使用される zval 構造体は次のとおりです。
typedef struct _zval_gc_info { zval z; union { gc_root_buffer *buffered; struct _zval_gc_info *next; } u;} zval_gc_info;
通常の zval 構造体は zval_gc_info 構造体に埋め込まれています。同時に 2 つのポインター パラメーターも追加されますが、これらは同じ共用体 u に属しているため、実際の使用に役立つのは 1 つのポインターのみです。バッファされたポインタは、zval の参照アドレスをルート バッファに格納するために使用されるため、サイクル リサイクルが実行される前に zval が破棄された場合、このフィールドは削除される可能性があります。 Next は、価値をリサイクルしたり破壊したりするときに使用されますが、ここでは詳しく説明しません。
変更の動機
ここで述べたのは 64 ビット システムについてです。まず、str と obj は同じサイズを占有するため、zvalue_value 共用体は 16 バイトのメモリを占有します。 zval 構造全体が占有するメモリは 24 バイト (メモリ アライメントを考慮)、zval_gc_info のサイズは 32 バイトです。要約すると、ヒープ上の zval に割り当てられたメモリ (スタックに対して) にはさらに 16 バイトが必要なので、各 zval にはさまざまな場所で合計 48 バイトが必要になります (上記の計算方法を理解するには、次の点に注意する必要があります) 64 ビット システムでは、各ポインターにも 8 バイトが必要です)。
現時点では、どの側面から見ても、zval の設計効率は非常に低いと考えられます。たとえば、整数を格納する場合、zval 自体は 8 バイトしか必要としません。追加の情報とメモリ アライメントを格納する必要性を考慮しても、追加の 8 バイトで十分です。
整数を格納する場合は 16 バイトが必要ですが、実際には参照カウントに 16 バイトが使用され、リサイクルに 16 バイトが使用されます。したがって、メモリの割り当てと zval の解放は非常にコストのかかる操作であり、最適化する必要があります。
次の観点から考えてみましょう: 整数は参照カウントを保存し、情報をリサイクルし、ヒープにメモリを個別に割り当てる必要があるのでしょうか?答えはもちろんそうではありません。これはまったく良い対処方法ではありません。
ここでは、PHP5 での zval の実装における主な問題の概要を示します。
PHP7 中的 zval
在 PHP7 中 zval 有了新的实现方式。最基础的变化就是 zval 需要的内存不再是单独从堆上分配,不再自己存储引用计数。复杂数据类型(比如字符串、数组和对象)的引用计数由其自身来存储。这种实现方式有以下好处:
我们看看现在 zval 结构体的定义(现在在 zend_types.h 文件中):
struct _zval_struct { zend_value value; /* value */ union { struct { ZEND_ENDIAN_LOHI_4( zend_uchar type, /* active type */ zend_uchar type_flags, zend_uchar const_flags, zend_uchar reserved) /* call info for EX(This) */ } 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 个字节,但是由于内存对齐,哪怕只增加一个字节,实际上也是占用 16 个字节(使用一个字节就意味着需要额外的 8 个字节)。但是显然我们并不需要 8 个字节来存储一个 type 字段,所以我们在 u1 的后面增加了了一个名为 u2 的联合体。默认情况下是用不到的,需要使用的时候可以用来存储 4 个字节的数据。这个联合体可以满足不同场景下的需求。
PHP7 中 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;
首先需要注意的是现在 value 联合体需要的内存是 8 个字节而不是 16。它只会直接存储整型(lval)或者浮点型(dval)数据,其他情况下都是指针(上面提到过,指针占用 8 个字节,最下面的结构体由两个 4 字节的无符号整型组成)。上面所有的指针类型(除了特殊标记的)都有一个同样的头(zend_refcounted)用来存储引用计数:
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;
现在,这个结构体肯定会包含一个存储引用计数的字段。除此之外还有 type、flags 和 gc_info。type 存储的和 zval 中的 type 相同的内容,这样 GC 在不存储 zval 的情况下单独使用引用计数。flags 在不同的数据类型中有不同的用途,这个放到下一部分讲。
gc_info 和 PHP5 中的 buffered 作用相同,不过不再是位于根缓冲区的指针,而是一个索引数字。因为以前根缓冲区的大小是固定的(10000 个元素),所以使用一个 16 位(2 字节)的数字代替 64 位(8 字节)的指针足够了。gc_info 中同样包含一个『颜色』位用于回收时标记结点。
zval 内存管理
上文提到过 zval 需要的内存不再单独从堆上分配。但是显然总要有地方来存储它,所以会存在哪里呢?实际上大多时候它还是位于堆中(所以前文中提到的地方重点不是堆,而是单独分配),只不过是嵌入到其他的数据结构中的,比如 hashtable 和 bucket 现在就会直接有一个 zval 字段而不是指针。所以函数表编译变量和对象属性在存储时会是一个 zval 数组并得到一整块内存而不是散落在各处的 zval 指针。之前的 zval * 现在都变成了 zval。
之前当 zval 在一个新的地方使用时会复制一份 zval * 并增加一次引用计数。现在就直接复制 zval 的值(忽略 u2),某些情况下可能会增加其结构指针指向的引用计数(如果在进行计数)。
那么 PHP 怎么知道 zval 是否正在计数呢?不是所有的数据类型都能知道,因为有些类型(比如字符串或数组)并不是总需要进行引用计数。所以 type_info 字段就是用来记录 zval 是否在进行计数的,这个字段的值有以下几种情况:
#define IS_TYPE_CONSTANT (1/* special */#define IS_TYPE_IMMUTABLE (1/* special */#define IS_TYPE_REFCOUNTED (1#define IS_TYPE_COLLECTABLE (1#define IS_TYPE_COPYABLE (1#define IS_TYPE_SYMBOLTABLE (1/* special */
注:在 7.0.0 的正式版本中,上面这一段宏定义的注释这几个宏是供 zval.u1.v.type_flags 使用的。这应该是注释的错误,因为这个上述字段是 zend_uchar 类型。
type_info 的三个主要的属性就是『可计数』(refcounted)、『可回收』(collectable)和『可复制』(copyable)。计数的问题上面已经提过了。『可回收』用于标记 zval 是否参与循环,不如字符串通常是可计数的,但是你却没办法给字符串制造一个循环引用的情况。
是否可复制用于表示在复制时是否需要在复制时制造(原文用的 “duplication” 来表述,用中文表达出来可能不是很好理解)一份一模一样的实体。”duplication” 属于深度复制,比如在复制数组时,不仅仅是简单增加数组的引用计数,而是制造一份全新值一样的数组。但是某些类型(比如对象和资源)即使 “duplication” 也只能是增加引用计数,这种就属于不可复制的类型。这也和对象和资源现有的语义匹配(现有,PHP7 也是这样,不单是 PHP5)。
下面的表格上标明了不同的类型会使用哪些标记(x 标记的都是有的特性)。『简单类型』(simple types)指的是整型或布尔类型这些不使用指针指向一个结构体的类型。下表中也有『不可变』(immutable)的标记,它用来标记不可变数组的,这个在下一部分再详述。
interned string(保留字符)在这之前没有提过,其实就是函数名、变量名等无需计数、不可重复的字符串。
| refcounted | collectable | copyable | immutable----------------+------------+-------------+----------+----------simple types | | | |string | x | | x |interned string | | | |array | x | x | x |immutable array | | | | xobject | x | x | |resource | x | | |reference | x | | |
要理解这一点,我们可以来看几个例子,这样可以更好的认识 zval 内存管理是怎么工作的。
下面是整数行为模式,在上文中 PHP5 的例子的基础上进行了一些简化 :
$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), zend_array_2 被销毁 // $b = zval_2(type=IS_ARRAY) -> zend_array_1(refcount=1, value=[])
这个过程其实挺简单的。现在整数不再是共享的,变量直接就会分离成两个单独的 zval,由于现在 zval 是内嵌的所以也不需要单独分配内存,所以这里的注释中使用 = 来表示的而不是指针符号 ->,unset 时变量会被标记为 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), zend_array_2 被销毁 // $b = zval_2(type=IS_ARRAY) -> zend_array_1(refcount=1, value=[])
这种情况下每个变量变量有一个单独的 zval,但是是指向同一个(有引用计数) zend_array 的结构体。修改其中一个数组的值时才会进行复制。这点和 PHP5 的情况类似。
类型(Types)
我们大概看一下 PHP7 支持哪些类型(zval 使用的类型标记):
/* 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 使用的类似,不过增加了几项:
实际上上面的列表中应该还存在两个 fake types,这里忽略了。
IS_LONG 类型表示的是一个 zend_long 的值,而不是原生的 C 语言的 long 类型。原因是 Windows 的 64 位系统(LLP64)上的 long 类型只有 32 位的位深度。所以 PHP5 在 Windows 上只能使用 32 位的数字。PHP7 允许你在 64 位的操作系统上使用 64 位的数字,即使是在 Windows 上面也可以。
zend_refcounted 的内容会在下一部分讲。下面看看 PHP 引用的实现。
引用
PHP7 使用了和 PHP5 中完全不同的方法来处理 PHP & 符号引用的问题(这个改动也是 PHP7 开发过程中大量 bug 的根源)。我们先从 PHP5 中 PHP 引用的实现方式说起。
通常情况下, 写时复制原则意味着当你修改一个 zval 之前需要对其进行分离来保证始终修改的只是某一个 PHP 变量的值。这就是传值调用的含义。
但是使用 PHP 引用时这条规则就不适用了。如果一个 PHP 变量是 PHP 引用,就意味着你想要在将多个 PHP 变量指向同一个值。PHP5 中的 is_ref 标记就是用来注明一个 PHP 变量是不是 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]) // 因为 is_ref 的值是 1, 所以 PHP 不会对 zval 进行分离
但是这个设计的一个很大的问题在于它无法在一个 PHP 引用变量和 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)); //
因为 count() 只接受传值调用,但是 $array 是一个 PHP 引用,所以 count() 在执行之前实际上会有一个对数组进行完整的复制的过程。如果 $array 不是引用,这种情况就不会发生了。
现在我们来看看 PHP7 中 PHP 引用的实现。因为 zval 不再单独分配内存,也就没办法再使用和 PHP5 中相同的实现了。所以增加了一个 IS_REFERENCE 类型,并且专门使用 zend_reference 来存储引用值:
struct _zend_reference { zend_refcounted gc; zval val;};
本质上 zend_reference 只是增加了引用计数的 zval。所有引用变量都会存储一个 zval 指针并且被标记为 IS_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(因为有两个变量在使用这个 PHP 引用)。但是值本身的引用计数是 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, 即使有的是 PHP 引用有的不是 $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 最大的不同就是所有的变量都可以共享同一个数组,即使有的是 PHP 引用有的不是。只有当其中某一部分被修改的时候才会对数组进行分离。这也意味着使用 count() 时即使给其传递一个很大的引用数组也是安全的,不会再进行复制。不过引用仍然会比普通的数值慢,因为存在需要为 zend_reference 结构体分配内存(间接)并且引擎本身处理这一块儿也不快的的原因。
结语
总结一下 PHP7 中最重要的改变就是 zval 不再单独从堆上分配内存并且不自己存储引用计数。需要使用 zval 指针的复杂类型(比如字符串、数组和对象)会自己存储引用计数。这样就可以有更少的内存分配操作、更少的间接指针使用以及更少的内存分配。
文章的第二部分我们会讨论复杂类型的问题。