naturalCloud naturalCloud

记录精彩的程序人生

目录
php之yield
/      

php之yield

Iterator 接口

实现了 Iterator 接口的对象可以使用 foreach 结构来访问,Iterator 接口所提供了 5 个方法:

1<?php
2Iterator extends Traversable {
3    /* Methods */
4    abstract public mixed current ( void )   //返回当前位置的元素
5    abstract public scalar key ( void )      //返回当前元素对应的key
6    abstract public void next ( void )       //移到指向下一个元素的位置
7    abstract public void rewind ( void )     //倒回到指向第一个元素的位置
8    abstract public boolean valid ( void )   //判断当前位置是否有效
9}

在使用 foreach 遍历实现这个接口的对象时,这里的某些方法会被隐式地调用。其中 next() 方法是控制元素移动的,current() 可以获取当前位置的元素。

yield

php 中,yield 关键字只能在函数中使用,使用了 yield 关键字的函数都会返回一个 Generator 对象。我们可以直接使用 foreach 结构遍历这个 Generato r 对象:

上面的代码输出:

11
22
33

Generator 对象的中可迭代的元素就是所有 yield 语句返回的值的集合,在这个示例中是 [1,2,3]
看起来跟数组很像,但它跟数组有本质的区别,遍历 Generator 对象的每次迭代都只会执行前一次 yield 语句之后的代码,
而且碰到 yield 语句就会返回一个值,相当于从 Generator 对象中返回,这有点像挂起一个进程(线程)的执行,然后又启动它继续执行,周而复始直到进程(线程)执行中止。

 1<?php
 2
 3function foo()
 4{
 5    $end = 'end';
 6    echo "break point 1\n";
 7    yield 1;
 8    echo "break point 2\n";
 9    yield 2;
10    echo "break point 3\n";
11    yield 3;
12    echo $end;
13}
14while ($foo->valid()) {
15    echo $foo->current() . "\n";
16    $foo->next();
17}

输出:

1
2break point 1
31
4break point 2
52
6break point 3
73
8end

send 方法

这里的 yield 相当于一个表达式,它需要跟 Generator 对象中的 send 方法配合使用。
send 方法接收一个参数,它会将这个参数的值传递给 Generator 对象并作为当前 yield 表达式的结果,同时还会恢复 Generator 对象的迭代:

 1<?php
 2function gen() {
 3    $ret = yield 'yield1';
 4    var_dump($ret);
 5    $ret = yield 'yield2';
 6    var_dump($ret);
 7}
 8$g = gen();
 9var_dump($g->current());
10var_dump($g->send('ret1'));
11var_dump($g->send('ret2'));

输出如下:

1string(6) "yield1"
2string(4) "ret1"
3string(6) "yield2"
4string(4) "ret2"
5NULL

下面我们看一个通过 yield 输出 fibonacci 数列的示例:

 1<?php
 2function fibonacci()
 3{
 4    $a = 0;
 5    $b = 1;
 6    $count = 1;
 7    while(true) {
 8 
 9        $tmp = $a;
10        $a = $b;
11        $b = $tmp + $b;
12        $commend = yield [$count++, $b];
13 
14        if('stop' === $commend) {
15            break;
16        }
17    }
18}
19 
20$fibonacci = fibonacci();
21 
22while($fibonacci->valid()) {
23 
24    $current = $fibonacci->current();
25    $fibonacci->next();
26 
27    echo $current[1]."\n";
28    // 输出第20个数时,发出命令停止输出
29    if(20 === $current[0]) {
30        $fibonacci->send('stop');
31        echo "end";
32    }
33}

该方法除了一次返回 fibonacci 数之外, 还有一个功能: 当接受到外部的 stop 信息后,会跳出循环,终止 yield 的输出:

 11
 22
 33
 45
 58
 613
 721
 834
 955
1089
11144
12233
13377
14610
15987
161597
172584
184181
196765
2010946
21end

这与调用函数的体验是非常不同的,调用函数我们通常只能通过入参来影响函数的运行,而对于一个使用 yield 的生成的 Generator 对象,我们可以通过与之通信的方式随时干涉内部代码的运行!

yield 有什么用?

使用更小的内存遍历集合
在我们编写某些脚本时,可能会需要一次从数据库取出大量的数据,通常我们会一次获取所有数据,然后再遍历处理。这种方式会把所有的数据全部加载到内存中,产生不必要的资源浪费;如果使用 yield,遍历 Generator 对象,仅在需要的时候才获取数据,可以极大地减小内存开销。

我们通过一个示例来比较这两种方式地差异:这里我构造了一张有 50 万条记录的数据表,分别通过上面提到两种方式来遍历数据。

 1<?php
 2
 3$pdo = new PDO($dsn, $username, $password);
 4  
 5$t1 = microtime(true);
 6 
 7$result = $pdo->query("select * from foo");
 8 
 9$data = $result->fetchAll();    // 一次获取所有数据
10 
11foreach ($data as $row) {
12 
13    var_dump($row);
14}
15 
16$t2 = microtime(true);
17 
18echo '耗时' . round($t2 - $t1, 2) . '秒'."\n";
19echo '内存: ' . memory_get_usage() / 1024 . "K\n";
20  
21  
22$pdo = new PDO($dsn, $username, $password);
23 
24$t1 = microtime(true);
25 
26function getAll($pdo)
27{
28    $result = $pdo->query("select * from foo");
29     
30    while ($row = $result->fetch()) {
31        yield $row;
32    }
33}
34 
35// 遍历生成器逐条获取数据
36foreach (getAll($pdo) as $row) {
37 
38    var_dump($row);
39}
40 
41$t2 = microtime(true);
42 
43echo '耗时' . round($t2 - $t1, 2) . '秒'."\n";
44echo '内存: ' . memory_get_usage() / 1024 . "K\n";

