Python和Golang協(xié)程的區(qū)別
背景
最近在做后端服務 Python 到 Go 的遷移和重構,這兩種語言里,最大的特色和優(yōu)勢就是都支持協(xié)程。之前主要做python的性能優(yōu)化和架構優(yōu)化,一開始覺得兩個協(xié)程原理和應用應該差不多,后來發(fā)現還是有很大的區(qū)別,今天就在這里總結一下。
什么是協(xié)程
在說它們兩者區(qū)別前,我們首先聊一下什么是協(xié)程,好像它沒有一個官方的定義,那就結合平時的應用經驗和學習內容來談談自己的理解。
協(xié)程,其實可以理解為一種特殊的程序調用。特殊的是在執(zhí)行過程中,在子程序(或者說函數)內部可中斷,然后轉而執(zhí)行別的子程序,在適當的時候再返回來接著執(zhí)行。
注意,它有兩個特征:
可中斷,這里的中斷不是普通的函數調用,而是類似CPU的中斷,CPU在這里直接釋放轉到其他程序斷點繼續(xù)執(zhí)行。
可恢復,等到合適的時候,可以恢復到中斷的地方繼續(xù)執(zhí)行,至于什么是合適的時候,我們后面再探討。
和進程線程的區(qū)別
上面兩個特點就導致了它相對于線程和進程切換來說極高的執(zhí)行效率,為什么這么說呢?我們先老生常談地說一下進程和線程。
進程是操作系統(tǒng)資源分配的基本單位,線程是操作系統(tǒng)調度和執(zhí)行的最小單位。這兩句應該是我們最常聽到的兩句話,拆開來說,進程是程序的啟動實例,擁有代碼和打開的文件資源、數據資源、獨立的內存空間。線程從屬于進程,是程序的實際執(zhí)行者,一個進程至少包含一個主線程,也可以有更多的子線程,線程擁有自己的??臻g。無論是進程還是線程,都是由操作系統(tǒng)所管理和切換的。
我們再來看協(xié)程,它又叫做微線程,但其實它和進程還有線程完全不是一個維度上的概念。進程和線程的切換完全是用戶無感,由操作系統(tǒng)控制,從用戶態(tài)到內核態(tài)再到用戶態(tài)。而協(xié)程的切換完全是程序代碼控制的,在用戶態(tài)的切換,就像函數回調的消耗一樣,在線程的棧內即可完成。
Python的協(xié)程(coroutine)
Python 的協(xié)程其實是我們通常意義上的協(xié)程 coroutine。
從概念上來講,Python 的協(xié)程同樣是在適當的時候可中斷可恢復。那么什么是適當的時候呢,就是你認為適當的時候,因為程序在哪里發(fā)生協(xié)程切換完全控制在開發(fā)者手里。當然,對于 Python 來說,由于 GIL 鎖,在 CPU 密集的代碼上做協(xié)程切換是沒啥意義的,CPU 本來就在忙著沒偷懶,切換到其他協(xié)程,也只是在單核內換個地方忙而已。很明顯,我們應該在 IO 密集的地方來起協(xié)程,這樣可以讓 CPU 不再空等轉而去別的地方干活,才能真正發(fā)揮協(xié)程的威力。
從實現上來講,如果熟知了 Python 生成器,還可以將協(xié)程理解為生成器+調度策略,生成器中的 yield 關鍵字,就可以讓生成器函數發(fā)生中斷,而調度策略,可以驅動著協(xié)程的執(zhí)行和恢復。這樣就實現了協(xié)程的概念。這里的調度策略可能有很多種,簡單的例如忙輪循:while True,更簡單的甚至是一個 for 循環(huán)。就可以驅動生成器的運行,因為生成器本身也是可迭代的。復雜的比如可能是基于 epool 的事件循環(huán),在 Python2 的 tornado 中,以及 Python3 的 asyncio 中,都對協(xié)程的用法做了更好的封裝,通過 yield 和 await 就可以使用協(xié)程,通過事件循環(huán)監(jiān)控文件描述符狀態(tài)來驅動協(xié)程恢復執(zhí)行。
我們看一個簡單的協(xié)程:
import time def consumer(): r = '' while True: n = yield r if not n: return print('[CONSUMER] Consuming %s...' % n) time.sleep(1) r = '200 OK' def produce(c): c.next() n = 0 while n < 5: n = n + 1 print('[PRODUCER] Producing %s...' % n) r = c.send(n) print('[PRODUCER] Consumer return: %s' % r) c.close() if __name__ == '__main__': c = consumer() produce(c)
很明顯這是一個傳統(tǒng)的生產者-消費者模型,這里 consumer 函數就是一個協(xié)程(生成器),它在 n = yield r 的地方發(fā)生中斷,生產者 produce 中的 c.send(n) ,可以驅動協(xié)程的恢復,并且向協(xié)程函數傳遞數據n,接收返回結果r。而while n < 5,就是我們所說的調度策略。在生產中,這種模式很適合我們來做一些 pipeline 數據的消費,我們不需要寫死幾個生產者進程幾個消費者進程,而是用這種協(xié)程的方式,來實現CPU動態(tài)地分配調度。
如果你看過上篇文章的話,是不是發(fā)現這個 Go 中流水線模型有點像呢,也是生產者和消費者間進行通信,但 Go是通過 channe l這種安全的數據結構,為什么 Python 不需要呢,因為 Python 的協(xié)程是在單線程內切換本身就是安全的,換句話說,協(xié)程間本身就是串行執(zhí)行的。而 Go 則不然。思考一個有意思的問題,如果我們將 Go 流水線模型中channel設置為無緩沖區(qū)時,生產者絕對驅動消費者的執(zhí)行,是不是就跟.Python 很像了呢。
所以 Python 的協(xié)程從某種意義來說,是不是 Go 協(xié)程的一種特殊情況呢?后端在線服務中我們更常用的 Python 協(xié)程其實是在異步IO框架中使用,之前我們也提過 Python 協(xié)程在 IO 密集的系統(tǒng)中使用才能發(fā)揮它的威力。
Python協(xié)程的特點
單線程內切換,適用于IO密集型程序中,可以最大化IO多路復用的效果。
無法利用多核。
協(xié)程間完全同步,不會并行。不需要考慮數據安全。
用法多樣,可以用在 web 服務中,也可用在 pipeline 數據/任務消費中
Golang的協(xié)程(goroutine)
Golang 的協(xié)程就和傳統(tǒng)意義上的協(xié)程不大一樣了,兼具協(xié)程和線程的優(yōu)勢。這也是 Golang 最大的特色,就是從語言層面支持并發(fā)。Go語言里,啟動一個goroutine很容易:go function 就行。
同樣從概念上來講,Golang 的協(xié)程同樣是在適當的時候可中斷可恢復。當協(xié)程中發(fā)生 channel 讀寫的阻塞或者系統(tǒng)調用時,就會切換到其他協(xié)程。具體的代碼示例可以看上篇文章,就不再贅述了。
從實現上來說,Goroutine 可以在多核上運行,從而實現協(xié)程并行,我們先直接看下 Golang 的調度模型 MPG。
M指的是Machine,一個M直接關聯了一個內核線程。由操作系統(tǒng)管理。P指的是processor,代表了M所需的上下文環(huán)境,也是處理用戶級代碼邏輯的處理器。它負責銜接 M 和 G 的調度上下文,將等待執(zhí)行的G與M對接。G指的是Goroutine,其實本質上也是一種輕量級的線程。包括了調用棧,重要的調度信息,例如 channel 等。
每次 go 調用的時候,都會:
創(chuàng)建一個 G 對象,加入到本地隊列或者全局隊列
如果還有空閑的 P,則創(chuàng)建一個 M
M 會啟動一個底層線程,循環(huán)執(zhí)行能找到的 G 任務
G 任務的執(zhí)行順序是,先從本地隊列找,本地沒有則從全局隊列找(一次性轉移(全局 G 個數/ P 個數)個,再去其它P中找(一次性轉移一半)
對于上面的第2-3步,創(chuàng)建一個 M,其過程:
先找到一個空閑的 P ,如果沒有則直接返回,(哈哈,這個地方就保證了進程不會占用超過自己設定的 CPU 個數)
調用系統(tǒng) api 創(chuàng)建線程,不同的操作系統(tǒng),調用不一樣,其實就是和 C 語言創(chuàng)建過程是一致的
然后創(chuàng)建的這個線程里面才是真正做事的,循環(huán)執(zhí)行 G 任務
當協(xié)程發(fā)生阻塞切換時:
M0 出讓 P
創(chuàng)建 M1 接管 P 及其任務隊列繼續(xù)執(zhí)行其他 G。
當阻塞結束后,M0 會嘗試獲取空閑的 P,失敗的話,就把當前的 G 放到全局隊列的隊尾。
這里我們需要注意三點:
1、M 與 P 的數量沒有絕對關系,一個 M 阻塞,P 就會去創(chuàng)建或者切換另一個 M,所以,即使P的默認數量是 1,也有可能會創(chuàng)建很多個 M 出來。
2、P 何時創(chuàng)建:在確定了 P 的最大數量 n 后,運行時系統(tǒng)會根據這個數量創(chuàng)建 n 個 P。
3、M 何時創(chuàng)建:沒有足夠的 M 來關聯P并運行其中的可運行的 G。比如所有的 M 此時都阻塞住了,而 P 中還有很多就緒任務,就會去尋找空閑的 M,而沒有空閑的,就會去創(chuàng)建新的 M。
Golang協(xié)程的特點
協(xié)程間需要保證數據安全,比如通過channel或鎖。
可以利用多核并行執(zhí)行。
協(xié)程間不完全同步,可以并行運行,具體要看channel的設計。
搶占式調度,可能無法實現公平。
coroutine(Python)和 Goroutine(Golang)的區(qū)別
除了 Python,C#, Lua 語言都支持 coroutine 特性。coroutine 與 goroutine 在名字上類似,都是可中斷可恢復的協(xié)程,它們之間最大的不同是,goroutine 可能在多核上發(fā)生并行執(zhí)行,單但 coroutine 始終是順序執(zhí)行。也基于此,我們應該清楚 coroutine 適用于 IO 密集程序中,而 goroutine 在 IO 密集和 CPU 密集中都有很好的表現。不過話說回來,Golang 就一定比 Python 快么,假如在完全IO并發(fā)密集的程序中,Python 的表現反而更好,因為單線程內的協(xié)程切換效率更高。
從運行機制上來說,coroutine 的運行機制屬于協(xié)作式任務處理, 程序需要主動交出控制權,宿主才能獲得控制權并將控制權交給其他 coroutine。如果開發(fā)者無意間或者故意讓應用程序長時間占用 CPU,操作系統(tǒng)也無能為力,表現出來的效果就是計算機很容易失去響應或者死機。goroutine 屬于搶占式任務處理,已經和現有的多線程和多進程任務處理非常類似, 雖然無法控制自己獲取高優(yōu)先度支持。但如果發(fā)現一個應用程序長時間大量地占用 CPU,那么用戶有權終止這個任務。
從協(xié)程:線程的對應方式來看:
N:1,Python 協(xié)程模式,多個協(xié)程在一個線程中切換。在 IO 密集時切換效率高,但沒有用到多核
1:1,Java 多線程模式,每個協(xié)程只在一個線程中運行,這樣協(xié)程和線程沒區(qū)別,雖然用了多核,但是線程切換開銷大。
M:N,Golang 模式,多個協(xié)程在多個線程上切換,既可以用到多核,又可以減少切換開銷。(當都是 CPU 密集時,在多核上切換好,當都是io密集時,在單核上切換好)。
從協(xié)程通信和調度機制來看:
其他文章的對比
http://www.dbjr.com.cn/article/160979.htm
async是非搶占式的,一旦開始采用 async 函數,那么你整個程序都必須是 async 的,不然總會有阻塞的地方(一遇阻塞對于沒有實現異步特性的庫就無法主動讓調度器調度其他協(xié)程了),也就是說 async 具有傳染性。
Python 整個異步編程生態(tài)的問題,之前標準庫和各種第三方庫的阻塞性函數都不能用了,如:requests,redis.py,open 函數等。所以 Python3.5后加入協(xié)程的最大問題不是不好用,而是生態(tài)環(huán)境不好,歷史包袱再次上演,動態(tài)語言基礎上再加上多核之間的任務調度,應該是很難的技術吧,真心希望python4.0能優(yōu)化或者放棄GIL鎖,使用多核提升性能。
goroutine 是 go 與生俱來的特性,所以幾乎所有庫都是可以直接用的,避免了 Python 中需要把所有庫重寫一遍的問題。
goroutine 中不需要顯式使用 await 交出控制權,但是 Go 也不會嚴格按照時間片去調度 goroutine,而是會在可能阻塞的地方插入調度。goroutine 的調度可以看做是半搶占式的。
以上就是Python和Golang協(xié)程的區(qū)別的詳細內容,更多關于Python和Golang協(xié)程的區(qū)別的資料請關注腳本之家其它相關文章!
相關文章
瀏覽器常用基本操作之python3+selenium4自動化測試(基礎篇3)
瀏覽器常用基本操作有很多種,今天給大家介紹python3+selenium4自動化測試的操作方法,是最最基礎的一篇,對python3 selenium4自動化測試相關知識感興趣的朋友一起看看吧2021-05-05