PHP生成器的操作yield功能优化

原创
小哥 3年前 (2022-11-05) 阅读数 618 #js教程
文章标签 JavaScript

什么是生成器?

听着高大上的名字,感觉像是创造什么东西的一个功能,实际上,生成器是一个用于迭代的迭代器。它提供了一种更容易的方式来实现简单的对象迭代,相比较定义类实现Iterator接口的方式,性能开销和复杂性大大降低。

说了半天不如直接看看代码更直观。

function test1()
{
    for ($i = 0; $i < 3; $i++) {
        yield $i + 1;
    }
    yield 1000;
    yield 1001;
}

foreach (test1() as $t) {
    echo $t, PHP_EOL;
}

// 1
// 2
// 3
// 1000
// 1001

就是这么简单的一段代码。首先,生成器必须在方法中并使用 yield 关键字;其次,每一个 yield 可以看作是一次 return ;最后,外部循环时,一次循环取一个 yield 的返回值。在这个例子,循环三次返回了1、2、3这三个数字。然后在循环外部又写了两行 yield 分别输出了1000和1001。因此,外部的 foreach 一共循环输出了五次。

很神奇吧,明明是一个方法,为什么能够循环它而且还是很奇怪的一种返回循环体的格式。我们直接打印这个 test() 方法看看打印的是什么:

// 是一个生成器对象
var_dump(test1());

// Generator Object
// (
// )

当使用了 yield 进行内容返回后,返回的是一个 Generator 对象。这个对象就叫作生成器对象,它不能直接被 new 实例化,只能通过生成器函数这种方式返回。这个类包含 current() 、 key() 等方法,而且最主要的这个类实现了 Iterator 接口,所以,它就是一个特殊的迭代器类。

Generator implements Iterator {
    /* 方法 */
    public current ( void ) : mixed
    public key ( void ) : mixed
    public next ( void ) : void
    public rewind ( void ) : void
    public send ( mixed $value ) : mixed
    public throw ( Exception $exception ) : void
    public valid ( void ) : bool
    public __wakeup ( void ) : void
}

生成器有什么用?

搞了半天不就是个迭代器嘛?搞这么麻烦干嘛,直接用迭代器或者在方法中直接返回一个数组不就好了吗?没错,正常情况下真的没有这么麻烦,但是如果是在数据量特别大的情况下,这个生成器就能发挥它的强大威力了。生成器最最强大的部分就在于,它不需要一个数组或者任何的数据结构来保存这一系列数据。每次迭代都是代码执行到 yield 时动态返回的。因此,生成器能够极大的节约内存。

// 内存占用测试
$start_time = microtime(true);
function test2($clear = false)
{
    $arr = [];
    if($clear){
        $arr = null;
        return;
    }
    for ($i = 0; $i < 1000000; $i++) {
        $arr[] = $i + 1;
    }
    return $arr;
}
$array = test2();
foreach ($array as $val) {
}
$end_time = microtime(true);

echo "time: ", bcsub($end_time, $start_time, 4), PHP_EOL;
echo "memory (byte): ", memory_get_usage(true), PHP_EOL;

// time: 0.0513
// memory (byte): 35655680

$start_time = microtime(true);
function test3()
{
    for ($i = 0; $i < 1000000; $i++) {
        yield $i + 1;
    }
}
$array = test3();
foreach ($array as $val) {

}
$end_time = microtime(true);

echo "time: ", bcsub($end_time, $start_time, 4), PHP_EOL;
echo "memory (byte): ", memory_get_usage(true), PHP_EOL;

// time: 0.0517
// memory (byte): 2097152

上述代码只是简单的进行 1000000 个循环后获取结果,不过也可以直观地看出。使用生成器的版本仅仅消耗了 2M 的内存,而未使用生成器的版本则消耗了 35M 的内存,直接已经10多倍的差距了,而且越大的量差距超明显。因此,有大神将生成器说成是PHP中最被低估了的一个特性。

生成器的应用

接下来我们来看看生成器的一些基本的应用方式。

返回空值以及中断

生成器当然也可以返回空值,直接 yield; 不带任何值就可以返回一个空值了。而在方法中直接使用 return; 也可以用来中断生成器的继续执行。下面的代码我们在 $i = 4; 的时候返回的是个空值,也就是不会输出 5 (因为我们返回的是 $i + 1 )。然后在 $i == 7 的时候使用 return; 中断生成器的继续执行,也就是循环最多只会输出到 7 就结束了。

// 返回空值以及中断
function test4()
{
    for ($i = 0; $i < 10; $i++) {
        if ($i == 4) {
            yield; // 返回null值
        }
        if ($i == 7) {
            return; // 中断生成器执行
        }
        yield $i + 1;
    }
}

foreach (test4() as $t) {
    echo $t, PHP_EOL;
}

// 1
// 2
// 3
// 4

// 5
// 6
// 7

返回键值对形式

不要惊讶,生成器真的是可以返回键值对形式的可遍历对象供 foreach 使用的,而且语法非常好记:yield key => value; 是不是和数组项的定义形式一模一样,非常直观好理解。

function test5()
{
    for ($i = 0; $i < 10; $i++) {
        yield key. . $i => $i + 1;
    }
}

foreach (test5() as $k=>$t) {
    echo $k . : . $t, PHP_EOL;
}

// key.0:1
// key.1:2
// key.2:3
// key.3:4
// key.4:5
// key.5:6
// key.6:7
// key.7:8
// key.8:9
// key.9:10

外部传递数据

我们可以通过 Generator::send 方法来向生成器中传入一个值。传入的这个值将会被当做生成器当前 yield 的返回值。然后我们根据这个值可以做一些判断,比如根据外部条件中断生成器的执行。

function test6()
{
    for ($i = 0; $i < 10; $i++) {
        // 正常获取循环值,当外部send过来值后,yield获取到的就是外部传来的值了
        $data = (yield $i + 1);
        if($data == stop){
            return;
        }
    }
}
$t6 = test6();
foreach($t6 as $t){
    if($t == 3){
        $t6->send(stop);
    }
    echo $t, PHP_EOL;
}

// 1
// 2
// 3

上述代码理解起来可能比较绕,但是注意记住注释的那行话就行了(正常获取循环值,当外部send过来值后,yield获取到的就是外部传来的值了)。另外,变量获取 yield 的值,必须要用括号括起来。

yield from 语法

yield from 语法其实就是指的从另一个可迭代对象中一个一个的获取数据并形成生成器返回。直接看代码。

function test7()
{
    yield from [1, 2, 3, 4];
    yield from new ArrayIterator([5, 6]);
    yield from test1();
}
foreach (test7() as $t) {
    echo test7:, $t, PHP_EOL;
}

// test7:1
// test7:2
// test7:3
// test7:4
// test7:5
// test7:6
// test7:1
// test7:2
// test7:3
// test7:1000

在 test7() 方法中,我们使用 yield from 分别从普通数组、迭代器对象、另一个生成器中获取数据并做为当前生成器的内容进行返回。

小惊喜

生成器可以用count获取数量吗?

抱歉,生成器是不能用count来获取它的数量的。

$c = count(test1()); // Warning: count(): Parameter must be an array or an object that implements Countable
// echo $c, PHP_EOL;

使用 count 来获取生成器的数量将直接报 Warning 警告。直接输出将会一直显示是 1 ,因为 count 的特性(强制转换成数组都会显示 1 )。

使用生产器来获取斐波那契数列

// 利用生成器生成斐波那契数列
function fibonacci($item)
{
    $a = 0;
    $b = 1;
    for ($i = 0; $i < $item; $i++) {
        yield $a;
        $a = $b - $a;
        $b = $a + $b;
    }
}

$fibo = fibonacci(10);
foreach ($fibo as $value) {
    echo "$value

"; }

这段代码就不多解释了,非常直观的一段代码了。

总结

生成器绝对是PHP中的一个隐藏的宝藏,不仅是对于内存节约来说,而且语法其实也非常的简洁明了。我们不需要在方法内部再多定义一个数组去存储返回值,直接 yield 一项一项的返回就可以了。在实际的项目中完全值得尝试一把,但是尝试完了别忘了和小伙伴们分享,大部分人可能真的没有接触过这个特性哦!!

/****?

开篇

刚开始接触 PHPyield 的时候,感觉, yield 是什么黑科技,百度一下: yield ——协程,生成器。很多文章都在讲 IteratorGenerater , 蛤~,这东西是 PHP 迭代器的一个补充。再翻几页,就是 Go 协程 。我出于好奇点开看了下 Go 协程 , 里面都是 并发线程管道通讯 这类字眼,wc,nb, 这tm才是黑科技啊,再回来看 PHP ,分分钟想转 Go

yield 语法加入 PHP

yield 语法是在版本5.5加入 PHP 的,配合迭代器使用,功能上就是 流程控制 代码,和 gotoreturn 类似。

以下就是官方提供的 yield 小例子,通过执行结果,我们可分析当代码执行到 yield $i 时,他会进行 return $i , 待 echo "$value " 后, goto for ($i = 1; $i <= 3; $i++) { , 对!PHP 的 yield 就是一个能出能进的语法。在z代码中七进七出,把 $i 平平安安得送了出来。

<?phpfunction gen_one_to_three() { for ($i = 1; $i <= 7; $i++) { //注意变量$i的值在不同的yield之间是保持传递的。 yield $i; }}$generator = gen_one_to_three();foreach ($generator as $value) { echo "$value\n";}// output12...67

我们遇到了什么问题

写代码就是解决问题。我们来看看他们遇到了什么问题:php官方呢,需要言简意赅地把yield介绍给大家。一部分网友呢,需要在有限的资源内完成大文件操作。而我们的鸟哥。面对的一群对当下yield的教程停留于初级而不满意的phper,就以一个任务调度器作为例子,给大家讲了一种 yield 高级用法。

php.net:生成器语法,
PHP如何读取大文件,
风雪之隅:在PHP中使用协程实现多任务调度.

提出问题,再用 yield 来解答,看到以上答案,我觉得呢,这PHP协程不过如此( _和 MARKDOWN_HASHcf0bd235c4e41923de8dcd77f845bf1dMARKDOWNHASH 相比 )。

有句话—— 一个好问题比答案更重要 ,目前广大网友还没有给yield提出更好,更困难的问题。

yield 这个进进出出的语法,很多举例都是再让yield做迭代器啊,或者利用低内存读取超大文本的 Excelcsv 什么的,再高级就是用它实现一个简单的任务调度器,并且这个调度器,一看代码都差不多。

我来出道题

正如一个好的问题,比答案更有价值

  1. 用PHP实现一个 Socket Server,他能接收请求,并返回Server的时间。

好,这是第一个问题,铺垫。 官方答案

  1. 在原来的代码上,我们加个需求,该Socket Server 处理请求时,依赖其他 Socket Server,还需要有 Client 功能。也就是他能接收请求,向其它Server发起请求。

这是第二个问题,也是铺垫。

  1. 原来的Socket Server同一时间只能服务一个客户,希望能实现一个 非阻塞I/O Socket Server, 这个 Server 内有 Socket Client 功能,支持 并发 处理收到的请求,和主动发起的请求。要求不用多线程,多进程。

这个问题,还是铺垫,这几个问题很干,大家可以想一想,2,3题的答案,都放在一个脚本里了:nio_server.php

以上这段代码,我列举了一个具体的业务,就是用户请求购物车加购动作, 而购物车服务呢,又需要和 产品服务,库存服务,优惠服务 交互,来验证加购动作可行性。有同步,异步方式请求,并做对比。

后续还有很多代码,我都放gitee链接了。使用方法,见readme.md

  1. 最后一个问题:在PHP中,用同步写代码,程序呢异步执行?需要怎么调整代码。

提示:这个和 PHPyield 语法有关。

再提示: yield 语法特征是什么, 进进出出 !

看着我们的代码, 同步, 异步,进进出出 你想到了什么?

看到代码,同步处理模式下,这三个函数 checkInventory checkProduct checkPromo 时,发起请求,并依次等待返回的结果,这三个函数执行后,再响应客户请求。

异步处理模式下,这三个函数发起请求完毕后,代码就跳出循环了,然后是在 select() 下的一个代码分支中接收请求, 并收集结果。每次收到结果后判断是否完成,完成则响应客户端。

那么能不能这样:在异步处理的流程中,当 Server 收到 自己发起的 client 有数据响应后,代码跳到 nio_server.php 的 247行呢,这样我们的收到请求校验相关的代码就能放到这里,编码能就是同步,容易理解。不然, client 的响应处理放在 280 行以后,不通过抓包,真的很难理解,执行了第 247 行代码后,紧接着是从 280 行开始的。

诶~这里是不是有 进进出出 那种感觉了~ 代码从 247 行出去,开始监听发出 Client 响应,收到返回数据,带着数据再回到 247 行,继续进行逻辑校验,综合结果后,再响应给客户端。

用yield来解决问题

基于 yield 实现的,同步编码, "异步"I/OSocket Server 就实现了。代码。

这里 “异步” 打了引号,大佬别扣这个字眼了。 该是 非阻塞I/O

不等大家的答案了,先上我的结果代码吧,代码呢都放在这个目录下了。

gitee https://gitee.com/xupaul/PHP-generator-yield-Demo/tree/master/yield-socket

运行测试代码

clone 代码到本地后,需要拉起4个 command 命令程序:

拉起3个第三方服务

启动一个处理耗时2s的库存服务$ php ./other_server.php 8081 inventory 2## 启动一个处理耗时4s的产品服务$ php ./other_server.php 8082 product 4## 监听8083端口,处理一个请求 耗时6s的 promo 服务$ php ./other_server.php 8083 promo 6

启动购物车服务

启动一个非阻塞购物车服务$ php ./async_cart_server.php

或者启动一个一般购物车服务$ php ./cart_server.php

发起用户请求

$ php ./user_client.php

运行结果呢如下,通过执行的时间日志,可得这三个请求是并发发起的,不是阻塞通讯。

在看我们的代码,三个函数,发起 socket 请求,没有设置 callback ,而是通过 yield from 接收了三个 socket 的返回结果。

也就是达到了, 同步编码,异步执行 的效果。

运行结果

非阻塞模式

client 端日志:

通过以上 起始时间结束时间 ,就看到这三个请求耗时总共就6s,也就按照耗时最长的promo服务的耗时来的。也就是说三个第三方请求都是并发进行的。

cart server 端日志:

而 cart 打印的日志,可以看到三个请求一并发起,并一起等待结果返回。达到非阻塞并发请求的效果。

阻塞模式

client 端日志:

以上是阻塞方式请求,可以看到耗时 12s。也就是三个服务加起来的耗时。

cart server 端日志:

cart 服务,依次阻塞方式请求第三方服务,顺序执行完毕后,共耗时12s,当然如果第一个,获第二个服务报错的话,会提前结束这个检查。会节约一点时间。

工作原理

这里就是用到了 yield 的工作特点——进进出出,在发起非阻塞 socket 请求后,不是阻塞方式等待socket响应,而是使用 yield 跳出当前执行生成器,等待有socket响应后,在调用生成器的 send 方法回到发起 socket 请求的函数内,在 yield from Async::all() 接收数据响应数据搜集完毕后,返回。

和Golang比一比

考虑到网速原因,我这就放上一个国内教程链接:Go 并发 教程

php 的协程是真协程,而 Go 是披着协程外衣的轻量化线程(“协程”里,都玩上“锁”了,这就是线程)。

我个人偏爱,协程的,觉得线程的调度有一定随机性,因此需要锁机制来保证程序的正确,带来了额外开销。协程的调度(换入换出)交给了用户,保证了一段代码执行连续性(当然进程级上,还是会有换入换出的,除非是跨进程的资源访问,或者跨机器的资源访问,这时,就要用到分布式锁了,这里不展开讨论),同步编码,异步执行,只需要考虑那个哪个方法会有IO交互会协程跳出即可。

和NodeJS比划一下

Javascript 和 PHP 两个脚本语言有很多相似的地方,弱类型,动态对象,单线程,在Web领域生态丰富。不同的是, Javascript 在浏览器端一开始就是异步的(如果js发起网络请求只能同步进行,那么你的网页渲染线程会卡住),例如 AjaxsetTimeoutsetInterval ,这些都是异步+回调的方式工作。

基于V8引擎而诞生的 NodeJS ,天生就是异步的,在提供高性能网络服务有很大的优势,不过它的 IO编码范式 么。。。刚开始是 回调——毁掉地狱,后来有了Promise——屏幕竖起来看,以及 Generator ——遇事不绝 yield 一下吧,到现在的 Async/Await ——语法糖?真香!

可以说JS的委员非常勤快,在异步编程范式的标准制定也做的很好(以前我尝试写 NodeJS 时,几个回调就直接把我劝退了),2009年诞生的 NodeJS 有点后来居上的意思。目前 PHP 只是赶上了协程,期待PHP的 Async/Await 语法糖的实现吧。

PHP yield 使用注意事项

一旦使用上 yield 后,就必须注意调用函数是,会得到函数结果,还是 生成器对象。PHP 不会自动帮你区别,需要你手动代码判断结果类型—— if ($re instanceof Generator) {} , 如果你得到的是 生成器,但不希望去手动调用 current() 去执行它,那么在生成器前 使用 yield from 交给上游(框架)来解决。

爆改 Workerman

博客写到这,就开始手痒痒了,看到Workerman框架,我在基础上二开,使其能—— 同步编码,异步执行

代码已放到:PaulXu-cn/CoWorkerman.git

目前还是dev阶段,大家喜欢可以先 体验一波。

$ composer require paulxu-cn/co-workerman

一个简单的单线程 TCP Server

<?php// file: ./examples/example2/coWorkermanServer.php , 详细代码见github$worker = new CoWorker(tcp://0.0.0.0:8080);// 设置fork一个子进程$worker->count = 1;$worker->onConnect = function (CoTcpConnection $connection) { try { $conName = "{$connection->getRemoteIp()}:{$connection->getRemotePort()}"; echo PHP_EOL . "New Connection, {$conName} \n";

    $re = yield from $connection->readAsync(1024);
    CoWorker::safeEcho(get request msg : . $re . PHP\_EOL );

    yield from CoTimer::sleepAsync(1000 * 2);

    $connection->send(json\_encode(array(productId => 12, re =>true)));

    CoWorker::safeEcho(Response to : . $conName . PHP\_EOL . PHP\_EOL);
} catch (ConnectionCloseException $e) {
    CoWorker::safeEcho(Connection closed,  . $e->getMessage() . PHP\_EOL);
}};CoWorker::runAll();

这里设置fork 一个 worker 线程,处理逻辑中带有一个 sleep() 2s 的操作,依然不影响他同时响应多个请求。

启动测试程序

启动CoWorker服务$ php ./examples/example2/coWorkermanServer.php start## 启动请求线程$ php ./examples/example2/userClientFork.php

运行结果

绿色箭头——新的请求,红色箭头——响应请求

从结果上看到,这一个worker线程,在接收新的请求同时,还在回复之前的请求,各个连接交错运行。而我们的代码呢,看样子就是同步的,没有回调。

CoWorker购物车服务

好的,这里我们做几个简单的微服务模拟实际应用,这里模拟 用户请求端购物车服务库存服务产品服务 。 模拟用户请求加购动作,购物车去分别请求 库存,产品 校验用户是否可以加购,并响应客户请求是否成功。

代码我就不贴了,太长了,麻烦移步 CoWorkerman/example/example5/coCartServer.php

运行命令

启动库存服务$ php ./examples/example5/otherServerFork.php 8081 inventory 1## 启动产品服务$ php ./examples/example5/otherServerFork.php 8082 product 2

启动CoWorker 购物车服务$ php ./examples/example5/coCartServer.php start

用户请求端$ php ./examples/example5/userClientFork.php

运行结果

黄色箭头——新的用户请求,蓝色箭头——购物车发起库存,产品检查请求,红色箭头——响应用户请求

从图中看到也是用1个线程服务多个连接,交错运行。

好的,那么PHP CoWorkerman 也能像 NodeJS 那样用 Async/Await 那样同步编码,异步运行了。

快来试试这个 CoWorkerman 吧:

$ composer require paulxu-cn/co-workerman

工作原理

workerman 内的 worker进程 遇到阻塞函数的处理方式时,会等待IO返回,如果这个时候,又有了新的请求,那么闲的worker会竞争到这个新的连接。

我在上图worker5中,描述了一个 AsyncTCPConnection 使用情况,woker内发起了一个非阻塞请求,并注册了回调函数,然后程序继续运行到结束。当异步请求响应时,就需要通过其他方式去响应(如自己再发起一个请求告知请求方)。

在下图中 CoWorkerman ,也是多个Worker竞争新的请求,当worker1收到一个新的请求,会产生一个生成器,生成器内发起异步请求,并注册响应回调,请求响应后,回到该生成器跳出( yield )的地方,继续执行代码。

发起异步请求,并注册回调函数,这些默认工作 CoWorkerman 框架内已做了,回调函数内工作是:收到数据,并发给 发起该请求的生成器。

这例子中,通过调用 Promise:all() 发起多个请求,并监听结果返回,待所有的响应返回再继续运行生成器

在程序 yield 跳出后,该worker就处于事件循环状态( $event->loop() ),也就是多路监听:请求端口,第三方客户端请求响应端口。这个时候如果:

  1. 有新的请求来,他和其他 worker 竞争新的请求,如果竞争到了,则该worker内又产生一个新的 生成器。
  2. 客户端有响应,则调用回调函数
  3. 客户端都响应了,继续运行 生成器程序。

从1中,我们可假设,如果就一个 Worker ,那么该 Worker 可以在上一个请求未完成情况下,继续接受处理下一个请求。也就是 CoWorkerman 可以在单 Worker 下运行,并发处理多个请求。

当然,这里也有个前提,单 Worker 模式内不能运行阻塞函数,一旦阻塞,后续请求就会堵在网卡。所以,除非对自己的代码非常了解,如果用到第三方库,那么我还是建议你在多 Worker 模式下运行 CoWorkerman ,阻塞时,还有其他 Worker 兜住新请求。

CoWorkerman 的意义

  1. 用同步的代码,发起异步请求,多个请求可并发,从IO串行等待,改为并行等待,减少无畏的等待时间。提高业务程序的效率同时,不降低代码可读性。
  2. 在一个线程内通过事件循环,尽可能处理多个请求,缓解了一个请求一个线程带来的频繁线程切换,从核心上提高运行效率。

CoWorkerman 生态位

适合处理纯 Socket 请求的应用,如 Workerman Gateway ,或者是 大前端 整合多个服务 RPC 结果, 综合后返给 前三页 这样的场景.

日志记录是每个程序最基本需求,由于写文件函数是阻塞的,建议用消息队列,或者 redis 队列,更或者跳过 Logstash 直接丢 Elasticsearch .

CoWorkerman有他的局限性,也有他自己位置。

关于PHP中yield的使用方法就分享到这里了,希望以上内容可以对大家有一定的帮助,可以学到更多知识。如果觉得文章不错,可以把它分享出去让更多的人看到。

/****/

版权声明

所有资源都来源于爬虫采集,如有侵权请联系我们,我们将立即删除

热门