40

PHP回顾之协程

 5 years ago
source link: https://www.tlanyan.me/php-review-coroutine/?amp%3Butm_medium=referral
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

转载请注明文章出处: https://tlanyan.me/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之生成器 ,生动有趣且易懂。

协程编程

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

进程、线程和协程

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

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

生成器和协程

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

协程编程

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

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

36beMzR.png!web

先看看基于生成器的协程工作方式:协程协作式工作,即协程之间通过主动让出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/

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK