PHP在__get魔术方法中进行Swoole协程切换的问题

我们有如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<?php

use Swoole\Coroutine;

class Foo
{
public function __get($name)
{
Coroutine::sleep(1);
}
}


$foo = new Foo;

for ($i=0; $i < 2; $i++) {
go(function () use ($foo) {
$foo->aaa;
});
}

执行结果如下:

1
2
3
4
5
[root@e2a14c00e7f6 get]# php get.php
PHP Notice: Undefined property: Foo::$aaa in /root/codeDir/phpCode/swoole/coroutine/get/get.php on line 18

Notice: Undefined property: Foo::$aaa in /root/codeDir/phpCode/swoole/coroutine/get/get.php on line 18
[root@e2a14c00e7f6 get]#

我们会发现,这里会有警告,说是使用了没有定义的属性。

为了理解这个问题,我们可以先来了解一下__get这个魔术方法。首先是这么一段代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<?php

use Swoole\Coroutine;

class Foo
{
public function __get($name)
{
var_dump($name);
}
}


$foo = new Foo;
$foo->aaa;

执行结果如下:

1
2
3
[root@e2a14c00e7f6 get]# php get.php
string(3) "aaa"
[root@e2a14c00e7f6 get]#

我们发现,因为aaa这个属性是类Foo的动态属性,所以默认会去调用Foo类的__get魔术方法,并且,传递给魔术方法的参数就是这个动态属性的名字。

好的,我们现在再来写一段代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<?php

use Swoole\Coroutine;

class Foo
{
public function __get($name)
{
$this->$name;
}
}


$foo = new Foo;
$foo->aaa;

执行结果如下:

1
2
3
4
5
[root@e2a14c00e7f6 get]# php get.php
PHP Notice: Undefined property: Foo::$aaa in /root/codeDir/phpCode/swoole/coroutine/get/get.php on line 9

Notice: Undefined property: Foo::$aaa in /root/codeDir/phpCode/swoole/coroutine/get/get.php on line 9
[root@e2a14c00e7f6 get]#

我们发现,当我们在__get魔术方法里面去读取动态属性aaa的时候,报的错误和我们在__get方法中进行协程切换是一模一样的。我们可以来理解一下为什么PHP要这么警告?

如果不这么警告的话,那么我们在__get函数里面读取动态属性aaa是不是又会继续调用__get魔术方法呢?那么这就会导致无限的递归了。所以PHP是禁止这种行为的。

那么,PHP底层是如何做到这种限制的呢?我们引用《PHP7内核剖析》的内容来解释一下:

1
2
3
4
5
6
7
Note: 如果类存在 get () 方法,则在实例化对象分配属性内存 (即:properties_table) 时会多分配一个 zval,类型为 HashTable,每次调用 get ($var) 时会把输入的 $var 名称存入这个哈希表,这样做的目的是防止循环调用,举个例子:

public function __get($var) { return $this->$var; }

这种情况是调用 get () 时又访问了一个不存在的属性,也就是会在 get () 方法中递归调用,如果不对请求的 $var 作判断则将一直递归下去,所以在调用 get () 前首先会判断当前 $var 是不是已经在 get () 中了,如果是则不会再调用 get (),否则会把 $var 作为 key 插入那个 HashTable,然后将哈希值设置为:*guard |= IN_ISSET,调用完 get () 再把哈希值设置为:*guard &= ~IN_ISSET。

这个 HashTable 不仅仅是给 get () 用的,其它魔术方法也会用到,所以其哈希值类型是 zend_long,不同的魔术方法占不同的 bit 位;其次,并不是所有的对象都会额外分配这个 HashTable,在对象创建时会根据 zend_class_entry.ce_flags 是否包含 ZEND_ACC_USE_GUARDS 确定是否分配,在类编译时如果发现定义了 get()、set()、unset ()、__isset () 方法则会将 ce_flags 打上这个掩码。

所以,总结起来就是,当调用了__get时,会对这个动态属性做一个IN_ISSET标记,直到结束了这次__get调用,才会取消这个IN_ISSET标记。如果在有IN_ISSET的时候,再次对这个动态属性进行访问,那么就会报这个警告了。

所以,如下写法就是可以的了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<?php

use Swoole\Coroutine;

class Foo
{
public function __get($name)
{
var_dump($name);
}
}

$foo = new Foo;
$foo->aaa;
$foo->aaa;

执行结果如下:

1
2
3
4
[root@e2a14c00e7f6 get]# php get.php
string(3) "aaa"
string(3) "aaa"
[root@e2a14c00e7f6 get]#

因为我们是在退出第一次__get魔术方法调用之后再次访问动态属性aaa的,这个时候IN_ISSET标记已经没了。

好的,我们现在来修改一下之前的协程切换的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<?php

use Swoole\Coroutine;

class Foo
{
public function __get($name)
{
Coroutine::sleep(1);
}
}


$foo = new Foo;

go(function () use ($foo) {
$foo->aaa;
});

go(function () use ($foo) {
$foo->aaa;
});

这样或许会更加直观一点。首先,当第一个协程读取动态属性aaa的时候,对象$foo第一次调用了__get魔术方法。然后,因为Coroutine::sleep,协程被挂起了,__get魔术方法还没有退出,此时IN_ISSET标记还在。这个时候,轮到第二个协程进行动态属性aaa的读取,此时,因为IN_ISSET还在,所以此时访问动态属性aaa就是禁止的了:

1
2
3
4
5
[root@e2a14c00e7f6 get]# php get.php
PHP Notice: Undefined property: Foo::$aaa in /root/codeDir/phpCode/swoole/coroutine/get/get.php on line 21

Notice: Undefined property: Foo::$aaa in /root/codeDir/phpCode/swoole/coroutine/get/get.php on line 21
[root@e2a14c00e7f6 get]#

我们发现报错的地方是第21行,是第二个协程报出的警告。