使用一般方式遍历:

耗时 46.15
内存: 313652.9765625K

使用 yield 的 Generator 对象遍历:

耗时 48.81
内存: 392.234375K

可以看出,使用 Generator 对象遍历占用的内存要小得多。

PHP 协程

通过 Generatorsend() 方法可以外部过程实现与任务内部的通信,这为协程开发提供了可能。

下面我们看一个简单的协程的实现:

首先需要对 Generato 生成器做一个封装:

 1<?php
 2class Task {
 3    protected $taskId;              // 任务Id
 4    protected $coroutine;           // 任务迭代器
 5    protected $sendValue = null;    // 下一次向任务迭代器发送的消息
 6    protected $beforeFirstYield = true; //首次迭代标识
 7 
 8    public function __construct($taskId, Generator $coroutine) {
 9        $this->taskId = $taskId;
10        $this->coroutine = $coroutine;
11    }
12 
13    public function getTaskId() {
14        return $this->taskId;
15    }
16 
17    public function setSendValue($sendValue) {
18        $this->sendValue = $sendValue;
19    }
20 
21    public function run() {
22        if ($this->beforeFirstYield) {
23            $this->beforeFirstYield = false;
24            return $this->coroutine->current();
25        } else {
26            $retval = $this->coroutine->send($this->sendValue);
27            $this->sendValue = null;
28            return $retval;
29        }
30    }
31 
32    public function isFinished() {
33        return !$this->coroutine->valid();
34    }
35}

这里我们简单说一下 run() 方法:它的主要功能是调用 Generator 里的 send() 方法推送信息给生成器,接收并返回生成器的返回值;
使用 $beforeFirstYield 属性是为了避免直接调用生成器 send() 方法会丢失首个 yield 返回值的问题;

接着我们要实现一个任务调度器来规划任务:

 1<?php
 2/**
 3 * 调度器
 4 * Class Scheduler
 5 */
 6class Scheduler {
 7    protected $maxTaskId = 0;   // 任务自增Id
 8    protected $taskMap = [];    // taskId => task
 9    protected $taskQueue;       // 任务队列
10 
11    public function __construct() {
12        $this->taskQueue = new SplQueue();
13    }
14 
15    public function newTask(Generator $coroutine) {
16        $tid = ++$this->maxTaskId;
17        $task = new Task($tid, $coroutine);
18        $this->taskMap[$tid] = $task;
19        $this->schedule($task);
20        return $tid;
21    }
22 
23    public function schedule(Task $task) {
24        $this->taskQueue->enqueue($task);
25    }
26 
27    public function run() {
28        while (!$this->taskQueue->isEmpty()) {
29            $task = $this->taskQueue->dequeue();  // 任务出队
30            $task->run();                            // 调用run()方法执行任务片段
31 
32            if ($task->isFinished()) {               // 任务执行完毕,从map中删除
33                unset($this->taskMap[$task->getTaskId()]);
34            } else {
35                $this->schedule($task);              // 任务未执行完,继续入队
36            }
37        }
38    }
39}

我们这里任务调度的逻辑很简单,从任务调度队列中顺序取出任务并执行任务,如果任务执行完毕,就从 map 中移除任务,否则继续将任务入队。

接下来注册具体的任务逻辑:

 1<?php
 2function task1() {
 3    for ($i = 1; $i <= 10; ++$i) {
 4        echo "This is task 1 iteration $i.\n";
 5        yield;
 6    }
 7}
 8 
 9function task2() {
10    for ($i = 1; $i <= 5; ++$i) {
11        echo "This is task 2 iteration $i.\n";
12        yield;
13    }
14}
15 
16$scheduler = new Scheduler;
17 
18$scheduler->newTask(task1());
19$scheduler->newTask(task2());
20 
21$scheduler->run();

执行结果如下:

 1This is task 1 iteration 1.
 2This is task 2 iteration 1.
 3This is task 1 iteration 2.
 4This is task 2 iteration 2.
 5This is task 1 iteration 3.
 6This is task 2 iteration 3.
 7This is task 1 iteration 4.
 8This is task 2 iteration 4.
 9This is task 1 iteration 5.
10This is task 2 iteration 5.
11This is task 1 iteration 6.
12This is task 1 iteration 7.
13This is task 1 iteration 8.
14This is task 1 iteration 9.
15This is task 1 iteration 10.

总结
如果在函数中使用 yield 关键字,那么这个函数的返回值会变成一个 Generator 对象。
通过 current()send() 方法可以运行函数定义的代码片段,代码片段会在遇到 yield 关键字时中断代码运行,保留上下文,并返回 yield 后声明的返回值。
使用 send() 方法可以向 Generator 对象传递信息。
对于某些顺序遍历集合的场景,使用 yield 定义的 Generator 生成器能节约内存开销。
通过 Generator 生成器可以实现 php 协程,不过具体的调度逻辑与通信逻辑需要用户自行实现。


标题:php之yield
作者:naturalCloud
地址:https://yunqiblog.cn/articles/2019/11/06/1573028488482.html