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
指向的是这个对象实例化对应的类本身, properties
和 properties_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
头,还可以看到 handle
和 handles
被移到 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_object
和 struct 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_UNDEF
zval,当hashtable没有关联的key的时候就会这样处理。所以如果 unset($a)
把 CV[0]
写出 UNDEF
的时候,就和符号表里面没有"a"这个键差不多。
Constants and ASTs
PHP5和PHP7中都有 IS_CONSTANT
和 IS_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的优化(尤其是 类型约定), 编译器的优化吧。