转载请注明文章出处: https://itlanyan.com/php-review-coroutine

PHP回顾系列目录

PHP自5.5起引入了生成器(Generator),基于其可实现协程编程。本文先回顾生成器,然后过渡到协程编程。

yield与生成器

生成器

生成器是一种数据类型,实现了iterator接口。不能通过new得到生成器实例,也没有获取生成器实例的静态方法。得到生成器实例的唯一办法是调用生成器函数(包含yield关键字的函数)。调用生成器函数直接返回一个生成器对象,生成器运行时函数内的代码才开始执行。

先上代码直观感受一下yield与生成器:

# generator1.php
function foo() {
    exit('exit script when generator runs.');
    yield;
}

$gen = foo();
var_dump($gen);
$gen->current();

echo 'unreachable code!';

# 执行结果
object(Generator)#1 (0) {
}
exit script when generator runs.

foo函数包含yield关键字,变身为生成器函数。调用foo不会执行函数体中的任何代码,而是返回一个生成器实例。生成器运行后,foo函数内的代码执行,脚本结束。

如其名,生成器可以用来生成数据。只是其生成数据的方式与其他函数不一样:生成器通过yield返回数据,而非return; yield返回数据后,生成器函数不会销毁,只是暂停运行,未来可以从暂停处恢复运行;生成器运行一次,(只)返回一个数据,多次运行就返回多个数据;不调用生成器获取数据,生成器内的代码就躺着不动,所谓动次打次,说的就是生成器生成数据的样子。

生成器实现了迭代器接口,获取生成器数据可以用foreach循环或手工current/next/valid。如下代码演示数据生成和遍历:

# generator2.php
function foo() {
  # 返回键值对数据
  yield "key1" => "value1";
  $count = 0;
  while ($count < 5) {
    # 返回值,key自动生成
    yield $count;
    ++ $count;
  }
  # 不返回值,相当于返回null
  yield;
}

# 手动获取生成器数据
$gen = foo();
while ($gen->valid()) {
  fwrite(STDOUT, "key:{$gen->key()}, value:{$gen->current()}\n");
  $gen->next();
}

# foreach 遍历数据
fwrite(STDOUT, "\ndata from foreach\n");
foreach (foo() as $key => $value) {
    fwrite(STDOUT, "key:$key, value:$value\n");
}

yield

yield关键字是生成器的核心,其让普通函数异化(进化)为生成器函数。yield有“让出”的意思,程序执行到yield语句会暂停执行,让出CPU并将控制权返回到调用者,下次执行时从中断点继续执行。控制权返回到调用者时,yield语句可以携带值返回给调用方。generator2.php脚本演示了yield返回值的三种形式:

  1. yield $key => $value: 返回数据的key和value;
  2. yield $value: 返回数据,key由系统分配;
  3. yield: 返回null值,key由系统分配;

yield让函数可以随时暂停、继续执行,并返回数据给调用方。如果继续执行时需要外部数据,这个工作由生成器的send函数提供:出现在yield左边等号的变量会接收send传来的值。看一个常见的send函数使用样例:

function logger(string $filename) {
  $fd = fopen($filename, 'w+');
  while($msg = yield) {
    fwrite($fd, date('Y-m-d H:i:s') . ':' . $msg . PHP_EOL);
  }
  fclose($fd);
}

$logger = logger('log.txt');
$logger->send('program starts!');
// do some thing
$logger->send('program ends!');

send让生成器之间和外部有双向数据通信的能力:yield返回数据;send提供继续运行的支撑数据。由于send让生成器继续执行,这个行为与迭代器的next接口类似,next相当于send(null)

其他

  1. $string = yield $data;的表达式在PHP7前不合法,需要加括号:$string = (yield $data);
  2. PHP5生成器函数不能return值,PHP7后可以return值,并通过生成器的getReturn获取返回的值。详情参考返回值的RFC:https://wiki.php.net/rfc/generator-return-expressions
  3. PHP7新增了yield from语法,实现了生成器委托,详情请参考其RFC: https://wiki.php.net/rfc/generator-delegation
  4. 生成器是单向迭代器,开动后不能调用rewind

总结

相对于其他迭代器,生成器具有性能开销小、编码容易的特点。其作用主要体现在三个方面:

  1. 数据生成(生产者),通过yield返回数据;
  2. 数据消费(消费者),消费send传来的数据;
  3. 实现协程。

关于PHP中的生成器及基本用法,建议看看 2gua 大佬的博文:PHP之生成器,生动有趣且易懂。

PHP协程编程

协程(coroutine)是随时可中断、恢复执行的子程序,yield关键字让函数拥有这种能力,所以可以用于协程编程。

进程、线程和协程

线程归属于进程,一个进程可有多个线程。进程是计算机分配资源的最小单位,线程是计算机调度执行的最小单位。进程和线程均由操作系统调度。

协程可以看成“用户态的线程”,需要用户程序实现调度。线程和进程由操作系统调度“抢占式”交替运行,协程主动让出CPU“协商式”交替运行。协程十分的轻量,协程切换不涉及线程切换,执行效率高,数目越多,越能体现协程的优势。

