【译】PHP的变量实现(给PHP开发者的PHP源码-第三部分)

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

文章来自:

原文:

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

在”给PHP开发者的PHP源码”系列的第三篇文章,我们打算扩展上一篇文章来帮助理解PHP内部是怎么工作的。在第一篇文章,我们介绍了如何查看PHP的源码,它的代码结构是怎样的以及一些介绍给PHP开发者的C指针基础。第二篇文章介绍了函数。这一次,我们打算深入PHP最有用的结构之一:变量。

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

进入ZVAL

在PHP的核心代码中,变量被称为ZVAL。这个结构之所以那么重要是有原因的,不仅仅是因为PHP使用弱类型而C使用强类型。那么ZVAL是怎么解决这个问题的呢?要回答这个问题,我们需要认真的查看ZVAL类型的定义。要查看这个定义,让我们尝试在lxr页面的定义搜索框里搜索zval。乍一眼看去,我们似乎找不到任何有用的东西。但是有一行typedef在zend.h文件(typedef在C里面是一种定义新的数据类型的方式)。这个也许就是我们要找的东西,再继续查看。原来,这看起来是不相干的。这里并没有任何有用的东西。但为了确认一些,我们来点击_zval_struct这一行。

1 struct _zval_struct {
2 /* Variable information */
3 zvalue_value value; /* value */
4 zend_uint refcount__gc;
5 zend_uchar type; /* active type */
6 zend_uchar is_ref__gc;
7 };

然后我们就得到PHP的基础,zval。看起来很简单,对吗?是的,没错,但这里还有一些很有意义的神奇的东西。注意,这是一个结构或结构体。基本上,这可以看作PHP里面的类,这些类只有公共的属性。这里,我们有四个属性:value,refcount__gc,type以及is_ref__gc。让我们来一一查看这些属性(省略它们的顺序)。

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

Value

我们第一个谈论的元素是value变量,它的类型是zvalue_value。我不认识你,但我也从来没有听说过zvalue_value。那么让我们尝试弄懂它是什么。跟网站的其他部分一样,你可以点击某个类型查看它的定义。一旦你点击了,你会看到它的定义跟下面的是一样的:

typedef union _zvalue_value {
    long lval; /* long value */
    double dval; /* double value */
    struct {
        char *val;
        int len;
    } str;
    HashTable *ht; /* hash table value */
    zend_object_value obj;
} zvalue_value;

现在,这里有一些黑科技。看到那个union的定义吗?那意味着这不是真正的结构体,而是一个单独的类型。但是有多个类型的变量在里面。如果这里面有多种类型的话,那它怎么能作为单一的类型呢?我很高兴你问了这个问题。要理解这个问题,我们需要先回想我们在第一篇文章谈论的C语言中的类型。

在C里面,变量只是一行内存地址的标签。也可以说类型只是标识哪一块内存将被使用的方式。在C里面没有使用任何东西将4个字节的字符串和整型值分隔开。它们都只是一整块的内存。编译器会尝试通过”标识”内存段作为变量来解析它,然后将这些变量转换为特定的类型,但这并不是总是成功(顺便说一句,当一个变量“重写”它得到的内存段,那将会产生段错误)。

那么,据我们所知,union是单独的类型,它根据怎么被访问而使用不同的方式解释。这可以让我们定义一个值来支持多种类型。有一点要注意的是,所有类型的数据都必须使用同一块内存来存储。这个例子,在64位的编译器,long和double都会占用64个位来保存。字符串结构体会占用96位(64位存储字符指针,32位保存整型长度)。hash_table会占用64位,还有zend_object_value会占用96位(32位用来存储元素,剩下的64位来存储指针)。而整一个union会占用最大元素的内存大小,因此在这里就是96位。

现在,如果再看清楚这个联合体(union),我们可以看到只有5种PHP数据类型在这里(long
== int,double == float,str == string,hashtable ==
array,zend_object_value ==
object)。那么剩下的数据类型去了哪里呢?原来,这个结构体已经足够来存储剩余的数据类型。BOOL使用long(int)来存储,NULL不占用数据段,RESOURCE也使用long来存储。

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
在回收销毁值的时候使用,这里不会深入。

