深入理解PHP5之引用计数以及变量分离

以下代码都基于php-5.6.21

zval简介

PHP使用zval结构体来存储各种类型的值,比如整数,字符串或者对象等。查看zend.h文件,我们可以找到定义zval结构的代码:

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

其中各个字段的含义如下所示:

字段名 含义 默认值
value 变量的具体值
refcount_gc 引用计数,即当前有多少个变量指向该zval,用于垃圾回收 1
type 变量的具体类型,比如IS_ARRAY则表示是数组类型
is_ref_gc 是否为引用类型 0

接下来,我们将对refcount_gc和is_ref_gc做更进一步地阐述。

引用计数

refcount_gc,即引用计数,是用来判断一个变量是否需要被垃圾回收的重要依据。如果当一个zval的refcount_gc为0了,那么该zval将会被gc所回收。引用计数的概念十分简单,如下所示:

1
2
3
$a = 1;    // $a =           zval_1(type=IS_LONG, value=1, refcount_gc=1)
$b = $a; // $a = $b = zval_1(type=IS_LONG, value=1, refcount_gc=2)
unset($a); // $b = zval_1(type=IS_LONG, value=1, refcount_gc=1)

当执行上述代码时,内存中的行为将分为以下三步:

  1. $a = 1:在内存中创建zval_1,赋值,并且在符号表中添加$a,将其指向zval_1
  2. $b = $a:由于写时复制技术的引入(下文会讲到),我们会将zval_1的refcount_gc的值加1,表示当前一共有两个变量($a$b)指向了zval_1,并且在符号表中添加$b,将其指向zval_1
  3. unset($a):销毁$a,并且将zval_1的refcount_gc的值减1。此时只有$b指向zval_1

上述代码针对的是不含引用的情况,那如果涉及到PHP中的引用,如下代码所示:

1
2
$a = 1;    // $a =      zval_1(type=IS_LONG, value=1, refcount_gc=1, is_ref_gc=0)
$b = &$a; // $a = $b = zval_1(type=IS_LONG, value=1, refcount_gc=2, is_ref_gc=1)

当执行$b = &$a的时候,由于声明$b为一个引用,那么此时在内存中,会将zval_1的is_ref_gc属性置为1,并且同时将refcount_gc加1。当zval的is_ref_gc属性为1时,则表明是一个引用。

PS:在实践中,我们可以用xdebug_debug_zval来查看引用计数以及引用标记位的值。

变量分离

写时复制与写时改变

当谈到变量分离时,我们首先需要了解两个概念,即写时复制与写时改变。

写时复制

写时复制,即Copy on Write(COW),是一种十分常用的减少内存占用的技术。该技术的核心思想就是使资源延迟(lazy)分配。那么到底何为写时复制,如下代码所示:

1
2
3
4
5
$a = 1;    // $a =           zval_1(type=IS_LONG, value=1, refcount_gc=1)
$b = $a; // $a = $b = zval_1(type=IS_LONG, value=1, refcount_gc=2)
$c = $b; // $a = $b = $c = zval_1(type=IS_LONG, value=1, refcount_gc=3)
$b = 2; // $a = $c = zval_1(type=IS_LONG, value=1, refcount_gc=2)
// $b = zval_2(type=IS_LONG, value=2, refcount_gc=1)

zend引擎在修改一个变量时,即write操作,会首先查看当前变量指向的zval的refcount_gc,如果大于1,那么会执行一次变量分离(Separation):对于上述代码来说,当执行到$b = 2的时候,由于该操作是一个修改操作,那么会copy出一份一模一样的zval,即注释中的zval_2,并且将$b指向新的zval_2,然后将原来的zval,即zval_1的refcount_gc减1。这样的机制也就是写时复制。通过写时复制,如果变量不发生变动,那么可以节省大量的内存分配操作。

写时改变

写时改变,即Change on Write,那么到底何为写时改变,如下代码所示:

1
2
3
$a = 1;    // $a =      zval_1(type=IS_LONG, value=1, refcount_gc=1, is_ref_gc=0)
$b = &$a; // $a = $b = zval_1(type=IS_LONG, value=1, refcount_gc=2, is_ref_gc=1)
$b = 2; // $a = $b = zval_1(type=IS_LONG, value=2, refcount_gc=2, is_ref_gc=1)

对于上述代码来说,当执行到$b = 2这步的时候,zend引擎不会去做变量分离的操作,而是直接将zval_1中的值置为了2。而这步操作会间接地使得$a的值也变成了2,那么这个过程就称作写时改变了。通过写时改变,可以达到引用的效果,即改变一个变量的引用的值时,会自动地改变该变量的值。

源码分析

针对于上述所有代码,我们发现其实最关键的一步在于$b = 2。那么当zend引擎执行到这一步赋值时,到底会进行怎样的逻辑判断呢?

由于$b = 2是一个常量赋值操作,所以我们在zend_execute.c中找到常量赋值的源码即zend_assign_const_to_variable,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
static inline zval* zend_assign_const_to_variable(zval **variable_ptr_ptr, zval *value TSRMLS_DC)
{
// ......
if (UNEXPECTED(Z_REFCOUNT_P(variable_ptr) > 1) &&
EXPECTED(!PZVAL_IS_REF(variable_ptr))) {
/* we need to split */
/* 发生写时复制,进行变量分离 */
Z_DELREF_P(variable_ptr); // 原zval的引用计数减1
GC_ZVAL_CHECK_POSSIBLE_ROOT(variable_ptr); // 判断原zval是否需要回收
ALLOC_ZVAL(variable_ptr); // 申请新的zval的空间
INIT_PZVAL_COPY(variable_ptr, value); // 将新的zval的值置为新的值
zval_copy_ctor(variable_ptr); // 开始拷贝
*variable_ptr_ptr = variable_ptr; // 将变量指向新的zval
return variable_ptr;
} else {
if (EXPECTED(Z_TYPE_P(variable_ptr) <= IS_BOOL)) {
/* nothing to destroy */
ZVAL_COPY_VALUE(variable_ptr, value); // 仅更改值
zendi_zval_copy_ctor(*variable_ptr);
} else {
ZVAL_COPY_VALUE(&garbage, variable_ptr);
ZVAL_COPY_VALUE(variable_ptr, value); // 仅更改值
zendi_zval_copy_ctor(*variable_ptr);
_zval_dtor_func(&garbage ZEND_FILE_LINE_CC);
}
return variable_ptr;
}
}

UNEXPECTED(Z_REFCOUNT_P(variable_ptr) > 1) && EXPECTED(!PZVAL_IS_REF(variable_ptr))判断当前的变量如果引用计数大于1并且不是一个引用,那么就会进行写时复制的变量分离操作;如果判断失败,那么只需要更改zval的值,无需进行变量分离,相当于写时改变操作。

由&引起的变量分离

某些情况下,&即引用操作是会引发未预期的变量分离行为。比如:

1
2
3
$a = 1;    // $a =           zval_1(type=IS_LONG, value=1, refcount_gc=1)
$b = $a; // $a = $b = zval_1(type=IS_LONG, value=1, refcount_gc=2)
$c = &$a;

对于上述代码,前两步不用多说,关键是执行到第三步$c = &$a时,此时有一对Copy on Write的变量:($a$b),以及另一对Change on Write的变量:($a$b)。那么zend引擎为了接下来的操作不产生任何混淆,它会将$b分离出去,并且将$a$c指向一块新的zval结构体,所以,第三步执行完成之后,内存中的情况如下所示:

变量 zval
$a、$c zval_1(type=IS_LONG, value=1, refcount_gc=2, is_ref_gc=1)
$b zval_2(type=IS_LONG, value=1, refcount_gc=1, is_ref_gc=0)

