作為一個php開發(fā),平時接觸最多的就是傳統(tǒng)fpm框架(tp、laravel等),以及守護進程框架(webman等)。
關(guān)于協(xié)程的概念,目前看到 swoole、golang 中可以實現(xiàn)。對 協(xié)程 的概念有點模糊。
關(guān)于 進程 的概念,無論是fpm,還是守護進程 workerman,都是一個進程處理一個請求,當(dāng) 進程數(shù)量 處理不過來很多的請求的時候,會阻塞。
想知道協(xié)程這一塊是怎么處理的?我有以下猜想:
舉個例子,業(yè)務(wù)邏輯是這樣的:
一個請求過來后,首先,需要 4 秒鐘調(diào)用第三方接口A,需要 4 秒鐘調(diào)用第三方接口B,拿到A和B接口返回的數(shù)據(jù)后,需要2秒鐘進行A和B接口返回數(shù)據(jù)的組裝。
我用同步處理這個場景,需要 4+4+2 = 10秒。如果我用協(xié)程,4+2=6秒 就可以完成。
在使用協(xié)程的情況下,如果我有5個進程,同時來了5個請求,單個進程里處理單個請求需要 6 秒鐘。是不是這 5個進程可以同時生成5個協(xié)程來處理呢?還是說 5個進程,同一時間內(nèi),只能有一個協(xié)程在處理?
協(xié)程本身不具備并發(fā)能力,只是一種上下文(請求/響應(yīng)/執(zhí)行等)的編排方案,類似于隊列;協(xié)程還區(qū)分有棧協(xié)程(PHP中的fiber)和無棧協(xié)程(PHP中的yield);協(xié)程一般需要結(jié)合線程或者異步執(zhí)行能力才可以達到并發(fā)/并行效果。
你可以理解為,把代碼段碎片化,按照協(xié)程調(diào)度器的執(zhí)行方案進行執(zhí)行。
協(xié)程 + 協(xié)程調(diào)度器 + 協(xié)程執(zhí)行單元
消息 + 消息隊列 + 消費者
看起來和隊列很像是不是,本質(zhì)上是一樣的
您上面回復(fù)的我看明白了,您回答的角度是從它本身以及和其它相關(guān)技術(shù)結(jié)合的 大面 來看的。我是有點糾結(jié)于他的實際流程是怎么執(zhí)行的。是想知道在請求到達進程后,協(xié)程是如何處理以及掛起的,是單個進程之內(nèi)只有一個協(xié)程在處理,還是所有進程之內(nèi)只有一個協(xié)程在處理。
如果按照您上面說的,是不是 golang 和 swoole的協(xié)程調(diào)度機制也不一樣,那我上面的提問是不是得建立在某個技術(shù)棧上才能進行下一步的探討
是的,swoole是單個執(zhí)行單元,golang是多個執(zhí)行單元,PHP-fiber只能利用eventloop在主線程上進行切換調(diào)度
通常來說,和linux內(nèi)核下面,進程切換的方案差不多,比如時間分片法,也就是某個執(zhí)行體如果執(zhí)行完了可以主動出讓當(dāng)前CPU,或者執(zhí)行超時了以后被強制暫停,等到分配下一個CPU時間;
也就是說,協(xié)程也可以這樣
這個具體調(diào)度的實現(xiàn),每種協(xié)程調(diào)度的具體細(xì)節(jié)可能都不同,取決于具體實現(xiàn),但思路大差不差,可以關(guān)注一下;
如果沒記錯的話,golang的協(xié)程和執(zhí)行單元是m:n,java21和swoole的協(xié)程和執(zhí)行單元是m:1;而php python等都是運用事件循環(huán)在當(dāng)前主線程內(nèi)進行調(diào)度的,與其他的用額外線程來執(zhí)行的不一樣。
m:n 和 m:1 也是相當(dāng)于單個進程內(nèi)的協(xié)程調(diào)度規(guī)則把。在單個進程內(nèi),swoole的 m:1,有多個協(xié)程,但是每次只調(diào)度一個。例如我是多核cpu,一個核心能處理兩個進程,但是這一個進程內(nèi),也就只能有1個協(xié)程在調(diào)度,雖然在很大程度上提高了請求的執(zhí)行速度,但是其余空閑的核心就利用不上了。不如 golang的 m:n ,有多個協(xié)程,又能同時調(diào)度多個,從而充分利用了cpu的核心吧?
我對操作系統(tǒng)進程線程調(diào)度這一塊的知識掌握的太少了,得補習(xí)補習(xí)了
我測試了swoole的協(xié)程,通過它提供的 WaitGroup 和 Barrier 這種方法,對于開發(fā)難度來說,感覺是比較好上手業(yè)務(wù)邏輯的。golang這種屬于性能好,但是對于實際開發(fā),如果不了解他的調(diào)度機制及實際順序流程,感覺很容易寫bug。
你可以這樣理解,通常來說golang下是用線程比較多,一般一個服務(wù)啟動一個進程即可,GPM模型最后會把碎片化的任務(wù)調(diào)度交給不同的線程來進行運行,這樣可以利用多核;
(如果沒記錯的話,你可以具體查看一下swoole文檔或者源碼)swoole/swow下除了php主線程之外還會有一個協(xié)程執(zhí)行線程,會將主線程的任務(wù)碎片化調(diào)度交給執(zhí)行線程進行執(zhí)行,通常來說會在上層加上多進程模型來利用多核,也就是多個進程一托一;
workerman如果使用fiber的話,就是主進程要處理碎片化調(diào)度和執(zhí)行協(xié)程,從始至終只有一個線程進行執(zhí)行,但是整體是多進程模型。
進程與進程之間相互隔離,各是各的,整體看來都是可以利用多核的,只是利用的方式不一樣;整體并沒有說m:n就性能更好,因為線程和進程在內(nèi)核中的調(diào)度也存在性能損耗,也會有內(nèi)核態(tài)和用戶態(tài)的切換等,各有優(yōu)劣。
golang其實還好,也分systemcall和netpoll,不同場景下有的會在固定線程上執(zhí)行,有的會有執(zhí)行線程的轉(zhuǎn)移切換,這個在調(diào)度器底層實現(xiàn)了,其實也不會有什么bug或者問題,對于開發(fā)者來說用起來跟m:1沒什么區(qū)別;為什么實現(xiàn)m:1而不是m:n,可能更多的層面是因為php和java都存在虛擬機,另外生態(tài)上面的復(fù)用上,為了不對虛擬機進行大規(guī)模的重寫,并利用之前的生態(tài)和模式(進程模型、bio等),從而用m:1的方式實現(xiàn)。
多進程的資源占用上肯定是比多線程要多的,多方面考量吧
多進程模型的話,相當(dāng)于自己的服務(wù)除了系統(tǒng)會對服務(wù)進行主進程的管理,自己的服務(wù)還需要在內(nèi)部進行子進程管理;而多線程模型就僅僅只需要在進程內(nèi)對線程管理而已,很多資源都可以復(fù)用,在界限上更符合“規(guī)范”或者“理念”;
進程之間肯定是沒有線程之間那么方便快捷,存在一定的難度,為了簡化這些內(nèi)容,方便管理,把一些工作交給系統(tǒng)本身的能力,這樣會更健壯,開發(fā)起來也不需要一些過分的奇淫技巧。但實際上來說,都能通過一些方法實現(xiàn)想要的功能。用一句比較通俗的話來說就是線程比進程更具有“邊界感”。
上面說的都很有道理,但我水平不夠,對你上面說的部分知識點還是有點一知半解,再就是長期的開發(fā)思維都是對進程的一些操作,忽然理解起來線程的調(diào)度還是難懂。我邊學(xué)邊測試這邊的知識點,在回頭看看你上面說的,可能就好理解了,感謝您的耐心回答~
@ikun 之后有空出一個吧,我最近在研究mmap和apcu,準(zhǔn)備完善一下這個插件 http://wtbis.cn/plugin/133 ,讓它支持更多的功能,因為我現(xiàn)在在計劃做一個輕調(diào)度的插件,純用內(nèi)存和sqlite來支撐小型服務(wù)。
協(xié)程還區(qū)分有棧協(xié)程(PHP中的fiber)和無棧協(xié)程(PHP中的yield),這兩種協(xié)程有啥區(qū)別呢?為啥流行不起來呢?
有棧協(xié)程要保留函數(shù)調(diào)用棧用于掛起恢復(fù),會需要更多的內(nèi)存空間
無棧協(xié)程在不改變函數(shù)調(diào)用棧的情況下,采用類似生成器的思路實現(xiàn)了上下文切換
理論上無棧比有棧性能好,但實際使用中不需要扣資源的時候,兩者沒多大區(qū)別,有棧用起來方便點
@鄔綵唔惪 yield一直都流行,只不過圈子很小,reactphp、amphp這些都是利用yield + eventloop實現(xiàn)的類似async/await;這類組件或者框架有個缺點,不能利用php歷史積攢下來的大部分組件包,因為整個思想是NIO的也就是no-blocking I/O,而PHP整個生態(tài)主要是圍繞FPM,然后整體思想是blocking I/O的;同樣,因為yield由于無棧,在框架層面實現(xiàn)時候很多東西沒辦法實現(xiàn),所以這個圈子引入了fiber。
還是那句話,協(xié)程本質(zhì)上不具備并發(fā)能力,本質(zhì)上是代碼執(zhí)行片段碎片化并編排的方案的一環(huán),協(xié)程+協(xié)程調(diào)度器+協(xié)程執(zhí)行單元才能實現(xiàn)具備高并發(fā)能力的方案;有的用線程,有的用事件驅(qū)動。
同一時間內(nèi),只能有一個協(xié)程在處理?
這句話指的是在一個進程內(nèi)同一時間只有一個協(xié)程在處理,單個進程是可以創(chuàng)建無數(shù)個協(xié)程的,我試過在服務(wù)器上單進程創(chuàng)建1000萬個定時器;
我用同步處理這個場景,需要 4+4+2 = 10秒。如果我用協(xié)程,4+2=6秒 就可以完成。
用協(xié)程也是同步,只不過不會阻塞了,開N個Curl,效果是“并發(fā)”N個請求,實際上還是一個個去執(zhí)行,只是發(fā)出請求后不會堵在那里等返回,跟隊列是挺像的,把任務(wù)拋給隊列,對當(dāng)前業(yè)務(wù)流程來說就是秒完成;
同時來了5個請求
協(xié)程的話單進程就可以處理了,不需要5個進程,fpm下一執(zhí)行curl進程就堵在那里等返回,無法處理下一個請求,協(xié)程不存在的。
以上說的是swoole/swow
首先你要搞清楚的是為什么像 php-fpm 這種東西一個進程一個線程只能處理一個請求,如果一個線程在處理一個請求時卡住了不能再處理別的請求了,那么它到底是卡在什么地方?
程序在運行的時候,籠統(tǒng)的可以分為兩個概述,一個是運算,一個是 IO。
運算就是你需要強依賴 CPU 完成的事,比如在本地計算 N 個 1+1,執(zhí)行大量的 if 判斷邏輯,運行各種各樣的本地代碼,這類工作、需要程序占用本地 CPU 和內(nèi)存,在單個線程中當(dāng)它們在執(zhí)行時,這些硬件資源就是被占用著的,沒有更多的 CPU 時間來給到程序,所以在運算類任務(wù)時,線程一定是阻塞的。
就好比你個人在吃飯,在寫字,在讀書時需要你自己來干這些事,這時你是騰不出手來處理別的事,你就是被占用的。
而另一種就是 IO,IO 就是程序調(diào)用外部的東西,然后就等外部的東西返回結(jié)果,在等的期間線程本身是閑著在那里的。比如你的程序調(diào)用 MySQL,程序就是通過協(xié)議(網(wǎng)絡(luò)、或者 Socket)將要執(zhí)行的語句發(fā)送給 MySQL 服務(wù)器,在 MySQL 返回數(shù)據(jù)前線程本身就是一直在等結(jié)果,自己并沒干什么。包括調(diào)用 Redis,或者發(fā)送 HTTP 請求等等,甚至包括向系統(tǒng)要求讀寫磁盤,都存在一定的等待,這個時間可能非常短,也可能非常長。想一想,如果你的程序執(zhí)行時間是 50ms,但在等待 MySQL 返回數(shù)據(jù)時就耗費了 30ms,這 30ms 的時間線程是閑著的,卻不能執(zhí)行其它的工作,是不是一種浪費。
和前面你自己吃飯,寫字的例子相反,你點了個外賣,外賣預(yù)估 30 分鐘送到你的手中,你要在等外賣送來的這 30 分鐘內(nèi)什么都不干,完全等在這里嗎?你對象喊你幫忙拿一下東西,你不回應(yīng),為什么,因為你在等外賣,你的時間被占用了?
如前所述,你在等待 IO 響應(yīng)時浪費了大量的時間,甚至不對外部作出響應(yīng)。也許你也想到了,在做等待的時候完全可以把閑置的時間用來干別的事,這對人來說是很正常的,你肯定不會在那里傻等。
程序的事情是有上下依賴關(guān)系的,就好比你的計劃是等到外賣后開始吃飯,外賣點的餐還沒來,你就暫還沒法吃飯。但是你雖然暫不能吃飯,但是做與吃飯以外的事情是可以干的,比如給對象拿一下東西。
但是別忘了,程序是線性執(zhí)行的,按代碼的意思就是從上到下的執(zhí)行,下面的東西依賴上面的東西。那么有沒有一種辦法,當(dāng)程序去讀取 MySQL 數(shù)據(jù)時,數(shù)據(jù)沒返回前程序先去干別的呢,當(dāng) MySQL 返回后,再接著執(zhí)行原來要往下執(zhí)行的邏輯?有,這就是異步的概念。
異步最開始都是先給程序設(shè)定一個所謂的“回調(diào)函數(shù)”,然后就去干別的了,因為這里順序存在不確定性,有的先跑然后接下來的事情進回調(diào)了,在回調(diào)前又有些東西在跑,總之順序是亂亂的,所以就叫作了異步。
我們再舉例子,你等外賣來后要吃飯,你還在等你的樂高玩具快遞到了后要拼樂高,你還在等這等那,如果你在等一萬件事,你還記得這些事在有結(jié)果后該干什么嗎?這么多事情你怎么去有條理的檢查它們是否有回應(yīng)了,有些事情你可能等著等著都忘記了,下一次有人給你個東西,你可能在想“這是什么?我什么時候要這個了?我接下來該干嘛??”
對于異步,這里有個重要的核心,那就是事件循環(huán)。
事件循環(huán)里面記錄了所有在等待的 IO 以及接下來的回調(diào)。這就好比你有一萬件事在等,每件事當(dāng)你要等的時候,你拿個小本子把它記下來,你在等什么,有結(jié)果后該干什么。當(dāng)你有事的時候你就做事,你一閑下來你就會去檢查小本子上的事有沒有結(jié)果了,有結(jié)果就把它要干的事情干掉,然后將這它從小本子上劃掉。還沒結(jié)果就繼續(xù)留在小本子上,下一次還會再檢查它。
這個小本子,就是一個隊列,有那么多事件在一個隊列里,有一個循環(huán)在線程一閑下來的時候就檢查它,事件循環(huán)的名字就是從此而來。
這樣一來,你所有的閑置時間都利用上來,你不會浪費每一分一秒,有一萬件事都可以在你的手上有條不紊的并發(fā)執(zhí)行著。你的效率真高!對于人來說,這可能有點不人道,太累了,人會崩潰,但是對于計算機,我們當(dāng)然是要充分的利用 CPU。
好現(xiàn)在我們就要脫離舉生活中例子的概念,重新回到代碼中,在程序里異步開始時是一大堆回調(diào),這帶來了兩個大問題:
一是編程習(xí)慣被完全打亂,回調(diào)在程序中往往是一個函數(shù)或者一個閉包,導(dǎo)致我們的代碼寫起來極不舒服,每當(dāng)要等一個調(diào)用返回結(jié)果時,總要把接下來的代碼寫進另一個函數(shù)或者閉包中。不再是以前的從上到下直觀的順序,看著不舒服,維護也極為困難。
以前的代碼:
$data = $db->query("SELECT * FROM data LIMIT 1");
print_r($data);
現(xiàn)在的代碼:
$db->query("SELECT * FROM data LIMIT 1", function($data) {
print_r($data);
})
二是可怕的回調(diào)地獄問題,當(dāng)你一套流程走下來需要等很多 IO,寫很多回調(diào)時,因為有先后順序,這些回調(diào)一層套一層,里里外外 N 多層,代碼看起來極為可怕,一眼看去,無法呼吸,無法思考。
以前的代碼:
$data = $db->query("SELECT * FROM data LIMIT 1");
$result = $cache->set("data_cache", $data);
if ($result) {
echo "寫入數(shù)據(jù)緩存成功";
}
現(xiàn)在的代碼:
$db->query("SELECT * FROM data LIMIT 1", function($data) use ($redis) {
$redis->set("data_cache", function($result) {
if ($result) {
echo "寫入數(shù)據(jù)緩存成功";
}
});
})
下面就輪到協(xié)程出場了,協(xié)程幫你從層層回調(diào)函數(shù)中解脫出來,再次回到以前的同步編程方式中來。以下面的偽代碼為例:
//調(diào)用 A
$cortinue->async(function() {
$data = $db->query("SELECT * FROM data LIMIT 1");
$result = $cache->set("data_cache", $data);
if ($result) {
echo "寫入數(shù)據(jù)緩存成功";
}
});
//調(diào)用 B
$cortinue->async(function() {
$data = $db->query("SELECT * FROM data LIMIT 1,1");
$result = $cache->set("data_cache", $data);
if ($result) {
echo "寫入數(shù)據(jù)緩存成功";
}
});
以上代碼中,當(dāng)調(diào)用 A的代碼執(zhí)行到 query() 在等待時,調(diào)用 A 的整個調(diào)用堆棧就暫停了等待 query() 返回結(jié)果,但是調(diào)用 B 中的代碼仍然在繼續(xù)跑,如果調(diào)用 B 的代碼停在某處等待 IO 返回時,調(diào)用 A 也不會受影響。這就實現(xiàn)了并發(fā),對于調(diào)用 A 和調(diào)用 B 中的代碼來講,它們自己就是同步的。
協(xié)程那么美好,它需要什么?
如你所見,上面的代碼要能夠?qū)崿F(xiàn)等待時停止在當(dāng)前調(diào)用堆棧的某處,卻不阻塞同一個線程中其它地方的調(diào)用,然后在調(diào)用完成后再恢復(fù)到那個地方繼續(xù)執(zhí)行,這個特性是需要語言支持的,從用戶代碼層面是無法實現(xiàn)這種東西的。如果語言層面沒有提供類似的支持,那么協(xié)程就無法實現(xiàn),比如 Swoole 就是通過擴展模塊來實現(xiàn),而 PHP 在 8.1 之前的版本中有一個叫生成器的東西,通過配置 yield 語法也可以實現(xiàn)(但是有點丑),而 PHP 8.1 之后增加了一個叫作 Fiber 的東西,可以做到無需添加關(guān)鍵字,隱式的有棧中斷和恢復(fù),是實現(xiàn)協(xié)程的基礎(chǔ)。
還有一點,如果底層的調(diào)用,比如向 MySQL 發(fā)請求走的 TCP 或者 Socket 調(diào)用,在語言層面本來就是阻塞的,也就是說語言不支持異步 IO,那么即使是異步或協(xié)程編程,程序在等待時也會完全暫停,無法并發(fā)。這是現(xiàn)在 PHP 的缺點,PHP 的網(wǎng)絡(luò) Socket 可以通過設(shè)定阻塞參數(shù)實現(xiàn)無阻塞 IO,但是文件讀寫 IO 還無法實現(xiàn)異步,除了像 Swoole 這種擴展層面提供異步文件 IO 支持,其它的比如 Workerman, Amp 要么在讀寫文件時阻塞當(dāng)前進程,要么在其它進程中寫,要么還是需要依賴擴展。
而有些語言,比如 Node,Go,Java,C,C++ 等等他們要么設(shè)計之初就把 IO 設(shè)計成異步的(Node、Go)、要么支持多線程,通過線程提供異步 IO 而不阻塞業(yè)務(wù)主線程。
這就是異步、協(xié)程。
異步、協(xié)程、多線程是現(xiàn)代語言幾個重要的概念和特性,PHP 在這方面有很大的不足,即使是語言層面提供了支持,異步和協(xié)程對整個生態(tài)也是一個很大的考驗,PHP 任重而道遠(yuǎn)。
Pader,2024年4月16日頭腦一熱發(fā)表于 Workerman 問答社區(qū),希望能解答你的疑問。