TYPE

因为这个value联合体并没有控制它是怎么被访问的,我们需要其他方式来记录变量的类型。这里,我们可以通过数据类型来得出如何访问value的信息。它使用type这个字节来处理这个问题(zend_uchar是一个无符号的字符,或者内存中的一个字节)。它从zend类型常量保留这些信息。这真的是一种魔法,是需要使用zval.type = IS_LONG来定义整型数据。因此这个字段和value字段就足够让我们知道PHP变量的类型和值。

修改动机

下面说说关于内存使用上的情况,这里说的都是指在 64 位的系统上。首先,由于
strobj 占用的大小一样, 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)。

IS_REF

这个字段标识变量是否为引用。那就是说,如果你执行了在变量里执行了$foo = &$bar。如果它是0,那么变量就不是一个引用,如果它是1,那么变量就是一个引用。它并没有做太多的事情。那么,在我们结束_zval_struct之前,再看一看它的第四个成员。

PHP7 中的 zval

在 PHP7 中 zval 有了新的实现方式。最基础的变化就是 zval
需要的内存不再是单独从堆上分配,不再自己存储引用计数。复杂数据类型(比如字符串、数组和对象)的引用计数由其自身来存储。这种实现方式有以下好处:

  • 简单数据类型不需要单独分配内存,也不需要计数;
  • 不会再有两次计数的情况。在对象中,只有对象自身存储的计数是有效的;
  • 由于现在计数由数值自身存储,所以也就可以和非 zval
    结构的数据共享,比如 zval 和 hashtable key 之间;
  • 间接访问需要的指针数减少了。

我们看看现在 zval 结构体的定义(现在在 zend_types.h 文件中):