我们查阅zend_execute.c中关于引用赋值的函数zend_assign_to_variable_reference的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
static void zend_assign_to_variable_reference(zval **variable_ptr_ptr, zval **value_ptr_ptr TSRMLS_DC)
{
/* $c = &$a */
zval *variable_ptr = *variable_ptr_ptr; // $c
zval *value_ptr = *value_ptr_ptr; // $a

if (variable_ptr == &EG(error_zval) || value_ptr == &EG(error_zval)) {
variable_ptr_ptr = &EG(uninitialized_zval_ptr);
} else if (variable_ptr != value_ptr) {
if (!PZVAL_IS_REF(value_ptr)) { // 如果$a不是一个引用
/* break it away */
/* 尝试变量分离操作 */
Z_DELREF_P(value_ptr); // 将$a指向的zval的refcount_gc减1
if (Z_REFCOUNT_P(value_ptr)>0) { // 如果$a指向的zval还被其他变量($b)引用
/* 创建一个新的zval(zval_2),并将$a指向它 */
/* 此处类似于做了变量分离操作 */
ALLOC_ZVAL(*value_ptr_ptr);
ZVAL_COPY_VALUE(*value_ptr_ptr, value_ptr);
value_ptr = *value_ptr_ptr;
zendi_zval_copy_ctor(*value_ptr);
}
Z_SET_REFCOUNT_P(value_ptr, 1); // 将zval_2的引用计数置为1
Z_SET_ISREF_P(value_ptr); // 将zval_2的引用标记置为1
}

*variable_ptr_ptr = value_ptr; // 将$c指向zval_2
Z_ADDREF_P(value_ptr); // 将zval_2的引用计数加1

zval_ptr_dtor(&variable_ptr); // 由于$c指向了新的zval,那么需要对$c原来的zval做垃圾回收
} else if (!Z_ISREF_P(variable_ptr)) {
//......
}
}

如上述代码所示,当执行$c = &$a完成之后,zend会在内存中copy一个新的zval。这样的行为称为由&引起的变量分离。类似地,下述代码,在执行$c = $a时也会隐式地引起变量分离:

1
2
3
$a = 1;    
$b = &$a;
$c = $a;

这种隐式的变量分离的行为在某些场合可能会引起某些性能问题,比如:

1
2
3
4
5
6
7
$arr = range(1, 100000);
function test($test_arr) {}
$i = 0;
$b = &$arr; // 不小心将$arr引用给了$b
while ($i++ < 100) {
test($arr); // 发生变量分离,不停地copy $arr
}

在上述代码中,我们有一个test函数。在这个函数中,我们传递了一个数组作为该函数的参数。如果没有$b = &$arr这行代码的话,这整段代码不会有任何问题,因为在Copy on Write的机制下,只有当$test_arr这个变量在函数中进行了write操作,我们才会创建新的zval,不然$test_arr$arr一直都指向同一个zval。

但是现在我们加上了$b = &$arr,那么我们执行test方法的时候,由于传参的时候有一个隐式的赋值行为,这个隐式的赋值行为会因为$arr是一个引用而引起隐式的变量分离,当$arr占用大量内存的时候,这种变量分离带来的copy,会极大的拖慢性能。

总结

zend引擎的COW策略,着实帮我们节约了很多内存消耗。在COW中,会有变量分离操作,综上所述,当满足下述任一情况的时候就会发生变量分离:

  1. 对一个(is_ref_gc=0,refcount_gc>1)的变量进行赋值
  2. 将一个(is_ref_gc=1,refcount_gc>1)的变量赋值给另一个普通变量

但是第2种情况某些时候会导致不可预期的性能问题。所以我们在写代码的过程中,要尽量避免&的使用,或者在使用&前,要对前后的代码逻辑有十分明确的认识。

以上实践以及结论若有错误,欢迎一起探讨~

参考资料

  1. 深入理解PHP内核-写时复制
  2. 深入理解PHP原理之变量分离/引用
Young wechat
欢迎您扫一扫上面的微信公众号,订阅我的博客!
坚持原创技术分享,您的支持将鼓励我继续创作