在談到 coroutine 之前,先來看一下緣起好了

異步

在現在的程式語言中,一直都有想做到異步、平行的需求,以減少 CPU 停下來等 IO 的時間。

目前有的解決方案,如 javascript 是直接對於會遇到 io 的程式碼直接跳過不執行,等到程式有空去執行 IO 的部分才會去處理。

例如:

var fs = require('fs');

fs.readFile(function (file) {
    console.log('world');
});

console.log('hello');

以上述的例子來說,當中的 readFile 是一個異步的函數,當下不會執行,只會將你寫的 callback 函數註冊起來,等到有空的時候再去呼叫你寫的 callback

因此上述 console.log 的順序會是 ‘hello’, ‘world’

雖然執行順序跟閱讀的順序不一樣,但確實可以節省 CPU 等待 IO 的時間。這麼方便的東西,一般開發者其實無法寫出異步的函數,只能用系統提供的異步函數。

Coroutine

前面 readFile 的例子,幾乎就只有系統提供的函數能變成異步的,要是使用者也想寫出這樣的東西在以往幾乎是不可行。而 php, python 等語言借鑒了其他語言中 coroutine 的概念,逐步實現了使用者自行撰寫異步函數的功能。

先列出要實現異步功能的需求:

  1. 讓出函數的執行權
  2. 繼續執行未完成的部分

而在其他有實現 coroutine 功能的語言裡面,多半以 yield 這樣的語法來讓出函數的執行權

例如 :

function 自我感覺IO很吃重的函數($callback) {
     yield; // 這裡先讓出執行權,之後的程式碼等有空再執行
     print("start to read data");
     $data = do_somthing_io(); // 很吃重的工作
     $callback($data); // 取得資料後送給你註冊的 callback
}

上述的程式碼在第一次呼叫的時候什麼事都不會發生,因為 yield 這行就讓出執行權了。

但是 yield 跟 return 最大的不同點在於 yield 會紀錄函數執行到一半的狀態,你可以用某些語法繼續執行,以下以 resume 函數當作繼續執行的功能

var $執行到一半的函數 = 自我感覺IO很吃重的函數();
print("hello");
resume($執行到一半的函數)

以上的範例是說明 自我感覺IO很吃重的函數() 會回傳一個執行到 yield 的紀錄,讓你可以用 consume 函數繼續執行它。

而上面的例子就是認為在 print(“hello”) 才有空去執行剩餘 IO 吃重的部分。

如果有螢幕的話,在螢幕上顯示文字的順序大概會是這樣 :

hello
start to read data

基本所謂的 coroutine 大概是這樣,與前面 js 的 readFile 的差異在於繼續執行的時間點是由自己呼叫 consume() 去控制,而非系統自己找時間點去執行

附帶一提,這裡說明的 coroutine 還差了那麼一點點,真正的 coroutine 是可以指定 yield 之後可以跳到哪裡。但為了說明方便,這裡假定會跳回呼叫者處。

Generator 又是什麼

提到 php, python 實作 coroutine 的時候一定又會提及 Generator

其實 Generator 是融合了 iterator 跟 coroutine 兩種特性的東西,在 php 裡面實作了 Iterator 介面,便能夠讓物件被 foreach 語法拿來迭代,類似這樣的物件便可以被稱作 iterator 迭代器。

在 php 裡面也是可以使用 yield 的,如果我們對一個函數 yield 去 var_dump 觀察一下可以發現會得到一個名為 Generator 的物件。

這個 Generator 其實也是前面提到的 執行到一半的函式 可以讓我們透過某種方法繼續執行下去。

而 php 的 Generator 是以實作了 Iterator 的作法,讓我們使用裡面的 next() 方法去執行下一步。

這樣融合迭代器的作法,又額外了多解決了一些記憶體消耗的情境。

以產生 1000 個奇數為例,會試做以下的函數 :

function odds()
{
    $odds = [];
    for ($odd = 0; count($odds) < 1000; $odd++) {
        if ($odd % 2 == 1)
            $odds[] = $odd;
    }

    return $odds;

在這裡會生成塞 1000 個奇數的陣列,當這個陣列小的時候還不會覺得有多大的問題。但是數量一多,然後可能要做的事情只是 print 出數字,整個記憶體就浪費了。

我們可以將上面例子稍微改寫一下

function odds()
{
    for ($i = 0;; $i++) {
        if ($i % 2 == 1) {
            yield $i;
        }
    }
}
foreach (odds() as $i => $value) {
    if ($i == 1000) break;
    echo $value . "\n";
}

這裡的 odds() 是用個無窮迴圈來產出所有奇數,但是根本不用怕會被無窮迴圈卡在這個函數裡,因為每產出一個奇數,便會讓出執行權。

而這裡有個小小的不同,就是在 yield 的後方可以加上類似回傳值的東西。在 Generator 裏可以利用 current() 方法把這個回傳值給取出來。如果是跑在 foreach 迴圈裡也不用想太多,迴圈會自己幫忙帶進來。

以這個改寫的例子來說,所佔用的記憶體只有一個變數 $i ,然後利用 coroutine 會記住上次執行狀態的特性,就能夠減少其他的記憶體消耗了。

最後稍微提一下,在 php 裡是不能夠自己去 new 一個 Generator 的唷,因為這個特殊的迭代器要記住當下的執行狀態,所以都是以 C 去寫成的,所以目前只能靠 yield 去產生 Generator 。