Python 的協程和 goroutine 有什么區(qū)別?
最近在做后端服務python到go的遷移和重構,這兩種語言里,最大的特色和優(yōu)勢就是都支持協程。之前主要做python的性能優(yōu)化和架構優(yōu)化,一開始覺得兩個協程原理和應用應該差不多,后來發(fā)現還是有很大的區(qū)別,今天就在這里總結一下。
什么是協程
在說它們兩者區(qū)別前,我們首先聊一下什么是協程,好像它沒有一個官方的定義,那就結合平時的應用經驗和學習內容來談談自己的理解。
協程,其實可以理解為一種特殊的程序調用。特殊的是在執(zhí)行過程中,在子程序(或者說函數)內部可中斷,然后轉而執(zhí)行別的子程序,在適當的時候再返回來接著執(zhí)行。
注意,它有兩個特征:
- 可中斷,這里的中斷不是普通的函數調用,而是類似CPU的中斷,CPU在這里直接釋放轉到其他程序斷點繼續(xù)執(zhí)行。
- 可恢復,等到合適的時候,可以恢復到中斷的地方繼續(xù)執(zhí)行,至于什么是合適的時候,我們后面再探討。
和進程線程的區(qū)別
上面兩個特點就導致了它相對于線程和進程切換來說極高的執(zhí)行效率,為什么這么說呢?我們先老生常談地說一下進程和線程。
進程是操作系統資源分配的基本單位,線程是操作系統調度和執(zhí)行的最小單位。這兩句應該是我們最常聽到的兩句話,拆開來說,進程是程序的啟動實例,擁有代碼和打開的文件資源、數據資源、獨立的內存空間。線程從屬于進程,是程序的實際執(zhí)行者,一個進程至少包含一個主線程,也可以有更多的子線程,線程擁有自己的??臻g。無論是進程還是線程,都是由操作系統所管理和切換的。
我們再來看協程,它又叫做微線程,但其實它和進程還有線程完全不是一個維度上的概念。進程和線程的切換完全是用戶無感,由操作系統控制,從用戶態(tài)到內核態(tài)再到用戶態(tài)。而協程的切換完全是程序代碼控制的,在用戶態(tài)的切換,就像函數回調的消耗一樣,在線程的棧內即可完成。
python的協程(coroutine)
python的協程其實是我們通常意義上的協程Goroutine。
從概念上來講,python的協程同樣是在適當的時候可中斷可恢復。那么什么是適當的時候呢,就是你認為適當的時候,因為程序在哪里發(fā)生協程切換完全控制在開發(fā)者手里。當然,對于python來說,由于GIL鎖,在CPU密集的代碼上做協程切換是沒啥意義的,CPU本來就在忙著沒偷懶,切換到其他協程,也只是在單核內換個地方忙而已。很明顯,我們應該在IO密集的地方來起協程,這樣可以讓CPU不再空等轉而去別的地方干活,才能真正發(fā)揮協程的威力。
從實現上來講,如果熟知了python生成器,還可以將協程理解為生成器+調度策略,生成器中的yield關鍵字,就可以讓生成器函數發(fā)生中斷,而調度策略,可以驅動著協程的執(zhí)行和恢復。這樣就實現了協程的概念。這里的調度策略可能有很多種,簡單的例如忙輪循:while True,更簡單的甚至是一個for循環(huán)。就可以驅動生成器的運行,因為生成器本身也是可迭代的。復雜的比如可能是基于epool的事件循環(huán),在python2的tornado中,以及python3的asyncio中,都對協程的用法做了更好的封裝,通過yield和await就可以使用協程,通過事件循環(huán)監(jiān)控文件描述符狀態(tài)來驅動協程恢復執(zhí)行。
我們看一個簡單的協程:
- 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:
- nn = 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)
很明顯這是一個傳統的生產者-消費者模型,這里consumer函數就是一個協程(生成器),它在n = yield r 的地方發(fā)生中斷,生產者produce中的c.send(n),可以驅動協程的恢復,并且向協程函數傳遞數據n,接收返回結果r。而while n < 5,就是我們所說的調度策略。在生產中,這種模式很適合我們來做一些pipeline數據的消費,我們不需要寫死幾個生產者進程幾個消費者進程,而是用這種協程的方式,來實現CPU動態(tài)地分配調度。
如果你看過上篇文章的話,是不是發(fā)現這個golang中流水線模型有點像呢,也是生產者和消費者間進行通信,但go是通過channel這種安全的數據結構,為什么python不需要呢,因為python的協程是在單線程內切換本身就是安全的,換句話說,協程間本身就是串行執(zhí)行的。而golang則不然。思考一個有意思的問題,如果我們將go流水線模型中channel設置為無緩沖區(qū)時,生產者絕對驅動消費者的執(zhí)行,是不是就跟python很像了呢。所以python的協程從某種意義來說,是不是golang協程的一種特殊情況呢?
后端在線服務中我們更常用的python協程其實是在異步IO框架中使用,之前我們也提過python協程在IO密集的系統中使用才能發(fā)揮它的威力。并且大多數的數據中間件都已經提供支持了異步包的支持,這里順便貼一個python3支持的異步IO庫,基本支持了常見的異步數據中間件。
再看一個我們業(yè)務代碼中的片段,asyncio支持的原生協程:
asyncio支持的基于epool的事件循環(huán):
- def main():
- define_options()
- options.parse_command_line()
- # 使用uvloop代替原生事件循環(huán)
- # asyncio.set_event_loop_policy(uvloop.EventLoopPolicy())
- app = tornado.web.Application(handlershandlers=handlers, debug=options.debug)
- http_server = tornado.httpserver.HTTPServer(app)
- http_server.listen(options.port)
- asyncio.get_event_loop().run_forever()
async/await支持的原生協程:
- class RcOutputHandler(BaseHandler):
- async def post(self):
- status, msg, user = self.check_args('uid', 'order_no', 'mid', 'phone', 'name', 'apply_id',
- 'product_id')
- if status != ErrorCodeConfig.SUCCESS:
- status, msg, report = status, msg, None
- else:
- rcoutput_flow_instance = ZHANRONG_CUSTOM_PRODUCTID_RCFLOW_MAP.get(user.product_id,
- RcOutputFlowControler())
- status, msg, report = await rcoutput_flow_instance.get_rcoutput_result(user)
- res = self.generate_response_data(status, msg, report)
- await self.finish(res)
- # 陪跑流程
- await AccompanyRunningFlowControler().get_accompany_data(user)
python協程的特點
單線程內切換,適用于IO密集型程序中,可以最大化IO多路復用的效果。
無法利用多核。
協程間完全同步,不會并行。不需要考慮數據安全。
用法多樣,可以用在web服務中,也可用在pipeline數據/任務消費中。
golang的協程(goroutine)
golang的協程就和傳統意義上的協程不大一樣了,兼具協程和線程的優(yōu)勢。這也是go最大的特色,就是從語言層面支持并發(fā)。Go語言里,啟動一個goroutine很容易:go function 就行。
同樣從概念上來講,golang的協程同樣是在適當的時候可中斷可恢復。當協程中發(fā)生channel讀寫的阻塞或者系統調用時,就會切換到其他協程。具體的代碼示例可以看上篇文章,就不再贅述了。
從實現上來說,goroutine可以在多核上運行,從而實現協程并行,我們先直接看下go的調度模型MPG。
如上圖,M指的是Machine,一個M直接關聯了一個內核線程。由操作系統管理。
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個數)
- 調用系統api創(chuàng)建線程,不同的操作系統,調用不一樣,其實就是和c語言創(chuàng)建過程是一致的
- 然后創(chuàng)建的這個線程里面才是真正做事的,循環(huán)執(zhí)行G任務
當協程發(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后,運行時系統會根據這個數量創(chuàng)建n個P。
3、M何時創(chuàng)建:沒有足夠的M來關聯P并運行其中的可運行的G。比如所有的M此時都阻塞住了,而P中還有很多就緒任務,就會去尋找空閑的M,而沒有空閑的,就會去創(chuàng)建新的M。
Go協程的特點
協程間需要保證數據安全,比如通過channel或鎖。
可以利用多核并行執(zhí)行。
協程間不完全同步,可以并行運行,具體要看channel的設計。
搶占式調度,可能無法實現公平。
coroutine(python)和goroutine(go)的區(qū)別
除了python,C#, Lua語言都支持 coroutine 特性。coroutine 與 goroutine 在名字上類似,都是可中斷可恢復的協程,它們之間最大的不同是,goroutine 可能在多核上發(fā)生并行執(zhí)行,單但 coroutine 始終是順序執(zhí)行。也基于此,我們應該清楚coroutine適用于IO密集程序中,而goroutine在 IO密集和CPU密集中都有很好的表現。不過話說回來,go就一定比python快么,假如在完全IO并發(fā)密集的程序中,python的表現反而更好,因為單線程內的協程切換效率更高。
從運行機制上來說,coroutine 的運行機制屬于協作式任務處理, 程序需要主動交出控制權,宿主才能獲得控制權并將控制權交給其他 coroutine。如果開發(fā)者無意間或者故意讓應用程序長時間占用 CPU,操作系統也無能為力,表現出來的效果就是計算機很容易失去響應或者死機。goroutine 屬于搶占式任務處理,已經和現有的多線程和多進程任務處理非常類似, 雖然無法控制自己獲取高優(yōu)先度支持。但如果發(fā)現一個應用程序長時間大量地占用 CPU,那么用戶有權終止這個任務。
從協程:線程的對應方式來看:
N:1,Python協程模式,多個協程在一個線程中切換。在IO密集時切換效率高,但沒有用到多核
1:1,Java多線程模式,每個協程只在一個線程中運行,這樣協程和線程沒區(qū)別,雖然用了多核,但是線程切換開銷大。
1:1,go模式,多個協程在多個線程上切換,既可以用到多核,又可以減少切換開銷。(當都是cpu密集時,在多核上切換好,當都是io密集時,在單核上切換好)。
從協程通信和調度機制來看: