2015年7月

PHP7变量内部实现(二)(译)

在上一篇文章中,讨论了PHP5和PHP7变量之间大的改变。回顾一下最大的变化就是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,值的类型,周期回收信息(gc_info),以及和类型相关的flag信息。

下面会详细的讨论一下各种复杂类型的实现,以及和PHP5中的区别。其中引用类型在上一篇中已经说过了。资源类型这里不会提到,因为我觉得没什么意思。

String

PHP7中的字符串有一个专门的zend_string类型,是这么定义的:

struct _zend_string {
    zend_refcounted   gc;   /* 这就是上面说的通用的 zend_refcount gc */
    zend_ulong        h;        /* hash value */
    size_t            len;
    char              val[1];
};

除了引用计数的头,字符串结构还包含了一个哈希缓存h,长度len,值val
哈希缓存的作用是避免每次在hashtable中查找key的时候重复计算hash,在首次使用的时候就会被初始化成一个非0的哈希值。

如果你对C不是很熟悉的话,就会对val感到奇怪:一个char怎么能存一个字符串呢?这其实是用了" struct hack " ,数组只用一个元素来声明,在创建zend_string的时候,我们给它分配一个大的string。我们还是可以通过这个val元素来访问这个string。

当然,这是一个技术上没有定义的行为,因为我们通过一个单字符来读、写一个array,然而,C编译器不知道你这样乱整。C99支持这样使用,其实就是"flexible array members(柔性数组)"。

这种新的字符串类型比原生的C字符串有一些有点:第一,本身包含了字符串的长度,意味着取长度的时候不需要遍历了;第二,字符串本身包含了引用计数,可以在多个地方使用同一个字符串,而不是使用zval,这对于共享hashtable的key很重要。

新的字符串和C字符串相比也有一个很大的缺点:从 zend_ztring 可以很简单的用 str->val取得对应的C string,然而不能直接把C的string变成 zend_string,实际上需要新申明一个zend_string,然后把C的string复制进去,这点在C代码中处理字符串的时候不方便。

下面是string中的flags (在gc里面flags字段):

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

Persistent strings 用的是系统的分配器而不是Zend内存管理器(ZMM),这样可以在不仅一次请求中有效,所以就可以透明的在zval中使用一个永久的字符串,然后这在PHP5中需要事先拷贝到ZMM中。

Interned strings 直到请求结束时才会被销毁,也不需要引用计数。他们也是不重复的,在创建一个新的 interned string的时候引擎会检查给的内容是不是已经存在了。PHP代码中所有的字符串(字符换、变量名、函数名等)一般用的是这个。

Permanent strings 是在请求开始时创建的interned string,但是请求结束时不会被销毁。

如果使用了 Opcache, interned string 就会存在共享内存里(SHM),在所有的PHP worker 进程间共享,这种情况下,Permanent strings 就没什么意思了,因为 Interned strings 会被销毁。

Array

因为上一篇文章已经说了数组了,所以这里不再展开讨论,但是因为最近的小改动影响了一些细节,但是大的概念还是一样的。

这里直说一个新概念:Immutable arrays(不可变数组),本质上和 interned string差不多,没有引用计数,在请求结束之前不会被销毁(或者更久)。

为了避免内存管理问题,不可变数组只在Opcache打开的时候使用,下面的代码可以看出会有什么差异:

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

有Opcache的情况下是 32M,没有的时候就会飙到390M,因为每个$array的元素都会拿到新的[foo]的拷贝。原因是VM为了避免SHM出错 ,而采用真的拷贝,而不是 refcount + 1。我希望将来可以在不用Opcache的时候解决这个灾难性的问题。

Objects in PHP 5

先看一下PHP5的对象是怎么工作的,找到里面低调的地方:zval 本身存着 zend_object_value,定义如下:

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

handle是对象的唯一id,用来查找这个对象的数据,handlers 是一个存了对象里各种方法的指针的虚函数表。常规的PHP代码中,对象的 handler 表都是一样的,但是 扩展里面创建的对象,可以通过自定义 handlers 来改变对象的行为。

对象句柄就是一个对象库(object_store)里面的索引,对象库就是对象组成的数组,像下面这样:

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;

这里的情况比较复杂。前3个成员是一些元信息(析构函数是否调用,bucket是否被使用,对象被递归调用了多少次),中间的联合体用来区分bucket当前是否正在使用还是在空闲列表中。对于使用者来说重要的是struct _store_object

第一个元素object是指向实际对象的指针,它不是直接内嵌在对象库桶里面的,因为对象没有一个固定的大小。这个指针下面紧跟着3个元素分别负责销毁、释放、克隆。注意,PHP里面对象的销毁和释放是不同的步骤,以前在某些情况下会跳过(不完全释放)。clone这个字段实际上从来没用过,因为这些字段都不是一般对象的一部分,(不管出于什么原因)他们都会被每个独立的对象拷贝,而不是共享。

接下来就是handlers指针,指向一个普通的对象,当在不知道zval的情况下销毁这个对象时,这个指针就会有用了(存对象的资源句柄)。

槽里面还包含了一个refcount,因为zval已经存了refcount了,这里还存一个就显得有点怪了。。。为什么需要这样呢?因为通常"拷贝" zval 的时候只是 refcount + 1,但是某些情况下,也会有真的拷贝,比如,申明一个全新的zval但是有相同zend_object_value,这种情况下就是两个独立的zval共享同一个对象库槽,所有对象库槽本身才会需要一个refcount。这种“双重引用计数”在PHP5的zval实现中就是硬伤。bufferd指针指向 GC root buffer,也是因为这个原因才重复的。