生成器和协程

生成器实现的协程属于无栈协程(stackless coroutine),即生成器函数只有函数帧,运行时附加到调用方的栈上执行。不同于功能强大的有栈协程(stackful coroutine),生成器暂停后无法控制程序走向,只能将控制权被动的归还调用者;生成器只能中断自身,不能中断整个协程。当然,生成器的好处便是效率高(暂停时只需保存程序计数器即可),实现简单。

PHP协程编程

说到PHP中的协程编程,相信大部分人已经看过鸟哥转载(翻译)的这篇博文:在PHP中使用协程实现多任务调度。原文作者 nikic 是PHP的核心开发者,生成器功能的倡议者和实现人。想深入了解生成器及基于其的协程编程,nikic关于生成器的RFC和鸟哥网站上的文章必读。

nikic的文章,生成器部分好懂,看完后用yield写个xrange类似函数肯定毫无压力。为什么一进入协程,就有点懵逼呢?

懵逼评论

先看看基于生成器的协程工作方式:协程协作式工作,即协程之间通过主动让出CPU达到多任务交替运行(即并发多任务,但不是并行);一个生成器可看成一个协程,执行到yield语句,让出CPU控制权回到调用方,调用方继续执行其他协程或其他代码。

再来看鸟哥博客理解的难点何在。协程非常轻量,一个系统中可以同时存在成千上万个协程(生成器)。而操作系统不会对协程调度,安排协程执行的工作就落到开发者身上。部分人看不懂鸟哥文章的协程部分,是因为里面说协程编程少(写协程主要就是写生成器函数),而是花笔墨实现了一个协程的调度器(scheduler或者kernel):模拟了操作系统,对所有协程进行公平调度。PHP开发一般的思维是:我写了这些代码,PHP引擎会调用我这些代码得到预期结果。而协程编程不仅要写干活的代码,还要写指导这些代码什么时候干活的代码。没有很好的把握作者的思维,理解起来自然会难一些。需要自行调度,这是生成器协程相对于原生协程(async/await形式)的一个缺点。

知道了协程是怎么回事,那么它能用来干什么?协程自行让出CPU来协作高效利用CPU,让出的时机当然应该是程序阻塞时。什么地方会让程序阻塞呢?用户态的代码鲜有阻塞,阻塞主要是系统调用。而系统调用的大头是IO,所以协程的主要应用场景在网络编程。为了让程序高性能、高并发,程序应该异步执行不能阻塞。既然异步执行,就需要通知和回调,写回调函数避免不了“回调地狱(callback hell)”的问题:代码可读性差,程序执行流程散落在层层回调函数中等。解决回调地狱的方式主要有两种:Promise和协程。协程能以同步的方式编写代码,在高性能网络编程(IO密集型)中是推荐的。

再回过头看PHP中的协程编程。PHP中基于生成器实现实现协程编程,优先推荐使用RecoilPHPAmp等协程框架。这些框架已经写好了调度器,在其上开发直接写生成器函数,内核会自动调度执行(想让一个函数以协程方式调度执行,在函数体内加上yield即可)。如果不想用yield方式进行协程编程,推荐swoole或其衍生框架,能做到类似golang的协程编程体验,又能享受PHP的开发效率。

如果想用原生态的做PHP协程编程,类似鸟哥博客中的调度器必不可少。调度器调度协程执行,协程中断后控制权又回到调度器中。所以调度器应该总是在主(事件)循环中,即CPU不在执行协程,就应当在执行调度器的代码。无协程运行时,调度器应当自我阻塞避免消耗CPU(鸟哥博客中使用了内置的select系统调用),等待事件到来再执行相应的协程。程序运行期间,除了调度器阻塞,协程在运行过程中不应该调用阻塞API。

总结

在协程编程中,yield的主要作用是将控制权转让,无需纠结于其返回值(基本上yield返回的值会在下次执行时直接send过来)。重点应当关注控制权转让的时机,以及协程的运作方式。

另外需要说明一点,协程和异步没有多大关系,还要看运行环境支撑。常规的PHP运行环境,即使用了promise/coroutine,也还是同步阻塞的。再牛逼的协程框架,sleep一下也不好使了。作为类比,即使JavaScript不使用promise/async这些技术,也是异步非阻塞的。

通过生成器和Promise,能实现类似于await的协程编程,相关代码在Github上很多,本文不再给出。

总结

本文先介绍了生成器的概念,重点是yield的用法及生成器的接口。协程部分则简要说了协程的原理,以及PHP协程编程中应当注意的事项。

感谢阅读,欢迎指正!

参考

  1. http://php.net/manual/zh/language.generators.php
  2. http://php.net/manual/zh/class.generator.php
  3. https://wiki.php.net/rfc/generators
  4. https://wiki.php.net/rfc/generator-return-expressions
  5. https://zhuanlan.zhihu.com/p/21329131
  6. http://www.laruence.com/2015/05/28/3038.html
  7. https://medium.com/async-php/co-operative-php-multitasking-ce4ef52858a0
  8. https://blog.kghost.info/2011/11/15/abstract-control-1-stackless-coroutine/