PHP:写入时复制和按引用分配在 PHP5 和 PHP7 上执行不同

PHP: Copy On Write and Assign By Reference perform different on PHP5 and PHP7

我们有一段简单的代码:

1    <?php
2    $i = 2;
3    $j = &$i;
4    echo (++$i) + (++$i);

在PHP5上输出8,因为:

$i 是一个引用,当我们将 $i 增加 ++i 时,它会更改 zval 而不是复制,所以第 4 行将是 4 + 4 = 8。这是按引用分配

如果我们注释第3行,它会输出7,每次我们通过增加它来改变值,PHP会复制一份,第4行会是3 + 4 = 7。这是 写时复制

但是在PHP7中,它总是输出7。

我检查了 PHP7 中的变化:http://php.net/manual/en/migration70.incompatible.php,但我没有得到任何线索。

任何帮助都将非常有用,在此先感谢。

更新1

这是 PHP5 / PHP7 上的代码的结果:https://3v4l.org/USTHR

update2

操作码:

[huqiu@101 tmp]$ php -d vld.active=1 -d vld.execute=0 -f incr-ref-add.php
Finding entry points
Branch analysis from position: 0
Jump found. Position 1 = -2
filename:       /home/huqiu/tmp/incr-ref-add.php
function name:  (null)
number of ops:  7
compiled vars:  !0 = $i, !1 = $j
line     #* E I O op                           fetch          ext  return  operands
-------------------------------------------------------------------------------------
   2     0  E >   ASSIGN                                                   !0, 2
   3     1        ASSIGN_REF                                               !1, !0
   4     2        PRE_INC                                                !0
         3        PRE_INC                                                !0
         4        ADD                                              ~4      , 
         5        ECHO                                                     ~4
   5     6      > RETURN                                                   1

branch: #  0; line:     2-    5; sop:     0; eop:     6; out1:  -2
path #1: 0,

我会说它在 PHP7 中的工作方式是正确的。根据是否在任何地方引用操作数来隐式更改运算符的工作方式是不好的。

这是完全重写 PHP7 的最大好处:没有 clumsy/bug-driven-development v4/v5 代码可以工作。

免责声明:我不是 PHP 内部专家(还是?)所以这完全是我的理解,不能保证 100% 正确或完全的。 :)

所以,首先,PHP 7 的行为——我注意到,HHVM 也紧随其后——似乎是正确的,PHP 5 在这里有一个错误。这里不应该有额外的引用行为赋值,因为无论执行顺序如何,两次调用 ++$i 的结果都不应该相同。

操作码看起来不错;至关重要的是,我们有两个临时变量 </code> 和 <code> 来保存两个增量结果。但不知何故,PHP 5 表现得好像我们这样写:

$i = 2;
$i++; $temp1 =& $i;
$i++; $temp2 =& $i;
echo $temp1 + $temp2; 

而不是这个:

$i = 2;
$i++; $temp1 = $i;
$i++; $temp2 = $i;
echo $temp1 + $temp2; 

编辑: PHP Internals 邮件列表中指出,在单个语句中使用多个修改变量的操作通常被认为是 "undefined behaviour",而 ++used as an example of this in C/C++.

因此,出于实现/优化的原因,PHP 5 到 return 它的值是合理的,即使它在逻辑上与多个语句的合理序列化不一致。

(相对较新)PHP language specification 包含类似的语言和示例:

Unless stated explicitly in this specification, the order in which the operands in an expression are evaluated relative to each other is unspecified. [...] (For example,[...] in the full expression $j = $i + $i++, whether the value of $i is the old or new $i, is unspecified.)

可以说,这是一个比 "undefined behaviour" 更弱的声明,因为它暗示它们是按某种特定顺序评估的,但我们现在要吹毛求疵了。

phpdbg调查(PHP5)

我很好奇,想更多地了解内部结构,所以使用 phpdbg 进行了一些尝试。

没有参考文献

运行 用 $j = $i 代替 $j =& $i 的代码,我们从 2 个共享地址的变量开始,引用计数为 2(但没有 is_ref 标志):