现在我们来看看 object 指向的实际 对象吧,用户用的对象通常是这样的:

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

zend_class_entry指向的是这个对象实例化对应的类本身,propertiesproperties_table 存的是对象的属性,动态属性(在运行的时候才有的,声明的时候没有)存在properties里。在类里面申明的属性有一个优化点:在编译期间,每个属性都被赋予了一个索引,索引和属性的值都存在properties_table里面。而属性名和索引的对应关系又存在类里面的一个hashtable里面。这样的哈希表内存开销的是避免单个对象,另外在运行时,属性的这个索引是动态缓存的。

guards这个哈希表是用来实现魔术方法的,比如__get,这里不讨论。

除了上面已经提到的“双重引用计数”,对象的实现也当占内存,仅有一个属性的类都会占用136bytes的内存。另外还有比如:从一个对象zval里面拿一个属性,先要取得对象库桶,然后再是 zend_object,然后再是properties_table,最后才是它指向的zval,这已经就有4层了(实际中,一般不会少于7层)。

Objects in PHP 7

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被取而代之的是一个直接指向对象或者对象库的指针。

除了通用的zend_refcounted头,还可以看到handlehandles被移到zend_object里面了,另外properties_table也使用了 struct hack,所以zend_object和属性表将会分配在一块内存里面。当然,属性表现在直接存了zvals,而不是以前那样存的是指针。

guards现在没有直接体现在zend_object结构里面了,如果用到的话,它将会存在properties_table的第一个槽里面,当然,如果没有使用__get这样的魔术方法,guards将会被省略。

之前的dtor,free_storage,clone现在被移到了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
};

offset是和内部对象表示相关的,内部对象通常包含一个标准的zend_object,但通常也会添加一些附加的东西,在PHP5中是这么弄得:

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

就是说,可以简单的把zend_object变成自定义的struct custom_object*,这是在C上面进行结构的继承。然后再PHP7中这样做的话就会有问题,因为PHP7的zend_object用了 struct hack来存属性表,PHP会在zend_object的结尾存属性部分,这会导致覆盖掉这些额外的自定义信息,所以PHP7中应该写在前面:

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

这就意味着不能直接在zend_objectstruct custom_object*之间转换,因为offset不同,在编译的时候可以通过offsetof()宏来确定offset。

你也许会好奇为什么PHP7还是有一个handle,毕竟现在存了一个直接指向zend_object的指针,所以现在不需要用handle在对象库里面查找对象了。

然而handle还是需要的,因为对象库还是存在的,尽管已经精简了它。它现在只是一个简单的数组,存的是指向对象的指针。当创建一个对象的时候,就会有一个指针插入到这个对象库中,索引就是这个handle,当释放对象的时候,这个也会被移除。

为什么还需要对象库呢?原因就是在 请求结束 的时候,再运行用户空间的代码就不安全了,因为执行器已经部分关闭了。为了避免这种情况,PHP会请求结束的时候执行所有对象的析构函数并阻止他们之后再运行。所以才需要这么一个对象库列表。

另外,这个handle对调试代码有好处,因为他就是每个对象的唯一ID,所以就很方便查看两个对象是真的相同还是只是有相同的内容。HHVM尽管没有对象库的概念,但它还是存了对象的handle。

和PHP5相比,现在只有一个refcount了(zval本身没有了),内存占用变小了,只要40bytes存对象结构,16bytes存一个属性(包含它的zval)。间接指向也变少了,很多中间的结构都去掉了或者内嵌了。现在读一个属性只需要1步,而不是之前的4步了。

Indirect zvals

到此为此,常规的zval类型都说完了。还有两个在特定情况下才会出现的类型,都是PHP7新加的,其中一个就是 IS_INDIRECT.

间接zval意思就是zval的值是存在别的地方的,注意这和IS_REFERENCE这种直接指向另一个zval不同,zend_reference结构是直接嵌在zval里面的。

为了理解什么情况下这个是必须得,需要想一下PHP怎么实现一个变量的:

所有在编译时已知的变量都会被赋予一个索引,索引和值本身都会被 compiled variables(CV) table,PHP还允许你动态的引用变量,比如$$val。PHP将会为函数和脚本创建一个符号表,里面包含了所有的变量名和值得关系映射。

这就带来了一个问题:这两种访问格式怎么能同时支持呢?我们用CV表来访问常规的变量,用符号表来访问 $$ 变量。在PHP5中CV table用的是二级的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的桶,举例说明:

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中肯定不能用这样做了,因为当rehash hashtable的时候,指针就失效了。实际上PHP7用了相反的策略:对于存在CV table的变量,符号表里面就有一个INDIRECT入口,指向CV table的入口,CV table在符号表的有效期里是不会重新分配的,所以也就没有指针失效的问题。

所以如果在函数中使用 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_UNDEFzval,当hashtable没有关联的key的时候就会这样处理。所以如果unset($a) CV[0]写出UNDEF的时候,就和符号表里面没有"a"这个键差不多。

Constants and ASTs

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()运行的时候,常量才可用。

出于这个原因,参数和属性的默认值(静态属性),常量以及其他接受静态表达式的东西,不得不推迟表达式的计算,直到首次使用。

如果值是常量或者静态属性,这些用的最平凡的地方都用了延迟计算,这个常量zval就有IS_CONSTANT标志。如果是常量表达式的话就是IS_CONSTANT_AST标示,并且zval指向了一个抽象语法树(AST)。

关于变量的实现,就说这么多吧,两篇文章了。不久之后再讲一些关于 VM的优化(尤其是 类型约定), 编译器的优化吧。