開篇
剛開始接觸PHP
的 yield
的時候,感覺,yield
是什麼黑科技,百度一下:yield
——協程,生成器。很多文章都在講 Iterator
,Generater
, 蛤~,這東西是 PHP 迭代器的一個補充。再翻幾頁,就是Go 協程
。我出於好奇點開看了下Go 協程
, 裡面都是 併發
,執行緒
,管道通訊
這類字眼,wc,nb, 這tm才是黑科技啊,再回來看PHP
,分分鐘想轉 Go
。
yield 語法加入 PHP
yield
語法是在版本5.5加入PHP
的,配合迭代器使用,功能上就是 流程控制
程式碼,和goto
,return
類似。
以下就是官方提供的 yield 小例子,通過執行結果,我們可分析當程式碼執行到 yield $i
時,他會進行 return $i
, 待 echo "$value\n"
後, goto
for ($i = 1; $i <= 3; $i++) {
, 對!PHP 的 yield 就是一個能出能進的語法。在z程式碼中七進七出,把 $i
平平安安得送了出來。
<?php
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
什麼的,再高階就是用它實現一個簡單的任務排程器,並且這個排程器,一看程式碼都差不多。
我來出道題
正如一個好的問題,比答案更有價值
- 用PHP實現一個 Socket Server,他能接收請求,並返回Server的時間。
好,這是第一個問題,鋪墊。 官方答案
- 在原來的程式碼上,我們加個需求,該Socket Server 處理請求時,依賴其他 Socket Server,還需要有 Client 功能。也就是他能接收請求,向其它Server發起請求。
這是第二個問題,也是鋪墊。
- 原來的Socket Server同一時間只能服務一個客戶,希望能實現一個
非阻塞I/O
Socket Server, 這個 Server 內有 Socket Client 功能,支援併發處理收到的請求,和主動發起的請求。要求不用多執行緒,多程式。
這個問題,還是鋪墊,這幾個問題很乾,大家可以想一想,2,3題的答案,都放在一個指令碼里了:nio_server.php
以上這段程式碼,我列舉了一個具體的業務,就是使用者請求購物車加購動作, 而購物車服務呢,又需要和 產品服務,庫存服務,優惠服務 互動,來驗證加購動作可行性。有同步,非同步方式請求,並做對比。
後續還有很多程式碼,我都放gitee連結了。使用方法,見readme.md
- 最後一個問題:在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
不等大家的答案了,先上我的結果程式碼吧,程式碼呢都放在這個目錄下了。
gitee 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 端日誌:
通過以上 起始時間
和 結束時間
,就看到這三個請求耗時總共就6s,也就按照耗時最長的promo服務的耗時來的。也就是說三個第三方請求都是併發進行的。
cart server 端日誌:
而 cart 列印的日誌,可以看到三個請求一併發起,並一起等待結果返回。達到非阻塞併發請求的效果。
阻塞模式
client 端日誌:
以上是阻塞方式請求,可以看到耗時 12s。也就是三個服務加起來的耗時。
cart server 端日誌:
cart 服務,依次阻塞方式請求第三方服務,順序執行完畢後,共耗時12s,當然如果第一個,獲第二個服務報錯的話,會提前結束這個檢查。會節約一點時間。
工作原理
這裡就是用到了 yield
的工作特點——進進出出,在發起非阻塞socket
請求後,不是阻塞方式等待socket響應,而是使用yield
跳出當前執行生成器,等待有socket響應後,在呼叫生成器的send
方法回到發起socket
請求的函式內,在 yield from Async::all()
接收資料響應資料蒐集完畢後,返回。
和Golang比一比
考慮到網速原因,我這就放上一個國內教程連結:Go 併發 教程
php
的協程是真協程,而Go
是披著協程外衣的輕量化執行緒(“協程”裡,都玩上“鎖”了,這就是執行緒)。
我個人偏愛,協程的,覺得執行緒的排程有一定隨機性,因此需要鎖機制來保證程式的正確,帶來了額外開銷。協程的排程(換入換出)交給了使用者,保證了一段程式碼執行連續性(當然程式級上,還是會有換入換出的,除非是跨程式的資源訪問,或者跨機器的資源訪問,這時,就要用到分散式鎖了,這裡不展開討論),同步編碼,非同步執行,只需要考慮那個哪個方法會有IO互動會協程跳出即可。
和NodeJS比劃一下
Javascript 和 PHP 兩個指令碼語言有很多相似的地方,弱型別,動態物件,單執行緒,在Web領域生態豐富。不同的是,Javascript
在瀏覽器端一開始就是非同步的(如果js發起網路請求只能同步進行,那麼你的網頁渲染執行緒會卡住),例如Ajax
,setTimeout
,setInterval
,這些都是非同步+回撥的方式工作。
基於V8引擎而誕生的NodeJS
,天生就是非同步的,在提供高效能網路服務有很大的優勢,不過它的IO編碼正規化
麼。。。剛開始是 回撥——毀掉地獄,後來有了Promise——螢幕豎起來看,以及Generator
——遇事不絕yield
一下吧,到現在的Async/Await
——語法糖?真香!
可以說JS的委員非常勤快,在非同步程式設計正規化的標準制定也做的很好(以前我嘗試寫NodeJS
時,幾個回撥就直接把我勸退了),2009年誕生的NodeJS
有點後來居上的意思。目前PHP
只是趕上了協程,期待PHP的Async/Await
語法糖的實現吧。
PHP yield 使用注意事項
一旦使用上 yield 後,就必須注意呼叫函式是,會得到函式結果,還是 生成器物件。PHP 不會自動幫你區別,需要你手動程式碼判斷結果型別—— if ($re instanceof \Generator) {}
, 如果你得到的是 生成器,但不希望去手動呼叫 current() 去執行它,那麼在生成器前 使用 yield from 交給上游(框架)來解決。
爆改 Workerman
部落格寫到這,就開始手癢癢了,看到Workerman框架,我在基礎上二開,使其能——同步編碼,非同步執行。
程式碼已放到:PaulXu-cn/CoWorkerman.git
目前還是dev階段,大家喜歡可以先 體驗一波。
$ composer require paulxu-cn/co-workerman
一個簡單的單執行緒 TCP Server
<?php
// file: ./examples/example2/coWorkermanServer.php , 詳細程式碼見github
$worker = new CoWorker('tcp://0.0.0.0:8080');
// 設定fork一個子程式
$worker->count = 1;
$worker->onConnect = function (CoTcpConnection $connection) {
try {
$conName = "{$connection->getRemoteIp()}:{$connection->getRemotePort()}";
echo PHP_EOL . "New Connection, {$conName} \n";
$re = yield from $connection->readAsync(1024);
CoWorker::safeEcho('get request msg :' . $re . PHP_EOL );
yield from CoTimer::sleepAsync(1000 * 2);
$connection->send(json_encode(array('productId' => 12, 're' =>true)));
CoWorker::safeEcho('Response to :' . $conName . PHP_EOL . PHP_EOL);
} catch (ConnectionCloseException $e) {
CoWorker::safeEcho('Connection closed, ' . $e->getMessage() . PHP_EOL);
}
};
CoWorker::runAll();
這裡設定fork 一個worker
執行緒,處理邏輯中帶有一個sleep()
2s
的操作,依然不影響他同時響應多個請求。
啟動測試程式
## 啟動CoWorker服務
$ php ./examples/example2/coWorkermanServer.php start
## 啟動請求執行緒
$ php ./examples/example2/userClientFork.php
執行結果
綠色箭頭——新的請求,紅色箭頭——響應請求
從結果上看到,這一個worker執行緒,在接收新的請求同時,還在回覆之前的請求,各個連線交錯執行。而我們的程式碼呢,看樣子就是同步的,沒有回撥。
CoWorker購物車服務
好的,這裡我們做幾個簡單的微服務模擬實際應用,這裡模擬 使用者請求端
,購物車服務
,庫存服務
,產品服務
。 模擬使用者請求加購動作,購物車去分別請求 庫存,產品 校驗使用者是否可以加購,並響應客戶請求是否成功。
程式碼我就不貼了,太長了,麻煩移步 CoWorkerman/example/example5/coCartServer.php
執行命令
## 啟動庫存服務
$ php ./examples/example5/otherServerFork.php 8081 inventory 1
## 啟動產品服務
$ php ./examples/example5/otherServerFork.php 8082 product 2
## 啟動CoWorker 購物車服務
$ php ./examples/example5/coCartServer.php start
## 使用者請求端
$ php ./examples/example5/userClientFork.php
執行結果
黃色箭頭——新的使用者請求,藍色箭頭——購物車發起庫存,產品檢查請求,紅色箭頭——響應使用者請求
從圖中看到也是用1個執行緒服務多個連線,交錯執行。
好的,那麼PHP CoWorkerman
也能像 NodeJS
那樣用 Async/Await
那樣同步編碼,非同步執行了。
快來試試這個 CoWorkerman 吧:
$ composer require paulxu-cn/co-workerman
工作原理
先上圖:
圖的上部是Workerman 的工作泳道圖,圖下部是CoWorkerman的工作泳道圖。
workerman
內的worker程式
遇到阻塞函式的處理方式時,會等待IO返回,如果這個時候,又有了新的請求,那麼閒的worker會競爭到這個新的連線。
我在上圖worker5中,描述了一個AsyncTCPConnection
使用情況,woker內發起了一個非阻塞請求,並註冊了回撥函式,然後程式繼續執行到結束。當非同步請求響應時,就需要通過其他方式去響應(如自己再發起一個請求告知請求方)。
在下圖中CoWorkerman
,也是多個Worker競爭新的請求,當worker1收到一個新的請求,會產生一個生成器,生成器內發起非同步請求,並註冊響應回撥,請求響應後,回到該生成器跳出(yield
)的地方,繼續執行程式碼。
發起非同步請求,並註冊回撥函式,這些預設工作
CoWorkerman
框架內已做了,回撥函式內工作是:收到資料,併發給 發起該請求的生成器。
這例子中,通過呼叫 Promise:all() 發起多個請求,並監聽結果返回,待所有的響應返回再繼續執行生成器
在程式yield
跳出後,該worker就處於事件迴圈狀態($event->loop()
),也就是多路監聽:請求埠,第三方客戶端請求響應埠。這個時候如果:
- 有新的請求來,他和其他
worker
競爭新的請求,如果競爭到了,則該worker內又產生一個新的 生成器。 - 客戶端有響應,則呼叫回撥函式
- 客戶端都響應了,繼續執行 生成器程式。
從1中,我們可假設,如果就一個 Worker
,那麼該 Worker
可以在上一個請求未完成情況下,繼續接受處理下一個請求。也就是 CoWorkerman
可以在單 Worker
下執行,併發處理多個請求。
當然,這裡也有個前提,單
Worker
模式內不能執行阻塞函式,一旦阻塞,後續請求就會堵在網路卡。所以,除非對自己的程式碼非常瞭解,如果用到第三方庫,那麼我還是建議你在多Worker
模式下執行CoWorkerman
,阻塞時,還有其他Worker
兜住新請求。
CoWorkerman 的意義
- 用同步的程式碼,發起非同步請求,多個請求可併發,從IO序列等待,改為並行等待,減少無畏的等待時間。提高業務程式的效率同時,不降低程式碼可讀性。
- 在一個執行緒內通過事件迴圈,儘可能處理多個請求,緩解了一個請求一個執行緒帶來的頻繁執行緒切換,從核心上提高執行效率。
CoWorkerman 生態位
適合處理純Socket
請求的應用,如Workerman Gateway
,或者是 大前端
整合多個服務RPC
結果, 綜合後返給前三頁
這樣的場景.
日誌記錄是每個程式最基本需求,由於寫檔案函式是阻塞的,建議用訊息佇列,或者redis佇列,更或者跳過
Logstash
直接丟Elasticsearch
.
CoWorkerman有他的侷限性,也有他自己位置。
總結
好~PHP 協程編碼到 網路非同步編碼就到此結束了,如果看到本文章有很多疑惑,歡迎留言提問,如果是 yield
語法不太記得,可以先讀一讀這個系列前幾篇文章複習一下。
如果行,請三連。CoWorkerman
謝謝!
參考
本作品採用《CC 協議》,轉載必須註明作者和本文連結