PHP yield 协程实战—“多线程”任务调度器

小白要生发

想试试,用纯PHP代码,不依赖第三方拓展就实现"多线程"么。像 Java 那样使用 setPriority() 影响各个"线程"的被调用几率,使用join()等待其他线程结束;在sleep期间让出CPU占用,到点再回到该"线程";像 Golang 一样,用channel协程之间通信~

三部曲

接上回书,讲完了 yield 基本用法,这篇文章,带大家来实战一下,目标:手把手教会你用 yield 做一个任务调度器,加深对 PHP 生成器 理解。

建议大家先去看看 之前那篇文章复习下 yield 基础用法。

好,话不多说,开淦~

点睛

在上一讲中,我们学会了将 function() {...yield...} 就能将一个 函数 变为 “生成器”

一个简单任务调度器

这就是一个简单的任务调度器。代码比较少,直接贴这里了。

gitee地址: ./simpleYieldScheduler.php

<?php
/**
 * Class YieldScheduler
 */
Class YieldScheduler
{
    /**
     * @var array $gens
     */
    public $gens = array();

    /**
     * 新增任务到 调度器
     *
     * @param Generator $gen
     * @param null $key
     *
     * @return  $this
     */
    public function add($gen, $key = null)
    {
        if (null === $key) {
            $this->gens[] = $gen;
        } else {
            $this->gens[$key] = $gen;
        }
        return $this;
    }

    /**
     * 开始
     */
    public function start()
    {
        $keepRun = true;
        /**
         * @var Generator   $gen
         */
        $gen = null;
        do {

            // 循环调度任务
            foreach ($this->gens as $id => $gen) {
                $re = $gen->current();
                echo 'generator id: ' . $id . ' run, get current re : ' . $re . PHP_EOL;
                $gen->next();
            }

            // 检查任务是否已完成
            foreach ($this->gens as $id => $gen) {
                $check = $gen->valid();
                if (!$check) {
                    // 已执行完毕的任务就可以踢出任务调度队列了
                    unset($this->gens[$id]);
                }
            }

            // 调度器是否完成所有任务
            if (0 >= count($this->gens)) {
                $keepRun = false;
            }
        } while ($keepRun);
    }
}

function yieldFunc($max = 10)
{
    for($i = 0; $i < $max; $i ++) {
        (yield $i);
    }
    return $i;
}

$gen1 = yieldFunc(3);
$gen2 = yieldFunc(5);

$scheduler = new YieldScheduler();
$scheduler->add($gen1)->add($gen2);
$scheduler->start();

运行结果:

20200520105236.png

可以看到我们用同一个方法和不同的入参,生成了两个不同的生成器,用另一个方法也生成了一个生成器,虽然生成方式不同,但不影响他们仨一并启动,交替运行,他们的执行顺序确定(这个脚本运行多少遍都是同一个结果)。

我们来把这个理解透彻,看到yieldFunc($max)函数,他写了一个循环,循环内带有一个 yield,每当程序运行到这里时,就会跳出当前函数,让出运行时。

创建好三个 生成器后,再生成一个 YieldScheduler 对象,把两个 生成器 加入其中,开始运行任务。

start() 函数内,就是不断的逐个调用 currentnext 方法,驱使 生成器 运行,每次运行后,会调用 valid 检查 生成器 运行完成与否,完成后,就会从 任务调度器 生成器队列 中踢出该任务。

运行伪代码

我这把代码执行顺序伪代码贴一下:

<?php
// do 任务调度器
$sum = 0;
$re = $gen1->current();
    // 进入 gen1
    $n = 0;
    yield $n++;
    // 跳出 gen1, 获取返回值 赋值给 $re
echo 'generator id: ' . $id . ' run, get current re : ' . $re . PHP_EOL;
$gen1->send($sum++) // sum = 1
    // 进入 gen1
    $receive = yield;
    echo 'get scheduler sent : ' . $receive . PHP_EOL;
    $n++;
    // 跳出 gen1
// 任务调度器检查任务是否完成
if (!$gen1->valid()) {
    unset($gen1);
}
if (empty($gens)) {
    break;
}


// 任务调度器进入第二个循环
// 开始调度 第二个 生成器
$re = $gen2->current();
    // 进入 gen2 , 
    $i = 0;
    if ($i < $max) {
        yield $i;
    }
    // 跳出 gen2
echo 'generator id: ' . $id . ' run, get current re : ' . $re . PHP_EOL;
$gen2->send($sum++)     // sum = 2
    // 进入 gen2
    $get = yield;
    echo 'get scheduler sent : ' . $get . PHP_EOL;
    $i++;
    if ($i < $max){
        return $i;
    }
    // 跳出 gen2
// 任务调度器检查任务是否完成
if (!$gen2->valid()) {
    unset($gen2);
}
if (empty($gens)) {
    break;
}


// 任务调度器进入第三个循环
// 开始调度 第三个 生成器
$re = $gen3->current();
    // 进入 gen3, 这是第三个生成器,此 $i 不是 gen2 的 $i,所以 $i 从 0开始
    $i = 0;
    if ($i < $max) {
        yield $i;
    }
    // 跳出 gen3
echo 'generator id: ' . $id . ' run, get current re : ' . $re . PHP_EOL;
$gen2->send($sum++)     // sum = 3
    // 进入 gen3
    $get = yield;
    echo 'get scheduler sent : ' . $get . PHP_EOL;
    $i++;
    if ($i < $max){
        return $i;
    }
    // 跳出 gen3
// 任务调度器检查任务是否完成
if (!$gen3->valid()) {
    unset($gen3);
}
if (empty($gens)) {
    break;
}


// 任务调度器进入第四个循环
// 又开始调度 第1个 生成器
$re = $gen1->current();
    // 进入 gen1
    yield $n;           // $n = 1, 这里 $n++ 在第一次调度时,已完成?
    // 跳出 gen1, 获取返回值 赋值给 $re
echo 'generator id: ' . $id . ' run, get current re : ' . $re . PHP_EOL;
$gen1->send($sum++) // sum = 4
    // 进入 gen1
    $receive = yield;
    echo 'get scheduler sent : ' . $receive . PHP_EOL;
    $n++;
    // 跳出 gen1
// 任务调度器检查任务是否完成
if (!$gen1->valid()) {
    unset($gen1);
}
if (empty($gens)) {
    break;
}

看这伪代码的执行顺序,你想到了什么呢? goto !, PHP 也支持 goto 语法的,为了代码的阅读,易于维护,一般很少用它。

代码执行到 yiel d的右侧就跳出,这里有个细节一定要扣一下,那就是 yield 右侧表达式,或者函数执行完,才会跳出当前 生成器(并不是制定到 yield 这一行代码时,退出)。这个细节,你可以从 yieldFuncmyPrint 调用后的,命令行输出可以看到。在 任务调度器 第4个循环调度时,调用 send() 方法后,生成器 内不仅执行完毕了 echo 'get scheduler sent : ' . $receive . PHP_EOL;, 还执行了 myPrint($n++)。 然后呢,才是进入下一个 生成器

20200520105335.png

每个 生成器(函数) 内的 变量 都有自己的栈空间,不受其他 生成器 影响。 跳出当前生成器,变量的状态依然存在,这个地方就有点像线程的感觉,每个线程也维持者自己的栈空间。所以,你会看到 $i = 0,1,2。。。都打印了3遍。

线程有自己独占的栈内存以及计数器。

转载著名出处: sifou

PHP 的 goto

这里打岔讲一下 PHP.net goto.

PHP 中的 goto 有一定限制,目标位置只能位于同一个文件和作用域,也就是说无法跳出一个函数或类方法,也无法跳入到另一个函数。也无法跳入到任何循环或者 switch 结构中。可以跳出循环或者 switch,通常的用法是用 goto 代替多层的 break。

所以 yield 虽然没有 goto 灵活,但是比 goto 更强大, 能跳 循环,还能跨函数,作用域。

嗯,以上呢就是一个最简单的形态任务调度器,大家先理解透彻了,再继续往下看。

复杂一点的 任务调度器

在复杂一点的 任务调度器,就拿鸟哥的转载文章里 在PHP中使用协程实现多任务调度。 的一个任务调度器来讲吧,在文章中迭代了2个版本。代码较多,并且代码散落在文章中,我整理后放gitee scheduler了。大家可以clone到本地运行试试。

鸟哥的文章已经讲解得很清楚了,我就不画蛇添足了,说说我个人感想吧。

文中的代码使用了大量的 闭包,回调,引用。很多地方传递的是 一个个可执行的变量,理解起来有些烧脑。

类似多线程那样的任务调度器

我们先看一下Java线程的生命周期, 以及PHP 生成器的状态图。

java 线程状态转换图

20200519235459.png

有很多相似的地方,接下来,我们就尝试用 PHP yield 实现一个 "类Java的多线程" 调度器。

代码很多,放 gitee 了。

git clone https://gitee.com/xupaul/PHP-...

讲解

第一个Demo, priority

$ php ./YieldBootstrap.php ./YieldSchedulerDemo1.php

20200520122200.png

这个测试代码,里面用到了priority功能,可以看到 t 需要个周期,t2 需要10个周期,由于t2具有最高的执行优先级,在随机调度过程中,很快就执行完毕了。最后是 t 和 t3 (t3 需要运行8个周期)最后才执行完毕。

第二个Demo, interrupt,sleep

按照 Java 的实现,调用 一个线程的interrupt 方法时,会让该线程,抛出一个异常,而PHP yield 有 throw 方法,我就依葫芦画瓢实现了。

$ php ./YieldBootstrap.php ./YieldSchedulerDemo1.php

代码执行结果如下:

20200520144403.png

YieldThread 对象调用 sleep 方法后,5s内,任务调度输出,就没显示 "线程1" 被执行的输出。

第三个Demo, join,wait

我这代码里的 join,和wait是一个意思。等待线程执行完毕,不过还没有做 join(seconds) 这个功能。
$ php ./YieldBootstrap.php ./YieldSchedulerDemo1.php

执行效果如下

20200520150016.png

t3 生成器内 调用了t->join() 后,t3 在 t 没执行前完毕之前,就没有被调用过了。

而我们的 主线程使用 wait(), 等待他们t,t4 俩都执行完毕后才开始 输出自己执行完毕的字符。

原理

整个核心文件就:

  • InterruptedException.php
  • MainYieldTread.php
  • YieldBootstrap.php
  • YieldThread.php
  • YieldThreadScheduler.php

可以看到执行命令都是:$ php ./YieldBootstrap.php ./YieldSchedulerDemo1.php
。php 调用 YieldBootstrap.php 程序,自定义的代码(demo代码),是作为参数传入。在bootstrap中,会对主程序做一个包装—— MainYieldThread.php 包裹主 生成器。而 用户自定义的线程是继承自 YieldThread.php, 主线程,自线程,都继承自 YieldThread, 都放入到 YieldThreadScheduler.php 中,统一调度,这样就实现了,线程切换。

这个"线程"的接口设计是照搬Java的,原理实现呢,就按照Java-Thread生命周期图,以及PHP-yield 的活动状态图推演实现的。任务调度,优先级采用了轮盘,加随机数实现的随机调度。joinwait是通过一个数组记录各个线程之间的依赖关系来判断,当先线程是否ready

这个类多线程调度器,还不那么完善,后续更新会放到 PHP yield thread

结语

文字不多,代码很长,很苦涩,大家下载到本地,多运行,多琢磨琢磨,一定能搞明白 yield 高级用法。欢迎留言,提问。

没人比我更懂 PHP yield

参考

阅读 4.2k

PHP工程师

905 声望
1.2k 粉丝
0 条评论

PHP工程师

905 声望
1.2k 粉丝
文章目录
宣传栏