PHP生成器的使用yield性能优化

什么是生成器?

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

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

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

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

在这里插入图片描述

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

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

在这里插入图片描述

是一个生成器对象

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

final class Generator implements Iterator {
    /**
     * Throws an exception if the generator is currently after the first yield.
     * @return void
     */
    function rewind() {}
    /**
     * Returns false if the generator has been closed, true otherwise.
     * @return bool
     */
    function valid() {}
    /**
     * Returns whatever was passed to yield or null if nothing was passed or the generator is already closed.
     * @return mixed
     */
    function current() {}
    /**
     * Returns the yielded key or, if none was specified, an auto-incrementing key or null if the generator is already closed.
     * @return mixed
     */
    function key() {}
    /**
     * Resumes the generator (unless the generator is already closed).
     * @return void
     */
    function next() {}

    /**
     * Sets the return value of the yield expression and resumes the generator (unless the generator is already closed).
     * @param mixed $value
     * @return mixed
     */
    function send($value) {}

    /**
     * Throws an exception at the current suspension point in the generator.
     * @param Throwable $exception
     * @return mixed
     */
    function PS_UNRESERVE_PREFIX_throw(Throwable $exception) {}

    /**
     * Returns whatever was passed to return or null if nothing.
     * Throws an exception if the generator is still valid.
     * @link https://wiki.php.net/rfc/generator-return-expressions
     * @return mixed|null
     */
    function getReturn() {}

    /**
     * Serialize callback
     * Throws an exception as generators can't be serialized.
     * @link https://php.net/manual/en/generator.wakeup.php
     * @return void
     */
    public function __wakeup(){}
}

生成器有什么用?

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

<?php

// 内存占用测试
$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.0324
// memory (byte): 37748736

$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.0289
// memory (byte): 2097152

上述代码只是简单的进行 1000000 个循环后获取结果,不过也可以直观地看出。使用生成器的版本仅仅消耗了 2M 的内存,而未使用生成器的版本则消耗了 37M 的内存,直接已经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
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
// 4
// 5
// 6
// 7
// 8
// 9
// 10

上述代码理解起来可能比较绕,但是注意记住注释的那行话就行了(正常获取循环值,当外部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 分别从普通数组、迭代器对象、另一个生成器中获取数据并做为当前生成器的内容进行返回。

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;
    }
    return $a;
}

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

// 0
// 1
// 1
// 2
// 3
// 5
// 8
// 13
// 21
// 34

这段代码就不多解释了,非常直观的一段代码了。这种方式生成斐波那契额数列比使用递归效率高太多了。

总结

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

开篇

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

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

以下就是官方提供的 yield 小例子

function 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";
}

// output 1 2 ... 6 7

我们遇到了什么问题

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

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

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

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

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

我来出道题

正如一个好的问题,比答案更有价值
  1. 用PHP实现一个 Socket Server,他能接收请求,并返回Server的时间。 好,这是第一个问题,铺垫。 官方答案
  2. 在原来的代码上,我们加个需求,该Socket Server 处理请求时,依赖其他 Socket Server,还需要有 Client 功能。也就是他能接收请求,向其它Server发起请求。
    这是第二个问题,也是铺垫。
  3. 原来的Socket Server同一时间只能服务一个客户,希望能实现一个 非阻塞I/O Socket Server, 这个 Server 内有 Socket Client 功能,支持并发处理收到的请求,和主动发起的请求。要求不用多线程,多进程。

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

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

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

提示:这个和 PHP 的 yield 语法有关。

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

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

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

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

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

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

用yield来解决问题

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

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

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

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 端日志:

connect to server: [127.0.0.1:8080]...
send to server: {"method":"cart","data":{"productId":419},"noBlocking":true} , time: 2022-07-21 09:37:31
receive server: {"method":"cart","data":{"product_id":419},"re":false,"msg":"suc"}
  client time : 2022-07-21 09:37:37.
close connection...

connect to server: [127.0.0.1:8080]...
send to server: {"method":"cart","data":{"productId":600},"noBlocking":false} , time: 2022-07-21 09:37:39
receive server: {"method":"cart","data":{"product_id":600},"re":false,"msg":"suc"}
  client time : 2022-07-21 09:37:45.
close connection...

connect to server: [127.0.0.1:8080]...
send to server: {"method":"cart","data":{"productId":990},"noBlocking":false} , time: 2022-07-21 09:37:47
receive server: {"method":"cart","data":{"product_id":990},"re":false,"msg":"suc"}
  client time : 2022-07-21 09:37:53.
close connection...

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

cart server 端日志:

accept a new client~
received data :{"method":"cart","data":{"productId":600},"noBlocking":false}
 from : Resource id #39

connect to server: [127.0.0.1:8081]...
send to server: {"method":"inventory","data":{"productId":600}}

connect to server: [127.0.0.1:8082]...
send to server: {"method":"product","data":{"productId":600}}

connect to server: [127.0.0.1:8083]...
send to server: {"method":"promo","data":{"productId":600}}
response request: Resource id #39 , data: {"method":"cart","data":{"product_id":600},"re":false,"msg":"suc"}

accept a new client~
received data :{"method":"cart","data":{"productId":990},"noBlocking":false}
 from : Resource id #43

connect to server: [127.0.0.1:8081]...
send to server: {"method":"inventory","data":{"productId":990}}

connect to server: [127.0.0.1:8082]...
send to server: {"method":"product","data":{"productId":990}}

connect to server: [127.0.0.1:8083]...
send to server: {"method":"promo","data":{"productId":990}}
response request: Resource id #43 , data: {"method":"cart","data":{"product_id":990},"re":false,"msg":"suc"}

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

阻塞模式

client 端日志:

在这里插入图片描述

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

cart server 端日志:

在这里插入图片描述

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

工作原理

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

PHP yield 使用注意事项

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

原文地址

  • 2
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值