2020-12-03
搞懂 yield 表达式
作用
yield
表达式常用在
-
频繁通过函数的返回值传递大数组,消耗过多内存的时候
-
异步执行代码(协程)。
也可以直接参考 鸟哥文章 - 在PHP中使用协程实现多任务调度 ,但我自己看过一两遍的时候,完全看不懂。其他可以参考的文档不多,可以从 php
官网的 generators
介绍中了解一点 yield
信息。php官网 - 生成器总览
描述
首先 php
中的 yield
表达式是一个特殊的表达式,它只能用在 函数的作用域 中。只要某一个函数中使用了 yield
表达式,那么这个函数的返回值就是 Generator
类。而且,只要函数中包含 yield
表达式,这个函数的 return
将不再起作用。先看一个例子;
<?php
function _test()
{
yield; // 1. 直接使用
yield 'a'; // 2. 后面跟上 变量 或者 常量 甚至调用一个函数
echo "b\n"; // 问题:这里什么时候会执行呢?还是不会被执行
return 'foo bar'; // 函数返回值没有作用了
}
$t = _test();
var_dump($t);
foreach ($t as $item) {
echo "foreach-" . var_export($item, true) . "\n";
}
执行结果
$ php _a.php
object(Generator)#1 (0) {
}
foreach-NULL
foreach-'a'
b
可以验证以下结论:
yield
后面可以跟具体的值,也可以什么都不加,如_test
函数的第 1、2 行。_test
函数第 3 行,在$t = _test()
之后没有输出,说明没有执行到这。- 函数包含
yield
所以返回的是Generator
,而不是_test
函数的第 4 行返回的字符串foo bar
。 Generator
类可以循环,函数中包含 2 个yield
表达式,循环输出值就有 2 个,很像函数返回来包含 2 个值的数组。foreach
的结果是yield
表达式之后的值,如果没有就是NULL
,有的话就是对应的变量或者常量的值。_test
函数的第 3 行内容b
是在最后输出,如果第 3 行放到函数的第 1 行,或者两个yield
直接,那么输出次序呢?
可以调整下 echo
的位置,看下输出位置:
- 如果调整到函数第一行,会在
foreach
开始前输出。 - 如果放到两个
yield
中间,那么就是两次输出foreach—*
结果中间输出。 经过实验说明了什么?验证了鸟哥博客中说的那句话,那就是包含yield
的函数在被调用后,里面代码一行都没有执行,就会立即返回了一个Generator
类,也叫 生成器类 ,它其实是 “一种可中断的函数”,里面的每一个yield
就是一个中断点。中断什么时候触发呢?那就是foreach
语句循环的时候。
Generator 类
Generator
类是实现了 Iterator
接口的一个 final
类,所以它可以在 foreach(data as k => v) {}
等循环结构中使用。继承 Iterator
接口需要实现 5
个函数:
rewind
一般用来把游标初始化成最开始的位置,也就是下标为 0 的地方valid
用来判断是否有效,如果有效继续循环,无效结束循环current
获取当前位置的值,并返回当前的值,也就是返回foreach
中的v
key
获取当前的下标,也就是返回foreach
中的k
,然后结构中就可以使用k
,v
了next
游标移动到一个位置
foreach
一个实现了 Iterator
接口的类时,会依次执行这五个函数。次序分别是下面三种之一
rewind → valid(false) → 结束
rewind → valid(true) → current → key → { 执行 foreach 结构体 } → next → valid(false) → 结束
rewind → valid(true) → current → key → { 执行 foreach 结构体 } → next → valid(true) -> current ...
测试代码
class TestIterator implements Iterator
{
protected $index = 0;
protected $k;
protected $data = [
'k_1' => 'v_1',
'k_2' => 'v_2',
'k_3' => 'k_3',
];
public function rewind(): void
{
echo "rewind\n";
$this->index = 1;
$this->k = 'k_' . $this->index;
}
public function valid(): bool
{
echo "valid\n\n";
return isset($this->data[$this->k]) && strpos($this->data[$this->k], 'v_') === 0;
}
public function current()
{
echo "current\n";
return $this->data[$this->k];
}
public function key()
{
echo "key\n";
return $this->k;
}
public function next(): void
{
echo "next\n";
$this->index++;
$this->k = 'k_' . $this->index;
}
}
$test = new TestIterator();
foreach ($test as $key => $item) {
echo $key . '=' . $item . PHP_EOL;
}
结果是,输出 $data
中以 v_
开头的值,应该输出前两个值,顺便验证下内部 5
个函数的执行顺序,输出如下
rewind
valid
current
key
k_1=v_1
next
valid
current
key
k_2=v_2
next
valid
Generator
类实现了 Iterator
类的五个函数,所以可以循环 yield
表达式的值。现在回头看下第一个示例代码中的例子, $t = _test()
调用函数后,立即返回了一个 Generator
类,这个类包含了两个中断点,在 foreach
开始后调用的第一个函数 rewind
也就是把游标移动到第一个 yield
的地方,如果第一个 yield
之前有代码,那么在这个时候执行这些代码,如第 3 行第 echo xx
。然后就是第二个 yield
(中断的地方),依次类推,直到函数中所有代码执行完。这个时候可能会想,执行到最后是 return
语句,返回字符串,那么 $t
会不会变成字符串呢?其实不会的,不信可以试一试,$t
永远都会是 Generator
类,而不是 return
的字符串,所以 return
在包含 yield
的函数中失去了作用,写不写都没有用。
Generator
另外还有一个 send(value)
方法,用来通过外部传递 value
值给函数内部的 yield
,意思就是 yield
既可以接收外部传值,也可以将 yield
右边的值,通过 foreach
或者直接调用 current
返回给外部调用者
Send 传参
先看一个例子:
<?php
function _test()
{
echo 'echo ' . yield . ' bar' . PHP_EOL;
yield 'foo';
}
$t = _test();
sleep(3);
$ret = $t->send('hello world');
var_dump($ret);
通过 send
发送值给函数内部的 yield
表达式,执行后 过 3 秒才可以看到输出 echo hello world bar
说明是调用 send
函数之后,才会输出。思考下为什么?.... 上面介绍过,Generator
实际上是一个包含了多个中断点(yield
)的函数,只有在调用函数 rewind
或者 next
移动内部游标位置,执行流才会切换到不同的中断点,foreach
结构会自动触发 rewind
等函数调用。实验发现 send
调用是也会触发,说明内部也是通过调用 rewind
或者 next
来改变代码执行流程。
验证下猜测也很简单, 上面例子中 send
完之后,再次 foreach
..
$t->send('hello world');
foreach ($t as $item) {}
执行会报错 Fatal error: Uncaught Exception: Cannot traverse an already closed generator ...
这只说明当前只有一个中断点(yield
),且 send
执行完没有中断点了,就会关闭生成器,但没有直接证明会执行 rewind
。 修改下代码,多加一个 yield
<?php
function _test()
{
echo 'echo ' . yield . ' bar' . PHP_EOL;
echo 'echo ' . yield . ' bar' . PHP_EOL;
}
$t = _test();
sleep(3);
$t->send('hello world');
foreach ($t as $item) {}
两个中断点,一次 send
一次 foreach
,这次报错内容是:Fatal error: Uncaught Exception: Cannot rewind a generator that was already run ...
,很清楚地说明了,rewind
函数已经被 send
调用时自动触发了,因为我们知道 foreach
结构会触发 rewind
,而且 rewind
只能触发一次,同时也证明了,最好不要 send
和 foreach
同时使用。同时,send
函数会返回值,它返回的是下一个 yield
右边的值,如果没有就是 NULL
,所以 send
函数等同于设置 yield
表达式值的同时手动调用 rewind
, valid
, current
等函数,把下一个断点的值返回出来。
function _test()
{
echo yield . ' bar' . PHP_EOL;
echo (yield 'test') . ' bar' . PHP_EOL;
}
$t = _test();
var_dump($t->send('hello'));
var_dump($t->send('world'));
输出结果
hello bar // 第一行,发送 hello 给第一个 yield 的值,且触发 rewind 代码执行到这里并输出,然后返回下一个 yield 右边的值
string(4) "test" // 第一个 var_dump 打印的就是第一次 send 的返回值,也是第二个 yield 右边的值
world bar // 第二个 send 函数发送给第二个 yield 的值,然后输出结果为 “world bar”, 注意没有 “test”, “test” 已经返回给第一个 send 函数了
NULL // 第二个 send 返回值应该对应第三个 yield 值,但是没有,所以返回 NULL
// 可以看到,第 N 个 send 发送 value 给第 N 个 yield ,然后返回 N+1 个 yield 右边的值
// 而且 yield xx 这个表达式的值是 send 到此处的 value,foreach 只能获取 yield 右侧的 xx 值
和 foreach
不同的是,foreach
是循环移动,直到中断点循环完,而 send
是单步移动内部游标的。所以如果想无限次调用 send
发送数据给函数,可以加一个 while(true)
死循环。
function _test()
{
while (true) {
echo 'echo ' . yield . ' bar' . PHP_EOL;
}
}
$t = _test();
$t->send('hello world');
$t->send('hello world again');
这样就可以 send
无限次,函数内部也会在每次 send
之后输出对应的内容。可以看到 while(true
) 并不会阻塞,只有当 send
函数调用时,才会去执行,这种就把阻塞IO
变成 非阻塞IO
了。而且可以不通过函数参数来传递值,是不是很美好,因为函数参数传递,会涉及参数入栈、出栈等处理。如果参数是一个大的数组,那么将会即占用很多内存,也消耗更多时间。所以有了 yield
的第一个用途,就是减少内存和提高处理速度。值得注意的是,如果使用了 while(true)
死循环的方式,那么一定不要用 foreach
循环结果,否则没有适当的跳出条件,真的成为死循环了。看到这里,鸟哥博客中第一个示例就能看懂了。
内存消耗测试
以上解释了 yield 如何实现和如何使用,再测试下和函数参数传递的方式对比,内存消耗和时间消耗差异。
不使用 yield
测试函数返回一个约 100M
大小的数组的耗时和最高内存,为了时间久一点,中间使用 usleep(500)
,关闭垃圾回收机制,是为了测试内存更准确一点。
function _no_yield()
{
$strs = [];
for ($i = 0; $i < 10240; $i++) {
$str = '';
for ($j = 0; $j < 10240; $j++) {
$str .= '1';
}
usleep(500);
$strs[] = $str;
}
return $strs;
}
gc_disable();
echo '3: 内存使用、耗时测试:' . PHP_EOL;
echo "|--- 3.1: 不使用 yield, 返回长度为 10240*10k 的大数组\n";
$st = microtime(true) * 1000;
_no_yield();
echo '|------- 峰值内存:' . sprintf('%.4f', (memory_get_peak_usage(true)) / 1024 / 1024 ) . "MB\n";
echo '|------- 所需时间:' . sprintf('%.4f', (microtime(true) * 1000 - $st) / 1000 ) . "s\n";
本机执行结果
3: 内存使用、耗时测试:
|--- 3.1: 不使用 yield, 返回长度为 10240*10k 的大数组
|------- 峰值内存:122.0000MB
|------- 所需时间:9.0147s
相同条件,使用 yield
测试如下
function _has_yield()
{
for ($i = 0; $i < 10240; $i++) {
$str = '';
for ($j = 0; $j < 10240; $j++) {
$str .= '1';
}
usleep(500);
yield $str;
}
}
gc_disable();
echo '3: 内存使用、耗时测试:' . PHP_EOL;
echo "|--- 3.2: 使用 yield, 返回长度为 10240*10k 的大数组\n";
$st = microtime(true) * 1000;
_has_yield();
echo '|------- 峰值内存:' . sprintf('%.4f', (memory_get_peak_usage(true)) / 1024 / 1024) . "MB\n";
echo '|------- 所需时间:' . sprintf('%.4f', (microtime(true) * 1000 - $st) / 1000) . "s\n";
本机执行结果
3: 内存使用、耗时测试:
|--- 3.2: 使用 yield, 返回长度为 10240*10k 的大数组
|------- 峰值内存:2.0000MB
|------- 所需时间:0.0002s
其中两个函数返回的值,都可以使用 foreach
来循环使用。
为什么有这么大差异?其实很简单明了了,因为带 yield
表达式的函数被调用的时候,实际根本没有执行函数的任何代码,就算里面有再耗时、耗内存的逻辑,它只是返回了一个可中断的函数
,即生成器类- Generator
,只有在 foreach
的时候,才会真正执行函数逻辑代码,它只是把耗时操作推迟了而已。当你的主程序代码需要大量的数据,比如数据库查询的数十万条记录,无需在调用函数时,立即全部返回,而是在实际使用的时候(也只有-foreach
)才会循环执行。正常函数调用时立即执行
,而 yield
的函数调用完不执行,等到用的时候才执行,从而解决大参数传值带来的内存和性能消耗。
它但局限性也很明显:
- 是如果两者都加上
foreach
,也就是实际使用的逻辑,那么他们的执行时间基本一致,yield
也只剩下节省内存一个好处了; - 如果多次使用函数返回的数据时,
_has_yield()
返回的生成器类就不能使用多次,多次使用只能多次调用_has_yield()
, 每次调用就是生成一个新的生成器,所以多次的情况,yield
只能更加耗时,因为要多次实际去执行业务函数_has_yield()
额外增加很多负担,比如数据库查询,这种情况就是致命的。
但如果只是为了业务代码解耦,封装很多通用的业务函数,比如大量数据查询相关逻辑,使用函数返回值的方式,确实会消耗太多的内存,这个时候使用 yield
还是非常方便的。明白了 php
使用 yield
的原理,再去看鸟哥博客中关于任务托管的代码,理解起来就容易很多了