PHP7变量的内部实现(一)

本文第一部分和第二均翻译自Nikita Popov(nikic,PHP
官方开发组成员,柏林科技大学的学生)
的博客。为了更符合汉语的阅读习惯,文中并不会逐字逐句的翻译。

PHP7变量的内部实现-part 1

本文翻译自Nikita的文章,水平有限,如有错误,欢迎指正查看原文

受篇幅限制,这篇文章将分为两个部分。本部分会讲解PHP5和PHP7在zval结构体的差异,同时也会讨论引用的实现。第二部分会深入探究一些数据类型如string和对象的实现。

要理解本文,你应该对 PHP5 中变量的实现有了一些了解,本文重点在于解释
PHP7 中 zval 的变化。

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

由于大量的细节描述,本文将会分成两个部分:第一部分主要描述 zval(zend
value) 的实现在 PHP5 和 PHP7
中有何不同以及引用的实现。第二部分将会分析单独类型(strings、objects)的细节。

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; // $c -> zval_1(type=IS_LONG, value=42, refcount=1)           // $a -> zval_2(type=IS_LONG, value=43, refcount=1)unset; // 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指针在收集器销毁值的时候会被用到,但是我不会深入讲解这一点。

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
后缀的字段。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
语言联合体的特征是一次只有一个成员是有效的并且分配的内存与需要内存最多的成员匹配(也要考虑内存对齐)。所有成员都存储在内存的同一个位置,根据需要存储不同的值。当你需要
lval 的时候,它存储的是有符号整形,需要 dval 时,会存储双精度浮点数。

需要指出的是是联合体中当前存储的数据类型会记录到 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

改进的动机

先讨论一下基于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变量存放)。

PHP5 中的引用计数

在PHP5中,zval 的内存是单独从堆(heap)中分配的(有少数例外情况),PHP
需要知道哪些 zval
是正在使用的,哪些是需要释放的。所以这就需要用到引用计数:zval 中
refcount__gc 的值用于保存 zval 本身被引用的次数,比如 $a = $b = 42
语句中,42 被两个变量引用,所以它的引用计数就是 2。如果引用计数变成
0,就意味着这个变量已经没有用了,内存也就可以释放了。

注意这里提及到的引用计数指的不是 PHP 代码中的引用(使用
&),而是变量的使用次数。后面两者需要同时出现时会使用『PHP
引用』和『引用』来区分两个概念,这里先忽略掉 PHP 的部分。

一个和引用计数紧密相关的概念是『写时复制』:对于多个引用来说,zaval
只有在没有变化的情况下才是共享的,一旦其中一个引用改变 zval
的值,就需要复制(”separated”)一份 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)

引用计数有个致命的问题:无法检查并释放循环引用(使用的内存)。为了解决这问题,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
在回收销毁值的时候使用,这里不会深入。

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        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字节。它仅仅会直接存储整数和双精度浮点数,对其它类型它都会存储对应指针。所有的指针类型(除了什么代码中标记为特殊的)都会引用计数并且有一个通用的头部,定义为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成员。不同的是它存储了在根缓冲区中的索引,来代替之前的指针。因为跟缓冲区尺寸固定,用16字节的数子而不是64位的指针就足够了。gc_info还含有该节点的“颜色”信息,这在垃圾回收中用来标记节点。

修改动机

下面说说关于内存使用上的情况,这里说的都是指在 64 位的系统上。首先,由于
strobj澳门新葡亰赌995577, 占用的大小一样, zvalue_value 这个联合体占用 16
个字节(bytes)的内存。整个 zval 结构体占用的内存是 24
个字节(考虑到内存对齐),zval_gc_info 的大小是 32
个字节。综上,在堆(相对于栈)分配给 zval 的内存需要额外的 16
个字节,所以每个 zval 在不同的地方一共需要用到 48
个字节(要理解上面的计算方式需要注意每个指针在 64 位的系统上也需要占用 8
个字节)。

在这点上不管从什么方面去考虑都可以认为 zval 的这种设计效率是很低的。比如
zval 在存储整型的时候本身只需要 8
个字节,即使考虑到需要存一些附加信息以及内存对齐,额外 8
个字节应该也是足够的。

在存储整型时本来确实需要 16 个字节,但是实际上还有 16
个字节用于引用计数、16 个字节用于循环回收。所以说 zval
的内存分配和释放都是消耗很大的操作,我们有必要对其进行优化。

从这个角度思考:一个整型数据真的需要存储引用计数、循环回收的信息并且单独在堆上分配内存吗?答案是当然不,这种处理方式一点都不好。

这里总结一下 PHP5 中 zval 实现方式存在的主要问题:

  • zval 总是单独从堆中分配内存;
  • zval
    总是存储引用计数和循环回收的信息,即使是整型这种可能并不需要此类信息的数据;
  • 在使用对象或者资源时,直接引用会导致两次计数(原因会在下一部分讲);
  • 某些间接访问需要一个更好的处理方式。比如现在访问存储在变量中的对象间接使用了四个指针(指针链的长度为四)。这个问题也放到下一部分讨论;
  • 直接计数也就意味着数值只能在 zval 之间共享。如果想在 zval 和
    hashtable key 之间共享一个字符串就不行(除非 hashtable key 也是
    zval)。

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 |            |             |          |     xobject          |      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 = 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 = 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一样,当发生修改时,数组需要被复制。

发表评论

电子邮件地址不会被公开。 必填项已用*标注

相关文章

网站地图xml地图