Varobj

2020-12-03

搞懂 yield 表达式



作用

yield 表达式常用在

  1. 频繁通过函数的返回值传递大数组,消耗过多内存的时候

  2. 异步执行代码(协程)。

也可以直接参考 鸟哥文章 - 在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

可以验证以下结论:

  1. yield 后面可以跟具体的值,也可以什么都不加,如 _test 函数的第 1、2 行。
  2. _test 函数第 3 行,在 $t = _test() 之后没有输出,说明没有执行到这。
  3. 函数包含 yield 所以返回的是 Generator,而不是 _test 函数的第 4 行返回的字符串 foo bar
  4. Generator 类可以循环,函数中包含 2 个 yield 表达式,循环输出值就有 2 个,很像函数返回来包含 2 个值的数组。
  5. foreach 的结果是 yield 表达式之后的值,如果没有就是 NULL ,有的话就是对应的变量或者常量的值。
  6. _test 函数的第 3 行内容 b 是在最后输出,如果第 3 行放到函数的第 1 行,或者两个 yield 直接,那么输出次序呢?

可以调整下 echo 的位置,看下输出位置:

  1. 如果调整到函数第一行,会在 foreach 开始前输出。
  2. 如果放到两个 yield 中间,那么就是两次输出 foreach—* 结果中间输出。 经过实验说明了什么?验证了鸟哥博客中说的那句话,那就是包含 yield 的函数在被调用后,里面代码一行都没有执行,就会立即返回了一个 Generator 类,也叫 生成器类 ,它其实是 “一种可中断的函数”,里面的每一个 yield 就是一个中断点。中断什么时候触发呢?那就是 foreach 语句循环的时候。

Generator 类

Generator 类是实现了 Iterator 接口的一个 final 类,所以它可以在 foreach(data as k => v) {} 等循环结构中使用。继承 Iterator 接口需要实现 5 个函数:

  1. rewind 一般用来把游标初始化成最开始的位置,也就是下标为 0 的地方
  2. valid 用来判断是否有效,如果有效继续循环,无效结束循环
  3. current 获取当前位置的值,并返回当前的值,也就是返回 foreach 中的 v
  4. key 获取当前的下标,也就是返回 foreach 中的 k,然后结构中就可以使用 kv
  5. next 游标移动到一个位置

foreach 一个实现了 Iterator 接口的类时,会依次执行这五个函数。次序分别是下面三种之一

测试代码

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 只能触发一次,同时也证明了,最好不要 sendforeach 同时使用。同时,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 的函数调用完不执行,等到用的时候才执行,从而解决大参数传值带来的内存和性能消耗。

它但局限性也很明显:

  1. 是如果两者都加上 foreach,也就是实际使用的逻辑,那么他们的执行时间基本一致,yield 也只剩下节省内存一个好处了;
  2. 如果多次使用函数返回的数据时,_has_yield() 返回的生成器类就不能使用多次,多次使用只能多次调用 _has_yield(), 每次调用就是生成一个新的生成器,所以多次的情况,yield 只能更加耗时,因为要多次实际去执行业务函数 _has_yield() 额外增加很多负担,比如数据库查询,这种情况就是致命的。

但如果只是为了业务代码解耦,封装很多通用的业务函数,比如大量数据查询相关逻辑,使用函数返回值的方式,确实会消耗太多的内存,这个时候使用 yield 还是非常方便的。明白了 php 使用 yield 的原理,再去看鸟哥博客中关于任务托管的代码,理解起来就容易很多了