PHP7 がリリースされました。約束どおり、私もこのシリーズの記事を書き始めます。主に、記事を通じて、PHP7 の大幅なパフォーマンス向上の裏で私たちが何を行ったかを皆さんに理解してもらいたいと思っています。今日は皆さんにお話したいと思いますzval の変更について話す前に、まず zval が PHP5 でどのようになるかを見てみましょう
zval review
PHP5 では、 zval は次のとおりです。
struct _zval_struct { union { long lval; double dval; struct { char *val; int len; } str; HashTable *ht; zend_object_value obj; zend_ast *ast; } value; zend_uint refcount__gc; zend_uchar type; zend_uchar is_ref__gc; };
PHP5 カーネルに精通している学生は、この構造に精通しているはずです。zval は PHP のすべてのデータ型を表すことができるため、zval に格納される型を示す type フィールドが含まれています。値、一般的な可能なオプションは IS_NULL、IS_LONG、IS_STRING、IS_ARRAY、IS_OBJECT などです。
type フィールドのさまざまな値に応じて、値の値をさまざまな方法で解釈する必要があります。この値は共用体です。たとえば、type が IS_STRING の場合は、value.str を使用して zval.value フィールドを解釈する必要があり、type が IS_LONG の場合は、value.lval を使用して解釈する必要があります。
さらに、PHP では参照カウントが基本的なガベージ コレクションに使用されることがわかっているため、zval には refcount__gc フィールドがあり、この zval への参照の数を示します。ただし、ここで注意すべき点が 1 つあります。5.3 より前では、このフィールドの名前は依然として refcount と呼ばれていました。5.3 以降、循環参照カウントを処理するために新しいガベージ コレクション アルゴリズムが使用されたとき、作成者は refcount を操作するための多数のマクロを追加しました。エラーの表示を高速化するために、
同様に、is_ref があります。この値は、PHP の型が参照であるかどうかを示します。ここでは、参照がフラグであるかどうかを確認できます。
これは、2013 年の PHP5 時代の zval です 2016 年に PHP5 の opcache JIT に取り組んでいたとき、実際のプロジェクトでは JIT のパフォーマンスが低かったため、この構造には多くの問題があることに気づきました。
既存の問題
PHP5 の zval 定義は Zend Engine 2 で誕生しました。時間が経つにつれて、当時の設計の制限はさらに大きくなっていきました。そしてより明白です:
まず第一に、この構造体のサイズは 24 バイトです (64 ビット システム上)。この zval.value 共用体を詳しく見てみましょう。その中で、zend_object_value が最大です。長いボードなので、値全体に 16 バイトが必要になります。これは、結局のところ、IS_OBJECT が最も一般的に使用されるタイプではないため、移動してポインタに置き換えるなど、簡単に最適化できます。 ##Second, each field of this構造体には明確な定義があり、カスタムフィールドは予約されません。その結果、PHP5時代に多くの最適化を行う際に、zvalに関連する何らかの情報を保存する必要がある場合、他の構造マッピング、または外部のパッケージ化とパッチ適用を使用する必要があります。zval を拡張するには、たとえば、5.3 では、特に循環参照を解決する新しい GC が導入されました。次のハッキングなアプローチを採用してはなりません:
/* The following macroses override macroses from zend_alloc.h */ #undef ALLOC_ZVAL #define ALLOC_ZVAL(z) \ do { \ (z) = (zval*)emalloc(sizeof(zval_gc_info)); \ GC_ZVAL_INIT(z); \ } while (0) 它用zval_gc_info劫持了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 を展開するため、実際には PHP5 の時代に入っています。zval を適用すると、実際には 32 バイトが割り当てられますが、実際には GC は IS_ARRAY と IS_OBJECT タイプのみを考慮する必要があるため、大量のメモリの浪費につながります。
以前に作成した Taint 拡張機能と同様に、いくつかの文字列にいくつかのタグを保存する必要があります。zval には使用できる場所がないため、特別な手段を使用する必要があります:
Z_STRVAL_PP(ppzval) = erealloc(Z_STRVAL_PP(ppzval), Z_STRLEN_PP(ppzval) + 1 + PHP_TAINT_MAGIC_LENGTH); PHP_TAINT_MARK(*ppzval, PHP_TAINT_MAGIC_POSSIBLE);
は次のとおりです。文字列の長さを int で拡張し、マジック ナンバーをマークとして使用して書きます。 戻ると、このアプローチのセキュリティと安定性は技術的に保証されていません。
第三に、PHP の zval のほとんどは渡されます。値によって渡され、書き込み時に値がコピーされますが、例外はオブジェクトとリソースです。それらは常に参照によって渡されるため、問題が発生します。zval の参照カウントに加えて、オブジェクトとリソースにはグローバル参照も必要ですしたがって、PHP5 の時代では、オブジェクトを例に取ると、2 つの参照カウントのセットがあり、1 つは zval にあり、もう 1 つは obj 自体のカウントです。
typedef struct _zend_object_store_bucket { zend_bool destructor_called; zend_bool valid; 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;上記の 2 つの参照セットに加えて、オブジェクトを取得するには、次のメソッドを使用する必要があります。
EG(objects_store).object_buckets[Z_OBJ_HANDLE_P(z)].bucket.obj複数のメモリ読み取りの長いプロセスの後、次のメソッドを使用する必要があります。実際のオブジェクト オブジェクトそのものです。その効率は想像できます。これすべては、Zend エンジンが最初に設計されたときに、後続のオブジェクトが考慮されていなかったためです。良い設計は、一度何か予期せぬことが起こると、全体の構造が複雑になり、保守性が低下します。これは良い例です。4 番目に、PHP では、多くの計算が文字列指向であることがわかります。ただし、参照カウントが適用されるため、 zval, 文字列型の zval をコピーしたい場合は、他に選択肢がありません。彼はこの文字列のみをコピーできます。zval 文字列をキーとして配列に追加する場合、次の方法しかありません。この文字列をコピーします。PHP5.4 では INTERNED STRING を導入しましたが、それでもこの問題を根本的に解決することはできません。たとえば、PHP の多くの構造体は Hashtable に基づいて実装されています。 、ハッシュテーブルの削除、変更、チェックには多くの CPU 時間がかかりますが、文字列は最初に検索する必要があります。理論的には、文字列のハッシュ値を計算してから、計算を避けるために保存することができます。またまたなど
第五, 这个是关于引用的, PHP5的时代, 我们采用写时分离, 但是结合到引用这里就有了一个经典的性能问题:
<?php function dummy($array) {} $array = range(1, 100000); $b = &$array; dummy($array); ?>
当我们调用dummy的时候, 本来只是简单的一个传值就行的地方, 但是因为$array曾经引用赋值给了$b, 所以导致$array变成了一个引用, 于是此处就会发生分离, 导致数组复制, 从而极大的拖慢性能, 这里有一个简单的测试:
<?php $array = range(1, 100000); function dummy($array) {} $i = 0; $start = microtime(true); while($i++ < 100) { dummy($array); } printf("Used %sS\n", microtime(true) - $start); $b = &$array; //注意这里, 假设我不小心把这个Array引用给了一个变量 $i = 0; $start = microtime(true); while($i++ < 100) { dummy($array); } printf("Used %ss\n", microtime(true) - $start); ?>
我们在5.6下运行这个例子, 得到如下结果:
$ php-5.6/sapi/cli/php /tmp/1.php Used 0.00045204162597656s Used 4.2051479816437s
相差1万倍之多. 这就造成, 如果在一大段代码中, 我不小心把一个变量变成了引用(比如foreach as &$v), 那么就有可能触发到这个问题, 造成严重的性能问题, 然而却又很难排查.
第六, 也是最重要的一个, 为什么说它重要呢? 因为这点促成了很大的性能提升, 我们习惯了在PHP5的时代调用MAKE_STD_ZVAL在堆内存上分配一个zval, 然后对他进行操作, 最后呢通过RETURN_ZVAL把这个zval的值”copy”给return_value, 然后又销毁了这个zval, 比如pathinfo这个函数:
PHP_FUNCTION(pathinfo) { ..... MAKE_STD_ZVAL(tmp); array_init(tmp); ..... if (opt == PHP_PATHINFO_ALL) { RETURN_ZVAL(tmp, 0, 1); } else { ..... }
这个tmp变量, 完全是一个临时变量的作用, 我们又何必在堆内存分配它呢? MAKE_STD_ZVAL/ALLOC_ZVAL在PHP5的时候, 到处都有, 是一个非常常见的用法, 如果我们能把这个变量用栈分配, 那无论是内存分配, 还是缓存友好, 都是非常有利的
还有很多, 我就不一一详细列举了, 但是我相信你们也有了和我们当时一样的想法, zval必须得改改了, 对吧?
现在的zval
到了PHP7中, zval变成了如下的结构, 要说明的是, 这个是现在的结构, 已经和PHPNG时候有了一些不同了, 因为我们新增加了一些解释 (联合体的字段), 但是总体大小, 结构, 是和PHPNG的时候一致的:
struct _zval_struct { union { 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; } 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; };
虽然看起来变得好大, 但其实你仔细看, 全部都是联合体, 这个新的zval在64位环境下,现在只需要16个字节(2个指针size), 它主要分为俩个部分, value和扩充字段, 而扩充字段又分为u1和u2俩个部分, 其中u1是type info, u2是各种辅助字段.
其中value部分, 是一个size_t大小(一个指针大小), 可以保存一个指针, 或者一个long, 或者一个double.
而type info部分则保存了这个zval的类型. 扩充辅助字段则会在多个其他地方使用, 比如next, 就用在取代Hashtable中原来的拉链指针, 这部分会在以后介绍HashTable的时候再来详解.
类型
PHP7中的zval的类型做了比较大的调整, 总体来说有如下17种类型:
/* 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 /* fake types */ #define _IS_BOOL 13 #define IS_CALLABLE 14 /* internal types */ #define IS_INDIRECT 15 #define IS_PTR 17
其中PHP5的时候的IS_BOOL类型, 现在拆分成了IS_FALSE和IS_TRUE俩种类型. 而原来的引用是一个标志位, 现在的引用是一种新的类型.
对于IS_INDIRECT和IS_PTR来说, 这俩个类型是用在内部的保留类型, 用户不会感知到, 这部分会在后续介绍HashTable的时候也一并介绍.
从PHP7开始, 对于在zval的value字段中能保存下的值, 就不再对他们进行引用计数了, 而是在拷贝的时候直接赋值, 这样就省掉了大量的引用计数相关的操作, 这部分类型有:
IS_LONG IS_DOUBLE
当然对于那种根本没有值, 只有类型的类型, 也不需要引用计数了:
IS_NULL IS_FALSE IS_TRUE
而对于复杂类型, 一个size_t保存不下的, 那么我们就用value来保存一个指针, 这个指针指向这个具体的值, 引用计数也随之作用于这个值上, 而不在是作用于zval上了.
PHP7 zval示意图
以IS_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 reserve) } v; uint32_t flags; } u; uint32_t nTableMask; Bucket *arData; uint32_t nNumUsed; uint32_t nNumOfElements; uint32_t nTableSize; uint32_t nInternalPointer; zend_long nNextFreeElement; dtor_func_t pDestructor; };
zval.value.arr将指向上面的这样的一个结构体, 由它实际保存一个数组, 引用计数部分保存在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;
所有的复杂类型的定义, 开始的时候都是zend_refcounted_h结构, 这个结构里除了引用计数以外, 还有GC相关的结构. 从而在做GC回收的时候, GC不需要关心具体类型是什么, 所有的它都可以当做zend_refcounted*结构来处理.
另外有一个需要说明的就是大家可能会好奇的ZEND_ENDIAN_LOHI_4宏, 这个宏的作用是简化赋值, 它会保证在大端或者小端的机器上, 它定义的字段都按照一样顺序排列存储, 从而我们在赋值的时候, 不需要对它的字段分别赋值, 而是可以统一赋值, 比如对于上面的array结构为例, 就可以通过:
arr1.u.flags = arr2.u.flags;
一次完成相当于如下的赋值序列:
arr1.u.v.flags = arr2.u.v.flags; arr1.u.v.nApplyCount = arr2.u.v.nApplyCount; arr1.u.v.nIteratorsCount = arr2.u.v.nIteratorsCount; arr1.u.v.reserve = arr2.u.v.reserve;
还有一个大家可能会问到的问题是, 为什么不把type类型放到zval类型的前面, 因为我们知道当我们去用一个zval的时候, 首先第一点肯定是先去获取它的类型. 这里的一个原因是, 一个是俩者差别不大, 另外就是考虑到如果以后JIT的话, zval的类型如果能够通过类型推导获得, 就根本没有必要去读取它的type值了.
标志位
除了数据类型以外, 以前的经验也告诉我们, 一个数据除了它的类型以外, 还应该有很多其他的属性, 比如对于INTERNED STRING,它是一种在整个PHP请求期都存在的字符串(比如你写在代码中的字面量), 它不会被引用计数回收. 在5.4的版本中我们是通过预先申请一块内存, 然后再这个内存中分配字符串, 最后用指针地址来比较, 如果一个字符串是属于INTERNED STRING的内存范围内, 就认为它是INTERNED STRING. 这样做的缺点显而易见, 就是当内存不够的时候, 我们就没有办法分配INTERNED STRING了, 另外也非常丑陋, 所以如果一个字符串能有一些属性定义则这个实现就可以变得很优雅.
还有, 比如现在我们对于IS_LONG, IS_TRUE等类型不再进行引用计数了, 那么当我们拿到一个zval的时候如何判断它需要不需要引用计数呢? 想当然的我们可能会说用:
if (Z_TYPE_P(zv) >= IS_STRING) { //需要引用计数 }
但是你忘了, 还有INTERNED STRING的存在啊, 所以你也许要这么写了:
if (Z_TYPE_P(zv) >= IS_STRING && !IS_INTERNED(Z_STR_P(zv))) { //需要引用计数 }
是不是已经让你感觉到有点不对劲了? 嗯,别急, 还有呢, 我们还在5.6的时候引入了常量数组, 这个数组呢会存储在Opcache的共享内存中, 它也不需要引用计数:
if (Z_TYPE_P(zv) >= IS_STRING && !IS_INTERNED(Z_STR_P(zv)) && (Z_TYPE_P(zv) != IS_ARRAY || !Z_IS_IMMUTABLE(Z_ARRVAL(zv)))) { //需要引用计数 }
你是不是也觉得这简直太丑陋了, 简直不能忍受这样墨迹的代码, 对吧?
是的,我们早想到了,回头看之前的zval定义, 注意到type_flags了么? 我们引入了一个标志位, 叫做IS_TYPE_REFCOUNTED, 它会保存在zval.u1.v.type_flags中, 我们对于需要引用计数的类型就赋予这个标志, 所以上面的判断就可以变得很优雅:
if (!(Z_TYPE_FLAGS(zv) & IS_TYPE_REFCOUNTED)) { }
而对于INTERNED STRING来说, 这个IS_STR_INTERNED标志位应该是作用于字符串本身而不是zval的.
那么类似这样的标志位一共有多少呢?作用于zval的有:
IS_TYPE_CONSTANT //是常量类型 IS_TYPE_IMMUTABLE //不可变的类型, 比如存在共享内存的数组 IS_TYPE_REFCOUNTED //需要引用计数的类型 IS_TYPE_COLLECTABLE //可能包含循环引用的类型(IS_ARRAY, IS_OBJECT) IS_TYPE_COPYABLE //可被复制的类型, 还记得我之前讲的对象和资源的例外么? 对象和资源就不是 IS_TYPE_SYMBOLTABLE //zval保存的是全局符号表, 这个在我之前做了一个调整以后没用了, 但还保留着兼容, //下个版本会去掉
作用于字符串的有:
IS_STR_PERSISTENT //是malloc分配内存的字符串 IS_STR_INTERNED //INTERNED STRING IS_STR_PERMANENT //不可变的字符串, 用作哨兵作用 IS_STR_CONSTANT //代表常量的字符串 IS_STR_CONSTANT_UNQUALIFIED //带有可能命名空间的常量字符串
作用于数组的有:
#define IS_ARRAY_IMMUTABLE //同IS_TYPE_IMMUTABLE
作用于对象的有:
IS_OBJ_APPLY_COUNT //递归保护 IS_OBJ_DESTRUCTOR_CALLED //析构函数已经调用 IS_OBJ_FREE_CALLED //清理函数已经调用 IS_OBJ_USE_GUARDS //魔术方法递归保护 IS_OBJ_HAS_GUARDS //是否有魔术方法递归保护标志
有了这些预留的标志位, 我们就会很方便的做一些以前不好做的事情, 就比如我自己的Taint扩展, 现在把一个字符串标记为污染的字符串就会变得无比简单:
/* it's important that make sure * this value is not used by Zend or * any other extension agianst string */ #define IS_STR_TAINT_POSSIBLE (1<<7) #define TAINT_MARK(str) (GC_FLAGS((str)) |= IS_STR_TAINT_POSSIBLE)
这个标记就会一直随着这个字符串的生存而存在的, 省掉了我之前的很多tricky的做法.
zval预先分配
前面我们说过, PHP5的zval分配采用的是堆上分配内存, 也就是在PHP预案代码中随处可见的MAKE_STD_ZVAL和ALLOC_ZVAL宏. 我们也知道了本来一个zval只需要24个字节, 但是算上gc_info, 其实分配了32个字节, 再加上PHP自己的内存管理在分配内存的时候都会在内存前面保留一部分信息:
typedef struct _zend_mm_block { zend_mm_block_info info; #if ZEND_DEBUG unsigned int magic; # ifdef ZTS THREAD_T thread_id; # endif zend_mm_debug_info debug; #elif ZEND_MM_HEAP_PROTECTION zend_mm_debug_info debug; #endif } zend_mm_block;
从而导致实际上我们只需要24字节的内存, 但最后竟然分配48个字节之多.
然而大部分的zval, 尤其是扩展函数内的zval, 我们想想它接受的参数来自外部的zval, 它把返回值返回给return_value, 这个也是来自外部的zval, 而中间变量的zval完全可以采用栈上分配. 也就是说大部分的内部函数都不需要在堆上分配内存, 它需要的zval都可以来自外部.
于是当时我们做了一个大胆的想法, 所有的zval都不需要单独申请.
而这个也很容易证明, PHP脚本中使用的zval, 要么存在于符号表, 要么就以临时变量(IS_TMP_VAR)或者编译变量(IS_CV)的形式存在. 前者存在于一个Hashtable中, 而在PHP7中Hashtable默认保存的就是zval, 这部分的zval完全可以在Hashtable分配的时候一次性分配出来, 后面的存在于execute_data之后, 数量也在编译时刻确定好了, 也可以随着execute_data一次性分配, 所以我们确实不再需要单独在堆上申请zval了.
所以, 在PHP7开始, 我们移除了MAKE_STD_ZVAL/ALLOC_ZVAL宏, 不再支持存堆内存上申请zval. 函数内部使用的zval要么来自外面输入, 要么使用在栈上分配的临时zval.
在后来的实践中, 总结出来的可能对于开发者来说最大的变化就是, 之前的一些内部函数, 通过一些操作获得一些信息, 然后分配一个zval, 返回给调用者的情况:
static zval * php_internal_function() { ..... str = external_function(); MAKE_STD_ZVAL(zv); ZVAL_STRING(zv, str, 0); return zv; } PHP_FUNCTION(test) { RETURN_ZVAL(php_internal_function(), 1, 1); }
要么修改为, 这个zval由调用者传递:
static void php_internal_function(zval *zv) { ..... str = external_function(); ZVAL_STRING(zv, str); efree(str); } PHP_FUNCTION(test) { php_internal_function(return_value); }
要么修改为, 这个函数返回原始素材:
static char * php_internal_function() { ..... str = external_function(); return str; } PHP_FUNCTION(test) { str = php_internal_function(); RETURN_STRING(str); efree(str); }
总结
(まだどう言えばいいのかわかりません。もともと、zval** が Hashtable に存在しないという事実を紹介して、参照型の存在の必要性を紹介したかったのです。しかし、そうしないとまず Hashtable の構造について話さないでください。この導入は非常に唐突に思えます。今はこのままにしておき、後で修正しましょう)
これで、基本的に zval の変更を導入しました。抽象的な言葉で言うと、実際、PHP7 の zval は値ポインタになりました。元の値が保存されるか、元の値を指すポインタが保存されます。つまり、現在の zval は PHP5 の zval * と同等です。ただし、zval と比較すると*、zval は直接保存され、ポインターを保存できます。逆参照により、キャッシュの使いやすさが向上します。
実際には、PHP7 のパフォーマンスのための新しい技術モデルは導入していませんが、主に継続的に提供されています。メモリ使用量の削減、キャッシュフレンドリー性の向上、実行時間の短縮、これらの命令数の原則から来ており、PHP7 の再構築はこれら 3 つの原則に基づいていると言えます。
以上がPHP7 カーネルの zval についての深い理解の詳細内容です。詳細については、PHP 中国語 Web サイトの他の関連記事を参照してください。