博客 / 詳情

返回

PHP yield 協程實戰—“多線程”任務調度器

想試試,用純PHP代碼,不依賴第三方拓展就實現"多線程"麼。像 Java 那樣使用 setPriority() 影響各個"線程"的被調用機率,使用join()等待其他線程結束;在sleep期間讓出CPU佔用,到點再回到該"線程";像 Golang 一樣,用channel協程之間通信~

三部曲

  • yield 語法探究
  • yield from 語法探究
  • yield 實戰“多線程”編碼

接上回書,講完了 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

參考

  • https://www.runoob.com/java/j...
  • https://www.jianshu.com/p/192...
  • http://www.throwable.club/201...
  • https://blog.csdn.net/u013087...
  • https://m.php.cn/manual/view/...
  • https://www.cnblogs.com/sundd...
user avatar dominic-giglio 頭像 chenxiaokai 頭像 oneziyu 頭像
3 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.