深刻理解PHP7之zval

PHP7已经发布, 如承诺, 我也要开始这个系列的文章的编写, 今天我想先和你们聊聊zval的变化. 在讲zval变化的以前咱们先来看看zval在PHP5下面是什么样子php

PHP5
zval回顾
在PHP5的时候, zval的定义以下:node

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中的数据类型, 因此它包含了一个type字段, 表示这个zval存储的是什么类型的值, 常见的可能选项是IS_NULL, IS_LONG, IS_STRING, IS_ARRAY, IS_OBJECT等等.算法

根据type字段的值不一样, 咱们就要用不一样的方式解读value的值, 这个value是个联合体, 好比对于type是IS_STRING, 那么咱们应该用value.str来解读zval.value字段, 而若是type是IS_LONG, 那么咱们就要用value.lval来解读.express

另外, 咱们知道PHP是用引用计数来作基本的垃圾回收的, 因此zval中有一个refcount__gc字段, 表示这个zval的引用数目, 但这里有一个要说明的, 在5.3之前, 这个字段的名字还叫作refcount, 5.3之后, 在引入新的垃圾回收算法来对付循环引用计数的时候, 做者加入了大量的宏来操做refcount, 为了能让错误更快的显现, 因此更名为refcount__gc, 迫使你们都使用宏来操做refcount.api

相似的, 还有is_ref, 这个值表示了PHP中的一个类型是不是引用, 这里咱们能够看到是否是引用是一个标志位.数组

这就是PHP5时代的zval, 在2013年咱们作PHP5的opcache JIT的时候, 由于JIT在实际项目中表现不佳, 咱们转而意识到这个结构体的不少问题. 而PHPNG项目就是从改写这个结构体而开始的.缓存

存在的问题
PHP5的zval定义是随着Zend Engine 2诞生的, 随着时间的推移, 当时设计的局限性也愈来愈明显:安全

首先这个结构体的大小是(在64位系统)24个字节, 咱们仔细看这个zval.value联合体, 其中zend_object_value是最大的长板, 它致使整个value须要16个字节, 这个应该是很容易能够优化掉的, 好比把它挪出来, 用个指针代替,由于毕竟IS_OBJECT也不是最最经常使用的类型.ide

第二, 这个结构体的每个字段都有明确的含义定义, 没有预留任何的自定义字段, 致使在PHP5时代作不少的优化的时候, 须要存储一些和zval相关的信息的时候, 不得不采用其余结构体映射, 或者外部包装后打补丁的方式来扩充zval, 好比5.3的时候新引入专门解决循环引用的GC, 它不得采用以下的比较hack的作法:函数

/* 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, 而后用magic number作标记写到后面去, 这样的作法安全性和稳定性在技术上都是没有保障的

第三, PHP的zval大部分都是按值传递, 写时拷贝的值, 可是有俩个例外, 就是对象和资源, 他们永远都是按引用传递, 这样就形成一个问题, 对象和资源在除了zval中的引用计数之外, 还须要一个全局的引用计数, 这样才能保证内存能够回收. 因此在PHP5的时代, 以对象为例, 它有俩套引用计数, 一个是zval中的, 另一个是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;

除了上面提到的两套引用之外, 若是咱们要获取一个object, 则咱们须要经过以下方式:

EG(objects_store).object_buckets[Z_OBJ_HANDLE_P(z)].bucket.obj

通过漫长的屡次内存读取, 才能获取到真正的objec对象自己. 效率可想而知.

这一切都是由于Zend引擎最初设计的时候, 并无考虑到后来的对象. 一个良好的设计, 一旦有了意外, 就会致使整个结构变得复杂, 维护性下降, 这是一个很好的例子.

第四, 咱们知道PHP中, 大量的计算都是面向字符串的, 然而由于引用计数是做用在zval的, 那么就会致使若是要拷贝一个字符串类型的zval, 咱们别无他法只能复制这个字符串. 当咱们把一个zval的字符串做为key添加到一个数组里的时候, 咱们别无他法只能复制这个字符串. 虽然在PHP5.4的时候, 咱们引入了INTERNED STRING, 可是仍是不能根本解决这个问题.

还好比, PHP中大量的结构体都是基于Hashtable实现的, 增删改查Hashtable的操做占据了大量的CPU时间, 而字符串要查找首先要求它的Hash值, 理论上咱们彻底能够把一个字符串的Hash值计算好之后, 就存下来, 避免再次计算等等

第五, 这个是关于引用的, 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必须得改改了, 对吧?

PHP7
如今的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上了.

图片描述

以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);
}

总结
(这块还没想好怎么说, 原本我是要引出Hashtable再也不存在zval**, 从而引出引用类型的存在的必要性, 可是若是不先讲Hashtable的结构, 这个引出貌似很突兀, 先这么着吧, 之后再来修改)

到如今咱们基本上把zval的变化概况介绍完毕, 抽象的来讲, 其实在PHP7中的zval, 已经变成了一个值指针, 它要么保存着原始值, 要么保存着指向一个保存原始值的指针. 也就是说如今的zval至关于PHP5的时候的zval . 只不过相比于zval , 直接存储zval, 咱们能够省掉一次指针解引用, 从而提升缓存友好性.

其实PHP7的性能, 咱们并无引入什么新的技术模式, 不过就是主要来自, 持续不懈的下降内存占用, 提升缓存友好性, 下降执行的指令数的这些原则而来的, 能够说PHP7的重构就是这三个原则.

相关文章
相关标签/搜索