struct _zval_struct {
    zend_value        value;            /* 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;
};

结构体的第一个元素没太大变化,仍然是一个 value
联合体。第二个成员是由一个表示类型信息的整型和一个包含四个字符变量的结构体组成的联合体(可以忽略
ZEND_ENDIAN_LOHI_4
宏,它只是用来解决跨平台大小端问题的)。这个子结构中比较重要的部分是
type(和以前类似)和 type_flags,这个接下来会解释。

上面这个地方也有一点小问题:value 本来应该占 8
个字节,但是由于内存对齐,哪怕只增加一个字节,实际上也是占用 16
个字节(使用一个字节就意味着需要额外的 8 个字节)。但是显然我们并不需要
8 个字节来存储一个 type 字段,所以我们在 u1 的后面增加了了一个名为
u2 的联合体。默认情况下是用不到的,需要使用的时候可以用来存储 4
个字节的数据。这个联合体可以满足不同场景下的需求。

PHP7 中 value 的结构定义如下:

typedef union _zend_value {
    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;
} zend_value;

首先需要注意的是现在 value 联合体需要的内存是 8 个字节而不是
16。它只会直接存储整型(lval)或者浮点型(dval)数据,其他情况下都是指针(上面提到过,指针占用
8 个字节,最下面的结构体由两个 4
字节的无符号整型组成)。上面所有的指针类型(除了特殊标记的)都有一个同样的头(zend_refcounted)用来存储引用计数:

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;

现在,这个结构体肯定会包含一个存储引用计数的字段。除此之外还有
typeflagsgc_infotype 存储的和 zval 中的 type
相同的内容,这样 GC 在不存储 zval 的情况下单独使用引用计数。flags
在不同的数据类型中有不同的用途,这个放到下一部分讲。

gc_info 和 PHP5 中的 buffered
作用相同,不过不再是位于根缓冲区的指针,而是一个索引数字。因为以前根缓冲区的大小是固定的(10000
个元素),所以使用一个 16 位(2 字节)的数字代替 64 位(8
字节)的指针足够了。gc_info
中同样包含一个『颜色』位用于回收时标记结点。

REFCOUNT

这个变量是指向PHP变量容器的指针的计数器。也就是说,如果refcount是1,那就表示有一个PHP变量使用这个容器。如果refcount是2,那就表示有两个PHP变量指向同一个变量容器。单独的refcount变量并没有太多有用的信息,但如果它与is_ref一起使用,就构成了垃圾回收器和写时复制的基础。它允许我们使用同一个zval容器来保存一个或多个PHP变量。refcount的语义解释超出这篇文章的范围,如果你想继续深入,我推荐你查看这篇文档。

这就是ZVAL的所有内容。

zval 内存管理

上文提到过 zval
需要的内存不再单独从堆上分配。但是显然总要有地方来存储它,所以会存在哪里呢?实际上大多时候它还是位于堆中(所以前文中提到的地方重点不是,而是单独分配),只不过是嵌入到其他的数据结构中的,比如
hashtable 和 bucket 现在就会直接有一个 zval
字段而不是指针。所以函数表编译变量和对象属性在存储时会是一个 zval
数组并得到一整块内存而不是散落在各处的 zval 指针。之前的 zval *
现在都变成了 zval

之前当 zval 在一个新的地方使用时会复制一份 zval *
并增加一次引用计数。现在就直接复制 zval 的值(忽略
u2),某些情况下可能会增加其结构指针指向的引用计数(如果在进行计数)。

那么 PHP 怎么知道 zval
是否正在计数呢?不是所有的数据类型都能知道,因为有些类型(比如字符串或数组)并不是总需要进行引用计数。所以
type_info 字段就是用来记录 zval
是否在进行计数的,这个字段的值有以下几种情况:

#define IS_TYPE_CONSTANT            (1/* special */
#define IS_TYPE_IMMUTABLE           (1/* special */
#define IS_TYPE_REFCOUNTED          (1
#define IS_TYPE_COLLECTABLE         (1
#define IS_TYPE_COPYABLE            (1
#define IS_TYPE_SYMBOLTABLE         (1/* special */

注:在 7.0.0 的正式版本中,上面这一段宏定义的注释这几个宏是供
zval.u1.v.type_flags 使用的。这应该是注释的错误,因为这个上述字段是
zend_uchar 类型。

type_info
的三个主要的属性就是『可计数』(refcounted)、『可回收』(collectable)和『可复制』(copyable)。计数的问题上面已经提过了。『可回收』用于标记
zval
是否参与循环,不如字符串通常是可计数的,但是你却没办法给字符串制造一个循环引用的情况。

是否可复制用于表示在复制时是否需要在复制时制造(原文用的 “duplication”
来表述,用中文表达出来可能不是很好理解)一份一模一样的实体。”duplication”
属于深度复制,比如在复制数组时,不仅仅是简单增加数组的引用计数,而是制造一份全新值一样的数组。但是某些类型(比如对象和资源)即使
“duplication”
也只能是增加引用计数,这种就属于不可复制的类型。这也和对象和资源现有的语义匹配(现有,PHP7
也是这样,不单是 PHP5)。

下面的表格上标明了不同的类型会使用哪些标记(x
标记的都是有的特性)。『简单类型』(simple
types)指的是整型或布尔类型这些不使用指针指向一个结构体的类型。下表中也有『不可变』(immutable)的标记,它用来标记不可变数组的,这个在下一部分再详述。

interned
string(保留字符)在这之前没有提过,其实就是函数名、变量名等无需计数、不可重复的字符串。

                | refcounted | collectable | copyable | immutable
----------------+------------+-------------+----------+----------
simple types    |            |             |          |
string          |      x     |             |     x    |
interned string |            |             |          |
array           |      x     |      x      |     x    |
immutable array |            |             |          |     x
object          |      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); // $a = zval_1(type=IS_UNDEF)
           // $b = zval_2(type=IS_LONG, value=42)

这个过程其实挺简单的。现在整数不再是共享的,变量直接就会分离成两个单独的
zval,由于现在 zval
是内嵌的所以也不需要单独分配内存,所以这里的注释中使用 =
来表示的而不是指针符号 ->,unset 时变量会被标记为
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); // $a = zval_1(type=IS_UNDEF),   zend_array_2 被销毁
           // $b = zval_2(type=IS_ARRAY) -> zend_array_1(refcount=1, value=[])

这种情况下每个变量变量有一个单独的 zval,但是是指向同一个(有引用计数)
zend_array 的结构体。修改其中一个数组的值时才会进行复制。这点和 PHP5
的情况类似。

它是怎么工作的?

在PHP内部,zval使用跟其他C变量一样,作为内存段或者一个指向内存段的指针(或者指向指针的指针,等等),传递到函数。一旦我们有了变量,我们就想访问它里面的数据。那我们要怎么做到呢?我们使用定义在zend_operators.h文件里面的宏来跟zval一起使用,使得访问数据更简单。有一点很重要的是,每一个宏都有多个拷贝。不同的是它们的前缀。例如,要得出zval的类型,有Z_TYPE(zval)宏,这个宏返回一个整型数据来表示zval参数。但这里还有一个Z_TYPE(zval_p)宏,它跟Z_TYPE(zval)做的事情是一样的,但它返回的是指向zval的指针。事实上,除了参数的属性不一样之外,这两个函数是一样的,实际上,我们可以使用Z_TYPE(*zval_p),但_P和_PP让事情更简单。

我们可以使用VAL这一类宏来获取zval的值。可以调用Z_LVAL(zval)来得到整型值(比如整型数据和资源数据)。调用Z_DVAL(zval)来得到浮点值。还有很多其他的,到这里到此为止。要注意的关键是,为了在C里面获取zval的值,你需要使用宏(或应该)。因此,当我们看见有函数使用它们时,我们就知道它是从zval里面提取它的值。

类型(Types)

我们大概看一下 PHP7 支持哪些类型(zval 使用的类型标记):

/* 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

/* internal types */
#define IS_INDIRECT                 15
#define IS_PTR                      17

这个列表和 PHP5 使用的类似,不过增加了几项:

  • IS_UNDEF 用来标记之前为 NULL 的 zval 指针(和 IS_NULL
    并不冲突)。比如在上面的例子中使用 unset 注销变量;
  • IS_BOOL 现在分割成了 IS_FALSEIS_TRUE
    两项。现在布尔类型的标记是直接记录到 type
    中,这么做可以优化类型检查。不过这个变化对用户是透明的,还是只有一个『布尔』类型的数据(PHP
    脚本中)。
  • PHP 引用不再使用 is_ref 来标记,而是使用 IS_REFERENCE
    类型。这个也要放到下一部分讲;
  • IS_INDIRECTIS_PTR 是特殊的内部标记。

实际上上面的列表中应该还存在两个 fake types,这里忽略了。

IS_LONG 类型表示的是一个 zend_long 的值,而不是原生的 C 语言的 long
类型。原因是 Windows 的 64 位系统(LLP64)上的 long 类型只有 32
位的位深度。所以 PHP5 在 Windows 上只能使用 32 位的数字。PHP7 允许你在
64 位的操作系统上使用 64 位的数字,即使是在 Windows 上面也可以。

zend_refcounted 的内容会在下一部分讲。下面看看 PHP 引用的实现。

那么,类型呢?

到现在为止,我们知识谈论了类型和zval的值。我们都知道,PHP帮我们做了类型判断。因此,如果我们喜欢,我们可以将一个字符串当作一个整型值。我们把这一步叫做convert_to_type。要转换一个zval为string值,就调用convert_to_string函数。它会改变我们传递给函数的ZVAL的类型。因此,如果你看到有函数在调用这些函数,你就知道它是在转换参数的数据类型。

引用

PHP7 使用了和 PHP5 中完全不同的方法来处理 PHP &
符号引用的问题(这个改动也是 PHP7 开发过程中大量 bug 的根源)。我们先从
PHP5 中 PHP 引用的实现方式说起。