Address         Refs    Type            Variable
0x7f3272a83be8  2       (integer)       $i
0x7f3272a83be8  2       (integer)       $j

但是一旦预增量,zval 就会分开,只有一个临时变量与 $i 共享,引用计数为 2:

Address         Refs    Type            Variable
0x7f189f9ecfc8  2       (integer)       $i
0x7f189f859be8  1       (integer)       $j

有参考赋值

当变量绑定在一起时,它们共享一个地址,refcount 为 2,以及一个 by-ref 标记:

Address         Refs    Type            Variable
0x7f9e04ee7fd0  2       (integer)       &$i
0x7f9e04ee7fd0  2       (integer)       &$j

在预递增之后(但在添加之前),同一地址的引用计数为 4,显示 2 个临时变量被引用错误地绑定:

Address         Refs    Type            Variable
0x7f9e04ee7fd0  4       (integer)       &$i
0x7f9e04ee7fd0  4       (integer)       &$j

问题的根源

深入研究 http://lxr.php.net 上的源代码,我们可以找到 ZEND_PRE_INC 操作码的实现:

PHP 5

关键的一行是:

 SEPARATE_ZVAL_IF_NOT_REF(var_ptr);

所以我们为结果值创建一个新的 zval 只有当它当前不是一个引用时。再往下,我们有这个:

if (RETURN_VALUE_USED(opline)) {
    PZVAL_LOCK(*var_ptr);
    EX_T(opline->result.var).var.ptr = *var_ptr;
}

所以如果真正使用减量的return值,我们需要"lock"zval,后面一整套宏基本上就是"increment its refcount",然后再赋值结果。

如果我们之前创建了一个新的 zval,那很好——我们的引用计数现在是 2,1 是实际变量,1 是操作结果。但如果我们决定不这样做,因为我们需要保留一个引用,我们只是增加现有的引用计数,并指向一个可能即将再次更改的 zval。

PHP 7

那么 PHP 7 有何不同?几件事!

首先,phpdbg 的输出相当乏味,因为在 PHP 7 中整数不再被引用计数;相反,引用赋值会创建一个额外的指针,它本身的引用计数为 1,指向内存中的相同地址,这是实际的整数。 phpdbg 输出如下所示:

Address            Refs    Type      Variable
0x7f175ca660e8     1       integer   &$i
int (2)
0x7f175ca660e8     1       integer   &$j
int (2)

其次,在the source中有一个用于整数的特殊代码路径:

if (EXPECTED(Z_TYPE_P(var_ptr) == IS_LONG)) {
    fast_long_increment_function(var_ptr);
    if (UNEXPECTED(RETURN_VALUE_USED(opline))) {
        ZVAL_COPY_VALUE(EX_VAR(opline->result.var), var_ptr);
    }
    ZEND_VM_NEXT_OPCODE();
}

因此,如果变量是整数 (IS_LONG) 而不是对整数的引用 (IS_REFERENCE),那么我们可以将其递增地方。如果我们随后需要 return 值,我们可以将 它的值 复制到结果中 (ZVAL_COPY_VALUE).

如果它是一个引用,我们将不会访问该代码,但我们不会将引用绑定在一起,而是使用以下两行:

ZVAL_DEREF(var_ptr);
SEPARATE_ZVAL_NOREF(var_ptr);

第一行说"if it's a reference, follow it to its target";这将我们从 "reference to an integer" 带到整数本身。第二个-我认为-说"if it's something refcounted, and has more than one reference, create a copy of it";在我们的例子中,这不会做任何事情,因为整数不关心引用计数。

现在我们有了一个可以递减的整数,它会影响所有按引用关联,但不会影响引用计数类型的按值关联。最后,如果我们想要增量的return值,我们再次复制它,而不是仅仅分配它;这次有一个稍微不同的宏,它会在必要时增加我们新 zval 的引用计数:

ZVAL_COPY(EX_VAR(opline->result.var), var_ptr);