PHP之yield

Iterator接口

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

Iterator extends Traversable {
    /* Methods */
    abstract public mixed current ( void )   //返回当前位置的元素
    abstract public scalar key ( void )      //返回当前元素对应的key
    abstract public void next ( void )       //移到指向下一个元素的位置
    abstract public void rewind ( void )     //倒回到指向第一个元素的位置
    abstract public boolean valid ( void )   //判断当前位置是否有效
}

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

yield

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

function foo()
{
    yield 1;
    yield 2;
    yield 3;
 
    return 'return result';
}
 
$foo = foo();
 
foreach ($foo as $val) {
 
    echo "$val \n";
}

上面的代码输出:

1
2
3

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

function foo()
{
    $end = 'end';
    echo "break point 1\n";
    yield 1;
    echo "break point 2\n";
    yield 2;
    echo "break point 3\n";
    yield 3;
    echo $end;
}
while ($foo->valid()) {
    echo $foo->current() . "\n";
    $foo->next();
}

输出:


break point 1
1
break point 2
2
break point 3
3
end

send方法

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

function gen() {
    $ret = yield 'yield1';
    var_dump($ret);
    $ret = yield 'yield2';
    var_dump($ret);
}
$g = gen();
var_dump($g->current());
var_dump($g->send('ret1'));
var_dump($g->send('ret2'));

输出如下:

string(6) "yield1"
string(4) "ret1"
string(6) "yield2"
string(4) "ret2"
NULL

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

function fibonacci()
{
    $a = 0;
    $b = 1;
    $count = 1;
    while(true) {
 
        $tmp = $a;
        $a = $b;
        $b = $tmp + $b;
        $commend = yield [$count++, $b];
 
        if('stop' === $commend) {
            break;
        }
    }
}
 
$fibonacci = fibonacci();
 
while($fibonacci->valid()) {
 
    $current = $fibonacci->current();
    $fibonacci->next();
 
    echo $current[1]."\n";
    // 输出第20个数时,发出命令停止输出
    if(20 === $current[0]) {
        $fibonacci->send('stop');
        echo "end";
    }
}

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

1
2
3
5
8
13
21
34
55
89
144
233
377
610
987
1597
2584
4181
6765
10946
end

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

yield有什么用?

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

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

$pdo = new PDO($dsn, $username, $password);
  
$t1 = microtime(true);
 
$result = $pdo->query("select * from foo");
 
$data = $result->fetchAll();    // 一次获取所有数据
 
foreach ($data as $row) {
 
    var_dump($row);
}
 
$t2 = microtime(true);
 
echo '耗时' . round($t2 - $t1, 2) . '秒'."\n";
echo '内存: ' . memory_get_usage() / 1024 . "K\n";
  
  
$pdo = new PDO($dsn, $username, $password);
 
$t1 = microtime(true);
 
function getAll($pdo)
{
    $result = $pdo->query("select * from foo");
     
    while ($row = $result->fetch()) {
        yield $row;
    }
}
 
// 遍历生成器逐条获取数据
foreach (getAll($pdo) as $row) {
 
    var_dump($row);
}
 
$t2 = microtime(true);
 
echo '耗时' . round($t2 - $t1, 2) . '秒'."\n";
echo '内存: ' . memory_get_usage() / 1024 . "K\n";

使用一般方式遍历:

耗时46.15
内存: 313652.9765625K

使用yield的Generator对象遍历:

耗时48.81
内存: 392.234375K

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

PHP协程

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

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

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

class Task {
    protected $taskId;              // 任务Id
    protected $coroutine;           // 任务迭代器
    protected $sendValue = null;    // 下一次向任务迭代器发送的消息
    protected $beforeFirstYield = true; //首次迭代标识
 
    public function __construct($taskId, Generator $coroutine) {
        $this->taskId = $taskId;
        $this->coroutine = $coroutine;
    }
 
    public function getTaskId() {
        return $this->taskId;
    }
 
    public function setSendValue($sendValue) {
        $this->sendValue = $sendValue;
    }
 
    public function run() {
        if ($this->beforeFirstYield) {
            $this->beforeFirstYield = false;
            return $this->coroutine->current();
        } else {
            $retval = $this->coroutine->send($this->sendValue);
            $this->sendValue = null;
            return $retval;
        }
    }
 
    public function isFinished() {
        return !$this->coroutine->valid();
    }
}

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

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


/**
 * 调度器
 * Class Scheduler
 */
class Scheduler {
    protected $maxTaskId = 0;   // 任务自增Id
    protected $taskMap = [];    // taskId => task
    protected $taskQueue;       // 任务队列
 
    public function __construct() {
        $this->taskQueue = new SplQueue();
    }
 
    public function newTask(Generator $coroutine) {
        $tid = ++$this->maxTaskId;
        $task = new Task($tid, $coroutine);
        $this->taskMap[$tid] = $task;
        $this->schedule($task);
        return $tid;
    }
 
    public function schedule(Task $task) {
        $this->taskQueue->enqueue($task);
    }
 
    public function run() {
        while (!$this->taskQueue->isEmpty()) {
            $task = $this->taskQueue->dequeue();  // 任务出队
            $task->run();                            // 调用run()方法执行任务片段
 
            if ($task->isFinished()) {               // 任务执行完毕,从map中删除
                unset($this->taskMap[$task->getTaskId()]);
            } else {
                $this->schedule($task);              // 任务未执行完,继续入队
            }
        }
    }
}

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

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

function task1() {
    for ($i = 1; $i <= 10; ++$i) {
        echo "This is task 1 iteration $i.\n";
        yield;
    }
}
 
function task2() {
    for ($i = 1; $i <= 5; ++$i) {
        echo "This is task 2 iteration $i.\n";
        yield;
    }
}
 
$scheduler = new Scheduler;
 
$scheduler->newTask(task1());
$scheduler->newTask(task2());
 
$scheduler->run();

执行结果如下:

This is task 1 iteration 1.
This is task 2 iteration 1.
This is task 1 iteration 2.
This is task 2 iteration 2.
This is task 1 iteration 3.
This is task 2 iteration 3.
This is task 1 iteration 4.
This is task 2 iteration 4.
This is task 1 iteration 5.
This is task 2 iteration 5.
This is task 1 iteration 6.
This is task 1 iteration 7.
This is task 1 iteration 8.
This is task 1 iteration 9.
This is task 1 iteration 10.

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

远方的代码
请先登录后发表评论
  • 最新评论
  • 总共0条评论