通常情况下, 写时复制原则意味着当你修改一个 zval
之前需要对其进行分离来保证始终修改的只是某一个 PHP
变量的值。这就是传值调用的含义。

但是使用 PHP 引用时这条规则就不适用了。如果一个 PHP 变量是 PHP
引用,就意味着你想要在将多个 PHP 变量指向同一个值。PHP5 中的 is_ref
标记就是用来注明一个 PHP 变量是不是 PHP
引用,在修改时需不需要进行分离的。比如:

$a = [];  // $a     -> zval_1(type=IS_ARRAY, refcount=1, is_ref=0) -> HashTable_1(value=[])
$b =& $a; // $a, $b -> zval_1(type=IS_ARRAY, refcount=2, is_ref=1) -> HashTable_1(value=[])

$b[] = 1; // $a = $b = zval_1(type=IS_ARRAY, refcount=2, is_ref=1) -> HashTable_1(value=[1])
          // 因为 is_ref 的值是 1, 所以 PHP 不会对 zval 进行分离

但是这个设计的一个很大的问题在于它无法在一个 PHP 引用变量和 PHP
非引用变量之间共享同一个值。比如下面这种情况:

$a = [];  // $a         -> zval_1(type=IS_ARRAY, refcount=1, is_ref=0) -> HashTable_1(value=[])
$b = $a;  // $a, $b     -> zval_1(type=IS_ARRAY, refcount=2, is_ref=0) -> HashTable_1(value=[])
$c = $b   // $a, $b, $c -> zval_1(type=IS_ARRAY, refcount=3, is_ref=0) -> HashTable_1(value=[])

$d =& $c; // $a, $b -> zval_1(type=IS_ARRAY, refcount=2, is_ref=0) -> HashTable_1(value=[])
          // $c, $d -> zval_1(type=IS_ARRAY, refcount=2, is_ref=1) -> HashTable_2(value=[])
          // $d 是 $c 的引用, 但却不是 $a 的 $b, 所以这里 zval 还是需要进行复制
          // 这样我们就有了两个 zval, 一个 is_ref 的值是 0, 一个 is_ref 的值是 1.

$d[] = 1; // $a, $b -> zval_1(type=IS_ARRAY, refcount=2, is_ref=0) -> HashTable_1(value=[])
          // $c, $d -> zval_1(type=IS_ARRAY, refcount=2, is_ref=1) -> HashTable_2(value=[1])
          // 因为有两个分离了的 zval, $d[] = 1 的语句就不会修改 $a 和 $b 的值.

这种行为方式也导致在 PHP 中使用引用比普通的值要慢。比如下面这个例子:

$array = range(0, 1000000);
$ref =& $array;
var_dump(count($array)); //

因为 count() 只接受传值调用,但是 $array 是一个 PHP 引用,所以
count() 在执行之前实际上会有一个对数组进行完整的复制的过程。如果
$array 不是引用,这种情况就不会发生了。

现在我们来看看 PHP7 中 PHP 引用的实现。因为 zval
不再单独分配内存,也就没办法再使用和 PHP5 中相同的实现了。所以增加了一个
IS_REFERENCE 类型,并且专门使用 zend_reference 来存储引用值:

struct _zend_reference {
    zend_refcounted   gc;
    zval              val;
};

本质上 zend_reference 只是增加了引用计数的
zval。所有引用变量都会存储一个 zval 指针并且被标记为
IS_REFERENCEval 和其他的 zval
的行为一样,尤其是它也可以在共享其所存储的复杂变量的指针,比如数组可以在引用变量和值变量之间共享。

我们还是看例子,这次是 PHP7 中的语义。为了简洁明了这里不再单独写出
zval,只展示它们指向的结构体:

$a = [];  // $a                                     -> zend_array_1(refcount=1, value=[])
$b =& $a; // $a, $b -> zend_reference_1(refcount=2) -> zend_array_1(refcount=1, value=[])

$b[] = 1; // $a, $b -> zend_reference_1(refcount=2) -> zend_array_1(refcount=1, value=[1])

上面的例子中进行引用传递时会创建一个
zend_reference,注意它的引用计数是 2(因为有两个变量在使用这个 PHP
引用)。但是值本身的引用计数是 1(因为 zend_reference
只是有一个指针指向它)。下面看看引用和非引用混合的情况:

$a = [];  // $a         -> zend_array_1(refcount=1, value=[])
$b = $a;  // $a, $b,    -> zend_array_1(refcount=2, value=[])
$c = $b   // $a, $b, $c -> zend_array_1(refcount=3, value=[])

$d =& $c; // $a, $b                                 -> zend_array_1(refcount=3, value=[])
          // $c, $d -> zend_reference_1(refcount=2) ---^
          // 注意所有变量共享同一个 zend_array, 即使有的是 PHP 引用有的不是

$d[] = 1; // $a, $b                                 -> zend_array_1(refcount=2, value=[])
          // $c, $d -> zend_reference_1(refcount=2) -> zend_array_2(refcount=1, value=[1])
          // 只有在这时进行赋值的时候才会对 zend_array 进行赋值

这里和 PHP5 最大的不同就是所有的变量都可以共享同一个数组,即使有的是 PHP
引用有的不是。只有当其中某一部分被修改的时候才会对数组进行分离。这也意味着使用
count()
时即使给其传递一个很大的引用数组也是安全的,不会再进行复制。不过引用仍然会比普通的数值慢,因为存在需要为
zend_reference
结构体分配内存(间接)并且引擎本身处理这一块儿也不快的的原因。

Zend_Parse_Paramenters

上一篇文章中,介绍了zend_parse_paramenters这个函数。既然我们知道PHP变量在C里面是怎么表示的,那我们就来深入看看。

ZEND_API int zend_parse_parameters(int num_args TSRMLS_DC, const char *type_spec, ...)
{
    va_list va;
    int retval;

    RETURN_IF_ZERO_ARGS(num_args, type_spec, 0);

    va_start(va, type_spec);
    retval = zend_parse_va_args(num_args, type_spec, &va, 0 TSRMLS_CC);
    va_end(va);

    return retval;
}

现在,从表面上看,这看起来很迷惑。重点要理解的是,va_list类型只是一个使用’…’的可变参数列表。因此,它跟PHP中的func_get_args()函数的构造差不多。有了这个东西,我们可以看到zend_parse_parameters函数马上调用zend_parse_va_args函数。我们继续往下看看这个函数…

这个函数看起来很有趣。第一眼看去,它好像做了很多事情。但仔细看看。首先,我们可以看到一个for循环。这个for循环主要遍历从zend_parse_parameters传递过来的type_spec字符串。在循环里面我们可以看到它只是计算期望接收到的参数数量。它是如何做到这些的研究就留给读者。

继续往下看,我么可以看到有一些合理的检查(检查参数是否都正确地传递),还有错误检查,检查是否传递了足够数量的参数。接下来进入一个我们感兴趣的循环。这个循环真正解析那些参数。在循环里面,我们可以看到有三个if语句。第一个处理可选参数的标识符。第二个处理var-args(参数的数量)。第三个if语句正是我们感兴趣的。可以看到,这里调用了zend_parse_arg()函数。让我们再深入看看这个函数…

继续往下看,我们可以看到这里有一些非常有趣的事情。这个函数再调用另一个函数(zend_parse_arg_impl),然后得到一些错误信息。这在PHP里面是一种很常见的模式,将函数的错误处理工作提取到父函数。这样代码实现和错误处理就分开了,而且可以最大化地重用。你可以继续深入研究那个函数,非常容易理解。但我们现在仔细看看zend_parse_arg_impl()

现在,我们真正到了PHP内部函数解析参数的步骤。让我们看看第一个switch语句的分支,这个分支用来解析整型参数。接下来的应该很容易理解。那么,我们从分支的第一行开始吧:

long *p = va_arg(*va, long *);

如果你记得我们之前说的,va_args是C语言处理变量参数的方式。所以这里是定义一个整型指针(long在C里面是整型)。总之,它从va_arg函数里面得到指针。这说明,它得到传递给zend_parse_parameters函数的参数的指针。所以这就是我们会用分支结束后的值赋值的指针结果。接下来,我们可以看到进入一个根据传递进来的变量(zval)类型的分支。我们先看看IS_STRING分支(这一步会在传递整型值到字符串变量时执行)。

case IS_STRING:
{
    double d;
    int type;

    if ((type = is_numeric_string(Z_STRVAL_PP(arg), Z_STRLEN_PP(arg), p, &d, -1)) == 0) {
        return "long";
    } else if (type == IS_DOUBLE) {
        if (c == 'L') {
            if (d > LONG_MAX) {
                *p = LONG_MAX;
                break;
            } else if (d < LONG_MIN) {
                *p = LONG_MIN;
                break;
            }
        }

        *p = zend_dval_to_lval(d);
    }
}
break;

现在,这个做的事情并没有看起来的那么多。所有的事情都归结与is_numeric_string函数。总的来说,该函数检查字符串是否只包含整数字符,如果不是的话就返回0。如果是的话,它将该字符串解析到变量里(整型或浮点型,p或d),然后返回数据类型。所以我们可以看到,如果字符串不是纯数字,他返回“long”字符串。这个字符串用来包装错误处理函数。否则,如果字符串表示double(浮点型),它先检查这个浮点数作为整型数来存储的话是否太大,然后它使用zend_dval_to_lval函数来帮助解析浮点数到整型数。这就是我们所知道的。我们已经解析了我们的字符串参数。现在继续看看其他分支:

case IS_DOUBLE:
    if (c == 'L') {
        if (Z_DVAL_PP(arg) > LONG_MAX) {
            *p = LONG_MAX;
            break;
        } else if (Z_DVAL_PP(arg) < LONG_MIN) {
        *p = LONG_MIN;
        break;
    }
}
case IS_NULL:
case IS_LONG:
case IS_BOOL:
convert_to_long_ex(arg);
*p = Z_LVAL_PP(arg);
break;

这里,我们可以看到解析浮点数的操作,这一步跟解析字符串里的浮点数相似(巧合?)。有一个很重要的事情要注意的是,如果参数的标识不是大写’L’,它会跟其他类型变量一样的处理方式(这个case语句没有break)。现在,我们还有一个有趣的函数,convert_to_long_ex()。这跟我们之前说到的convert_to_type()函数集合是一类的,该函数转换参数为特定的类型。唯一的不同是,如果参数不是引用的话(因为这个函数在改变数据类型),这个函数就将变量的值及其引用分离(拷贝)了。(
The only difference is that it separates (copies) the passed in variable
if it’s not a reference (since it’s changing the type).
)这就是写时复制的作用。因此,当我们传递一个浮点数到到一个非引用的整型变量,该函数会把它当作整型来处理,但我们仍然可以得到浮点型数据。

case IS_ARRAY:
case IS_OBJECT:
case IS_RESOURCE:
default:
return "long";

最后,我们还有另外三个case分支。我们可以看到,如果你传递一个数组、对象、资源或者其他不知道的类型到整型变量中,你会得到错误。

剩下的部分我们留给读者。阅读zend_parse_arg_impl函数对更好地理解额PHP类型判断系统真的很有用。一部分一部分地读,然后尽量追踪在C里面的各种参数的状态和类型。

结语

总结一下 PHP7 中最重要的改变就是 zval
不再单独从堆上分配内存并且不自己存储引用计数。需要使用 zval
指针的复杂类型(比如字符串、数组和对象)会自己存储引用计数。这样就可以有更少的内存分配操作、更少的间接指针使用以及更少的内存分配。

文章的第二部分我们会讨论复杂类型的问题。

下一部分

下一部分会在Nikic的博客(我们会在这个系列的文章来回跳转)。在下一篇,他会谈到数组的所有内容。

发表评论

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