Python 的協(xié)程和 goroutine 有什么區(qū)別?
最近在做后端服務(wù)python到go的遷移和重構(gòu),這兩種語言里,最大的特色和優(yōu)勢就是都支持協(xié)程。之前主要做python的性能優(yōu)化和架構(gòu)優(yōu)化,一開始覺得兩個協(xié)程原理和應(yīng)用應(yīng)該差不多,后來發(fā)現(xiàn)還是有很大的區(qū)別,今天就在這里總結(jié)一下。
什么是協(xié)程
在說它們兩者區(qū)別前,我們首先聊一下什么是協(xié)程,好像它沒有一個官方的定義,那就結(jié)合平時的應(yīng)用經(jīng)驗(yàn)和學(xué)習(xí)內(nèi)容來談?wù)勛约旱睦斫狻?/p>
協(xié)程,其實(shí)可以理解為一種特殊的程序調(diào)用。特殊的是在執(zhí)行過程中,在子程序(或者說函數(shù))內(nèi)部可中斷,然后轉(zhuǎn)而執(zhí)行別的子程序,在適當(dāng)?shù)臅r候再返回來接著執(zhí)行。
注意,它有兩個特征:
- 可中斷,這里的中斷不是普通的函數(shù)調(diào)用,而是類似CPU的中斷,CPU在這里直接釋放轉(zhuǎn)到其他程序斷點(diǎn)繼續(xù)執(zhí)行。
- 可恢復(fù),等到合適的時候,可以恢復(fù)到中斷的地方繼續(xù)執(zhí)行,至于什么是合適的時候,我們后面再探討。
和進(jìn)程線程的區(qū)別
上面兩個特點(diǎn)就導(dǎo)致了它相對于線程和進(jìn)程切換來說極高的執(zhí)行效率,為什么這么說呢?我們先老生常談地說一下進(jìn)程和線程。
進(jìn)程是操作系統(tǒng)資源分配的基本單位,線程是操作系統(tǒng)調(diào)度和執(zhí)行的最小單位。這兩句應(yīng)該是我們最常聽到的兩句話,拆開來說,進(jìn)程是程序的啟動實(shí)例,擁有代碼和打開的文件資源、數(shù)據(jù)資源、獨(dú)立的內(nèi)存空間。線程從屬于進(jìn)程,是程序的實(shí)際執(zhí)行者,一個進(jìn)程至少包含一個主線程,也可以有更多的子線程,線程擁有自己的??臻g。無論是進(jìn)程還是線程,都是由操作系統(tǒng)所管理和切換的。
我們再來看協(xié)程,它又叫做微線程,但其實(shí)它和進(jìn)程還有線程完全不是一個維度上的概念。進(jìn)程和線程的切換完全是用戶無感,由操作系統(tǒng)控制,從用戶態(tài)到內(nèi)核態(tài)再到用戶態(tài)。而協(xié)程的切換完全是程序代碼控制的,在用戶態(tài)的切換,就像函數(shù)回調(diào)的消耗一樣,在線程的棧內(nèi)即可完成。
python的協(xié)程(coroutine)
python的協(xié)程其實(shí)是我們通常意義上的協(xié)程Goroutine。
從概念上來講,python的協(xié)程同樣是在適當(dāng)?shù)臅r候可中斷可恢復(fù)。那么什么是適當(dāng)?shù)臅r候呢,就是你認(rèn)為適當(dāng)?shù)臅r候,因?yàn)槌绦蛟谀睦锇l(fā)生協(xié)程切換完全控制在開發(fā)者手里。當(dāng)然,對于python來說,由于GIL鎖,在CPU密集的代碼上做協(xié)程切換是沒啥意義的,CPU本來就在忙著沒偷懶,切換到其他協(xié)程,也只是在單核內(nèi)換個地方忙而已。很明顯,我們應(yīng)該在IO密集的地方來起協(xié)程,這樣可以讓CPU不再空等轉(zhuǎn)而去別的地方干活,才能真正發(fā)揮協(xié)程的威力。
從實(shí)現(xiàn)上來講,如果熟知了python生成器,還可以將協(xié)程理解為生成器+調(diào)度策略,生成器中的yield關(guān)鍵字,就可以讓生成器函數(shù)發(fā)生中斷,而調(diào)度策略,可以驅(qū)動著協(xié)程的執(zhí)行和恢復(fù)。這樣就實(shí)現(xiàn)了協(xié)程的概念。這里的調(diào)度策略可能有很多種,簡單的例如忙輪循:while True,更簡單的甚至是一個for循環(huán)。就可以驅(qū)動生成器的運(yùn)行,因?yàn)樯善鞅旧硪彩强傻?。?fù)雜的比如可能是基于epool的事件循環(huán),在python2的tornado中,以及python3的asyncio中,都對協(xié)程的用法做了更好的封裝,通過yield和await就可以使用協(xié)程,通過事件循環(huán)監(jiān)控文件描述符狀態(tài)來驅(qū)動協(xié)程恢復(fù)執(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:
- 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)
很明顯這是一個傳統(tǒng)的生產(chǎn)者-消費(fèi)者模型,這里consumer函數(shù)就是一個協(xié)程(生成器),它在n = yield r 的地方發(fā)生中斷,生產(chǎn)者produce中的c.send(n),可以驅(qū)動協(xié)程的恢復(fù),并且向協(xié)程函數(shù)傳遞數(shù)據(jù)n,接收返回結(jié)果r。而while n < 5,就是我們所說的調(diào)度策略。在生產(chǎn)中,這種模式很適合我們來做一些pipeline數(shù)據(jù)的消費(fèi),我們不需要寫死幾個生產(chǎn)者進(jìn)程幾個消費(fèi)者進(jìn)程,而是用這種協(xié)程的方式,來實(shí)現(xiàn)CPU動態(tài)地分配調(diào)度。
如果你看過上篇文章的話,是不是發(fā)現(xiàn)這個golang中流水線模型有點(diǎn)像呢,也是生產(chǎn)者和消費(fèi)者間進(jìn)行通信,但go是通過channel這種安全的數(shù)據(jù)結(jié)構(gòu),為什么python不需要呢,因?yàn)閜ython的協(xié)程是在單線程內(nèi)切換本身就是安全的,換句話說,協(xié)程間本身就是串行執(zhí)行的。而golang則不然。思考一個有意思的問題,如果我們將go流水線模型中channel設(shè)置為無緩沖區(qū)時,生產(chǎn)者絕對驅(qū)動消費(fèi)者的執(zhí)行,是不是就跟python很像了呢。所以python的協(xié)程從某種意義來說,是不是golang協(xié)程的一種特殊情況呢?
后端在線服務(wù)中我們更常用的python協(xié)程其實(shí)是在異步IO框架中使用,之前我們也提過python協(xié)程在IO密集的系統(tǒng)中使用才能發(fā)揮它的威力。并且大多數(shù)的數(shù)據(jù)中間件都已經(jīng)提供支持了異步包的支持,這里順便貼一個python3支持的異步IO庫,基本支持了常見的異步數(shù)據(jù)中間件。
再看一個我們業(yè)務(wù)代碼中的片段,asyncio支持的原生協(xié)程:
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支持的原生協(xié)程:
- 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協(xié)程的特點(diǎn)
單線程內(nèi)切換,適用于IO密集型程序中,可以最大化IO多路復(fù)用的效果。
無法利用多核。
協(xié)程間完全同步,不會并行。不需要考慮數(shù)據(jù)安全。
用法多樣,可以用在web服務(wù)中,也可用在pipeline數(shù)據(jù)/任務(wù)消費(fèi)中。
golang的協(xié)程(goroutine)
golang的協(xié)程就和傳統(tǒng)意義上的協(xié)程不大一樣了,兼具協(xié)程和線程的優(yōu)勢。這也是go最大的特色,就是從語言層面支持并發(fā)。Go語言里,啟動一個goroutine很容易:go function 就行。
同樣從概念上來講,golang的協(xié)程同樣是在適當(dāng)?shù)臅r候可中斷可恢復(fù)。當(dāng)協(xié)程中發(fā)生channel讀寫的阻塞或者系統(tǒng)調(diào)用時,就會切換到其他協(xié)程。具體的代碼示例可以看上篇文章,就不再贅述了。
從實(shí)現(xiàn)上來說,goroutine可以在多核上運(yùn)行,從而實(shí)現(xiàn)協(xié)程并行,我們先直接看下go的調(diào)度模型MPG。
如上圖,M指的是Machine,一個M直接關(guān)聯(lián)了一個內(nèi)核線程。由操作系統(tǒng)管理。
P指的是”processor”,代表了M所需的上下文環(huán)境,也是處理用戶級代碼邏輯的處理器。它負(fù)責(zé)銜接M和G的調(diào)度上下文,將等待執(zhí)行的G與M對接。
G指的是Goroutine,其實(shí)本質(zhì)上也是一種輕量級的線程。包括了調(diào)用棧,重要的調(diào)度信息,例如channel等。
每次go調(diào)用的時候,都會:
- 創(chuàng)建一個G對象,加入到本地隊(duì)列或者全局隊(duì)列
- 如果還有空閑的P,則創(chuàng)建一個M
- M會啟動一個底層線程,循環(huán)執(zhí)行能找到的G任務(wù)
- G任務(wù)的執(zhí)行順序是,先從本地隊(duì)列找,本地沒有則從全局隊(duì)列找(一次性轉(zhuǎn)移(全局G個數(shù)/P個數(shù))個,再去其它P中找(一次性轉(zhuǎn)移一半)
對于上面的第2-3步,創(chuàng)建一個M,其過程:
- 先找到一個空閑的P,如果沒有則直接返回,(哈哈,這個地方就保證了進(jìn)程不會占用超過自己設(shè)定的cpu個數(shù))
- 調(diào)用系統(tǒng)api創(chuàng)建線程,不同的操作系統(tǒng),調(diào)用不一樣,其實(shí)就是和c語言創(chuàng)建過程是一致的
- 然后創(chuàng)建的這個線程里面才是真正做事的,循環(huán)執(zhí)行G任務(wù)
當(dāng)協(xié)程發(fā)生阻塞切換時:
- M0出讓P
- 創(chuàng)建M1接管P及其任務(wù)隊(duì)列繼續(xù)執(zhí)行其他G。
- 當(dāng)阻塞結(jié)束后,M0會嘗試獲取空閑的P,失敗的話,就把當(dāng)前的G放到全局隊(duì)列的隊(duì)尾。
這里我們需要注意三點(diǎn):
1、M與P的數(shù)量沒有絕對關(guān)系,一個M阻塞,P就會去創(chuàng)建或者切換另一個M,所以,即使P的默認(rèn)數(shù)量是1,也有可能會創(chuàng)建很多個M出來。
2、P何時創(chuàng)建:在確定了P的最大數(shù)量n后,運(yùn)行時系統(tǒng)會根據(jù)這個數(shù)量創(chuàng)建n個P。
3、M何時創(chuàng)建:沒有足夠的M來關(guān)聯(lián)P并運(yùn)行其中的可運(yùn)行的G。比如所有的M此時都阻塞住了,而P中還有很多就緒任務(wù),就會去尋找空閑的M,而沒有空閑的,就會去創(chuàng)建新的M。
Go協(xié)程的特點(diǎn)
協(xié)程間需要保證數(shù)據(jù)安全,比如通過channel或鎖。
可以利用多核并行執(zhí)行。
協(xié)程間不完全同步,可以并行運(yùn)行,具體要看channel的設(shè)計(jì)。
搶占式調(diào)度,可能無法實(shí)現(xiàn)公平。
coroutine(python)和goroutine(go)的區(qū)別
除了python,C#, Lua語言都支持 coroutine 特性。coroutine 與 goroutine 在名字上類似,都是可中斷可恢復(fù)的協(xié)程,它們之間最大的不同是,goroutine 可能在多核上發(fā)生并行執(zhí)行,單但 coroutine 始終是順序執(zhí)行。也基于此,我們應(yīng)該清楚coroutine適用于IO密集程序中,而goroutine在 IO密集和CPU密集中都有很好的表現(xiàn)。不過話說回來,go就一定比python快么,假如在完全I(xiàn)O并發(fā)密集的程序中,python的表現(xiàn)反而更好,因?yàn)閱尉€程內(nèi)的協(xié)程切換效率更高。
從運(yùn)行機(jī)制上來說,coroutine 的運(yùn)行機(jī)制屬于協(xié)作式任務(wù)處理, 程序需要主動交出控制權(quán),宿主才能獲得控制權(quán)并將控制權(quán)交給其他 coroutine。如果開發(fā)者無意間或者故意讓應(yīng)用程序長時間占用 CPU,操作系統(tǒng)也無能為力,表現(xiàn)出來的效果就是計(jì)算機(jī)很容易失去響應(yīng)或者死機(jī)。goroutine 屬于搶占式任務(wù)處理,已經(jīng)和現(xiàn)有的多線程和多進(jìn)程任務(wù)處理非常類似, 雖然無法控制自己獲取高優(yōu)先度支持。但如果發(fā)現(xiàn)一個應(yīng)用程序長時間大量地占用 CPU,那么用戶有權(quán)終止這個任務(wù)。
從協(xié)程:線程的對應(yīng)方式來看:
N:1,Python協(xié)程模式,多個協(xié)程在一個線程中切換。在IO密集時切換效率高,但沒有用到多核
1:1,Java多線程模式,每個協(xié)程只在一個線程中運(yùn)行,這樣協(xié)程和線程沒區(qū)別,雖然用了多核,但是線程切換開銷大。
1:1,go模式,多個協(xié)程在多個線程上切換,既可以用到多核,又可以減少切換開銷。(當(dāng)都是cpu密集時,在多核上切換好,當(dāng)都是io密集時,在單核上切換好)。
從協(xié)程通信和調(diào)度機(jī)制來看: