1. 程式人生 > >PHP回顧之協程

PHP回顧之協程

轉載請註明文章出處: https://tlanyan.me/php-review...

PHP回顧系列目錄

PHP自5.5起引入了生成器(Generator),基於其可實現協程程式設計。本文先回顧生成器,然後過渡到協程程式設計。

yield與生成器

生成器

生成器是一種資料型別,實現了iterator介面。不能通過new

得到生成器例項,也沒有獲取生成器例項的靜態方法。得到生成器例項的唯一辦法是呼叫生成器函式(包含yield關鍵字的函式)。呼叫生成器函式直接返回一個生成器物件,生成器執行時函式內的程式碼才開始執行。

先上程式碼直觀感受一下yield與生成器:


# generator1.php
function foo() {
    exit('exit script when generator runs.');
    yield;
}

$gen = foo();
var_dump($gen);
$gen->current();

echo 'unreachable code!';

# 執行結果
object(Generator)#1 (0) {
}
exit script when generator runs.

foo函式包含yield關鍵字,變身為生成器函式。呼叫foo不會執行函式體中的任何程式碼,而是返回一個生成器例項。生成器執行後,foo函式內的程式碼執行,指令碼結束。

如其名,生成器可以用來生成資料。只是其生成資料的方式與其他函式不一樣:生成器通過yield返回資料,而非return; yield返回資料後,生成器函式不會銷燬,只是暫停執行,未來可以從暫停處恢復執行;生成器執行一次,(只)返回一個數據,多次執行就返回多個數據;不呼叫生成器獲取資料,生成器內的程式碼就躺著不動,所謂動次打次,說的就是生成器生成資料的樣子。

生成器實現了迭代器介面,獲取生成器資料可以用foreach迴圈或手工current/next/valid

。如下程式碼演示資料生成和遍歷:


# generator2.php
function foo() {
  # 返回鍵值對資料
  yield "key1" => "value1";
  $count = 0;
  while ($count < 5) {
    # 返回值,key自動生成
    yield $count;
    ++ $count;
  }
  # 不返回值,相當於返回null
  yield;
}

# 手動獲取生成器資料
$gen = foo();
while ($gen->valid()) {
  fwrite(STDOUT, "key:{$gen->key()}, value:{$gen->current()}\n");
  $gen->next();
}

# foreach 遍歷資料
fwrite(STDOUT, "\ndata from foreach\n");
foreach (foo() as $key => $value) {
    fwrite(STDOUT, "key:$key, value:$value\n");
}

yield

yield關鍵字是生成器的核心,其讓普通函式異化(進化)為生成器函式。yield有“讓出”的意思,程式執行到yield語句會暫停執行,讓出CPU並將控制權返回到呼叫者,下次執行時從中斷點繼續執行。控制權返回到呼叫者時,yield語句可以攜帶值返回給呼叫方。generator2.php指令碼演示了yield返回值的三種形式:

  1. yield $key => $value: 返回資料的key和value;
  2. yield $value: 返回資料,key由系統分配;
  3. yield: 返回null值,key由系統分配;

yield讓函式可以隨時暫停、繼續執行,並返回資料給呼叫方。如果繼續執行時需要外部資料,這個工作由生成器的send函式提供:出現在yield左邊等號的變數會接收send傳來的值。看一個常見的send函式使用樣例:


function logger(string $filename) {
  $fd = fopen($filename, 'w+');
  while($msg = yield) {
    fwrite($fd, date('Y-m-d H:i:s') . ':' . $msg . PHP_EOL);
  }
  fclose($fd);
}

$logger = logger('log.txt');
$logger->send('program starts!');
// do some thing
$logger->send('program ends!');

send讓生成器之間和外部有雙向資料通訊的能力:yield返回資料;send提供繼續執行的支撐資料。由於send讓生成器繼續執行,這個行為與迭代器的next介面類似,next相當於send(null)

其他

  1. $string = yield $data;的表示式在PHP7前不合法,需要加括號:$string = (yield $data);
  2. PHP5生成器函式不能return值,PHP7後可以return值,並通過生成器的getReturn獲取返回的值。詳情參考返回值的RFC:https://wiki.php.net/rfc/gene...
  3. PHP7新增了yield from語法,實現了生成器委託,詳情請參考其RFC: https://wiki.php.net/rfc/gene...
  4. 生成器是單向迭代器,開動後不能呼叫rewind

總結

相對於其他迭代器,生成器具有效能開銷小、編碼容易的特點。其作用主要體現在三個方面:

  1. 資料生成(生產者),通過yield返回資料;
  2. 資料消費(消費者),消費send傳來的資料;
  3. 實現協程。

關於PHP中的生成器及基本用法,建議看看 2gua 大佬的博文:PHP之生成器,生動有趣且易懂。

協程程式設計

協程(coroutine)是隨時可中斷、恢復執行的子程式,yield關鍵字讓函式擁有這種能力,所以可以用於協程程式設計。

程序、執行緒和協程

執行緒歸屬於程序,一個程序可有多個執行緒。程序是計算機分配資源的最小單位,執行緒是計算機排程執行的最小單位。程序和執行緒均由作業系統排程。

協程可以看成“使用者態的執行緒”,需要使用者程式實現排程。執行緒和程序由作業系統排程“搶佔式”交替執行,協程主動讓出CPU“協商式”交替執行。協程十分的輕量,協程切換不涉及執行緒切換,執行效率高,數目越多,越能體現協程的優勢。

生成器和協程

生成器實現的協程屬於無棧協程(stackless coroutine),即生成器函式只有函式幀,執行時附加到呼叫方的棧上執行。不同於功能強大的有棧協程(stackful coroutine),生成器暫停後無法控制程式走向,只能將控制權被動的歸還呼叫者;生成器只能中斷自身,不能中斷整個協程。當然,生成器的好處便是效率高(暫停時只需儲存程式計數器即可),實現簡單。

協程程式設計

說到PHP中的協程程式設計,相信大部分人已經看過鳥哥轉載(翻譯)的這篇博文:在PHP中使用協程實現多工排程。原文作者 nikic 是PHP的核心開發者,生成器功能的倡議者和實現人。想深入瞭解生成器及基於其的協程程式設計,nikic關於生成器的RFC和鳥哥網站上的文章必讀。

nikic的文章,生成器部分好懂,看完後用yield寫個xrange類似函式肯定毫無壓力。為什麼一進入協程,就有點懵逼呢?

先看看基於生成器的協程工作方式:協程協作式工作,即協程之間通過主動讓出CPU達到多工交替執行(即併發多工,但不是並行);一個生成器可看成一個協程,執行到yield語句,讓出CPU控制權回到呼叫方,呼叫方繼續執行其他協程或其他程式碼。

再來看鳥哥部落格理解的難點何在。協程非常輕量,一個系統中可以同時存在成千上萬個協程(生成器)。而作業系統不會對協程排程,安排協程執行的工作就落到開發者身上。部分人看不懂鳥哥文章的協程部分,是因為裡面說協程程式設計少(寫協程主要就是寫生成器函式),而是花筆墨實現了一個協程的排程器(scheduler或者kernel):模擬了作業系統,對所有協程進行公平排程。PHP開發一般的思維是:我寫了這些程式碼,PHP引擎會呼叫我這些程式碼得到預期結果。而協程程式設計不僅要寫幹活的程式碼,還要寫指導這些程式碼什麼時候幹活的程式碼。沒有很好的把握作者的思維,理解起來自然會難一些。需要自行排程,這是生成器協程相對於原生協程(async/await形式)的一個缺點。

知道了協程是怎麼回事,那麼它能用來幹什麼?協程自行讓出CPU來協作高效利用CPU,讓出的時機當然應該是程式阻塞時。什麼地方會讓程式阻塞呢?使用者態的程式碼鮮有阻塞,阻塞主要是系統呼叫。而系統呼叫的大頭是IO,所以協程的主要應用場景在網路程式設計。為了讓程式高效能、高併發,程式應該非同步執行不能阻塞。既然非同步執行,就需要通知和回撥,寫回調函式避免不了“回撥地獄(callback hell)”的問題:程式碼可讀性差,程式執行流程散落在層層回撥函式中等。解決回撥地獄的方式主要有兩種:Promise和協程。協程能以同步的方式編寫程式碼,在高效能網路程式設計(IO密集型)中是推薦的。

再回過頭看PHP中的協程程式設計。PHP中基於生成器實現實現協程程式設計,優先推薦使用RecoilPHPAmp等協程框架。這些框架已經寫好了排程器,在其上開發直接寫生成器函式,核心會自動排程執行(想讓一個函式以協程方式排程執行,在函式體內加上yield即可)。如果不想用yield方式進行協程程式設計,推薦swoole或其衍生框架,能做到類似golang的協程程式設計體驗,又能享受PHP的開發效率。

如果想用原生態的做PHP協程程式設計,類似鳥哥部落格中的排程器必不可少。排程器排程協程執行,協程中斷後控制權又回到排程器中。所以排程器應該總是在主(事件)迴圈中,即CPU不在執行協程,就應當在執行排程器的程式碼。無協程執行時,排程器應當自我阻塞避免消耗CPU(鳥哥部落格中使用了內建的select系統呼叫),等待事件到來再執行相應的協程。程式執行期間,除了排程器阻塞,協程在執行過程中不應該呼叫阻塞API。

總結

在協程程式設計中,yield的主要作用是將控制權轉讓,無需糾結於其返回值(基本上yield返回的值會在下次執行時直接send過來)。重點應當關注控制權轉讓的時機,以及協程的運作方式。

另外需要說明一點,協程和非同步沒有多大關係,還要看執行環境支撐。常規的PHP執行環境,即使用了promise/coroutine,也還是同步阻塞的。再牛逼的協程框架,sleep一下也不好使了。作為類比,即使JavaScript不使用promise/async這些技術,也是非同步非阻塞的。

通過生成器和Promise,能實現類似於await的協程程式設計,相關程式碼在Github上很多,本文不再給出。

總結

本文先介紹了生成器的概念,重點是yield的用法及生成器的介面。協程部分則簡要說了協程的原理,以及PHP協程程式設計中應當注意的事項。

感謝閱讀,歡迎指正!

參考

  1. http://php.net/manual/zh/lang...
  2. http://php.net/manual/zh/clas...
  3. https://wiki.php.net/rfc/gene...
  4. https://wiki.php.net/rfc/gene...
  5. https://zhuanlan.zhihu.com/p/...
  6. http://www.laruence.com/2015/...
  7. https://medium.com/async-php/...
  8. https://blog.kghost.info/2011...