這篇文章闡述Celery是如何淘汰GIL和協(xié)同程序的。
最近,我重讀了Glyph寫的Unyielding。如果你還沒有讀過,那趕緊去。我將會在下文略述它的內(nèi)容,但是,原文絕對值得一讀。
近十年我都在研究Python全局解釋器鎖,即GIL。
關(guān)于GIL,真正的問題是異步I/O--線程就是作為處理它的簡潔方法推廣的。你接收到一個請求,你創(chuàng)建一個線程,魔法發(fā)生了。關(guān)注是分開的而資源是共享的。
但是在Python里,你不能高效的這樣做,因為線程需要爭奪GIL,而每個解釋器只有一個GIL,無論你的機器有多少核。所以,即使你使用頂配的英特爾酷睿i7處理器,你也不會覺得使用線程使性能有很大提升。
理論上是這樣的,現(xiàn)實可能更糟糕--Python 3.1之前的GIL實際上在多核處理器上處理多線程時性能更糟糕并且可能使你的代碼變得更慢。
異步I/O是問題嗎?
現(xiàn)代編程中我們做的大多數(shù)任務(wù)都可以歸結(jié)為I/O,或者通常這樣回答:
例如,從數(shù)據(jù)庫取數(shù)是I/O--你等待數(shù)據(jù)的時候,系統(tǒng)可以同時做其他事,比如,服務(wù)更多請求。
asyncio最近向Python添加的內(nèi)容是
使用協(xié)同程序(coroutines)編寫單線程并發(fā)代碼,通過socket和其他資源實現(xiàn)I/O復(fù)用,運行網(wǎng)絡(luò)服務(wù)器和客戶端
這里面有一些假設(shè),我將分析一下:
人們需要單線程并發(fā)代碼
人們經(jīng)常需要I/O復(fù)用
人們使用協(xié)同程序
首先,我們真的需要單線程并發(fā)執(zhí)行代碼嗎?
在過去10年里,我從來沒有遇見一個人指出“這個代碼需要并發(fā)執(zhí)行但是使用單線程。”
我認為這里的意思其實是我們需要并發(fā)執(zhí)行,這是GIL最具爭議的地方--我們不能實現(xiàn)真正的并發(fā)。
我最近意識到我們真的不需要并發(fā)--稍后討論這點,此前,我們來列出后面這兩條假設(shè),扔掉協(xié)同程序。
人們使用協(xié)同程序嗎?是的,但是不用在生產(chǎn)環(huán)境中。這可能有點武斷,但是我使用很多種語言編寫并發(fā)程序,并且從來沒有遇到過像協(xié)同程序那么難讀的。
如果代碼意味著可讀,那么協(xié)同程序就意味著弄瞎你的眼睛。
另外,協(xié)作并發(fā)又叫協(xié)同程序在很久之前就被拋棄了。為什么?
因為協(xié)作程序的最基本假設(shè)是他們合作。搶先并發(fā)強制,或者至少嘗試強制,公平使用資源。
協(xié)同程序并沒有這樣幸運--如果你的協(xié)同程序阻塞了,你必須等待。你的線程等待所有其他的協(xié)同程序。
那是協(xié)同程序在現(xiàn)實中的最大問題。如果你的程序是一個shell腳本,用于計算斐波那契數(shù),那或許還行。但是在這種困境,服務(wù)器斷掉,連接超時,我們不能閱讀讀取任何安裝的開源庫的代碼。
回到并發(fā)執(zhí)行--我限制可以使用的線程數(shù)了嗎?不,一次也沒有。
我覺得我們既不需要協(xié)同程序,也不需要并發(fā)性。
我認為我們需要的,并且值得花費精力研究的是,非阻塞代碼。
阻塞代碼的問題
代碼是阻塞的是說代碼具有以下兩個特征之一:
真得阻塞
完成需要很長時間
人類是沒有耐心的,但是在等待機器完成操作時我們看起來更急不可耐。長時間等待,還是避免阻塞,其實是同一個問題。
我們不需要阻塞一些可以很快做完的事,比如,等待一些慢的操作(數(shù)據(jù)庫請求)完成時,可以響應(yīng)用戶。
搶先并發(fā)(線程)是解決這個問題的好方法,有一些優(yōu)點:
代碼可讀性好
更好的利用資源
對于線程可讀性--不多說了,他們不是最簡單的可以理解的,但是絕對比協(xié)同程序好。
關(guān)于資源--那真得是從“更好一點”到“不可置信”轉(zhuǎn)變的實現(xiàn)細節(jié)。他們中的大部分可以使用雙核機器中的兩個核。
在經(jīng)典線程編程我們可以:
現(xiàn)在我們也可以用Python這樣做--不完成一樣,但是接近。我們可以把執(zhí)行處理數(shù)據(jù)的代碼放進一個進程里而不使用thread_procession_data。我當(dāng)然是在說超級棒的multiprocession庫。
但是,那樣真的更好嗎?
我仍然需要理解幾個概念,尤其是進程間是如何共享資源的,那看起來不是很明顯。
有更好的方法嗎?
無鎖的勝利和Celery
作為程序員我只想要不阻塞的代碼。
我不關(guān)心是通過進程,線程,事物內(nèi)存還是魔法實現(xiàn)的。
創(chuàng)建一個工作單元,描述它的參數(shù),比如優(yōu)先級,你的工作完成了。在Python世界里有一個包可以滿足你--Celery。
Celery是一個龐大的項目,開始你的第一個任務(wù)前有繁雜的配置。但是一旦它開始工作,就變得美妙。
舉個例子,工作中我有一個系統(tǒng),在各種各樣的網(wǎng)絡(luò)入口拉取一個社會股。調(diào)用API需要時間,還需要加上網(wǎng)絡(luò)連接收發(fā)數(shù)據(jù)的時間。
例如:
有了Celery,我就可以用一個任務(wù)(task)包裹update_metrics,然后這樣做:
update_metrics 是一個耗時操作,但是并沒有阻塞
queue參數(shù)指定執(zhí)行任務(wù)的隊列
update_metrics耗時很長--但是多虧Celery我不需要考慮那些:
由用戶動作準(zhǔn)確觸發(fā)
代碼可讀性高,并且非常明確
資源可用時便會使用
最重要的是:我不必再苦惱于代碼是否在執(zhí)行I/O,我是否應(yīng)該讓出,或者它是被CPU或I/O強迫的。
Celery可以做的事
你的問題有:抓取1000 URLs,然后計算用戶在表格里指定的3個詞的頻率。
通常,這很難--你需要定位將要抓取的URLs。連接可能超時,你需要一直等待直到所有任務(wù)完成,并且你需要以某種方式存儲用戶的輸入。
不使用Celery,搞清楚哪里以及如何存儲數(shù)據(jù)就是一個噩夢。使用Celery我只需要任務(wù):
chain用來在任務(wù)間建造通道,一個的輸出成為另一個的輸入
chord用來將任務(wù)分組,使一個任務(wù)在其他任務(wù)都完成后才執(zhí)行
這樣寫優(yōu)點有很多:
你不需要了解它是如何執(zhí)行的??梢允蔷€程或進程或協(xié)同程序。(在某種程度上,Celery支持所有類型池)
你不???
對了,這個例子里也有一些缺點:
因為Celery任務(wù)是函數(shù),我們不得不使用scrape_url.subtask(args=(url,))語法,它并不易讀
Celery需要明確的任務(wù)路徑,作為內(nèi)嵌函數(shù)的任務(wù),通常,task.py模塊--不能在其他任務(wù)中定義或者提交任務(wù)
因為我們不能在一個任務(wù)的內(nèi)部定義或者通過調(diào)用另一個任務(wù)串聯(lián)起任務(wù),需要chord和chain這樣的對象,而這些對象使代碼變復(fù)雜
無鎖?
拋開前面列出的問題,對我而言,最大的問題是細粒度控制的缺乏。隊列是一個偉大的實現(xiàn)無鎖編程的基本模型。
前面的例子假設(shè)你需要執(zhí)行一堆任務(wù)然后聚合結(jié)果--大約30行代碼的map/reduce。
但是讓我們考慮一種更加困難的情形--假設(shè)我們有一個不支持并發(fā)的任務(wù),完全是無鎖的,但是需要讀寫而不使用阻塞。
我們應(yīng)該怎么做?
首先(這里我假設(shè)你使用Django整合)
當(dāng)然,這有一個主要問題--我們不能同時讀取,即使可以。
結(jié)語
對我來講,這總結(jié)了整個GIL和協(xié)同程序/同步的爭論。我認為Python核心的主要問題是它很大程度上由C啟發(fā)。但是,在這里我認為這是Python的缺陷。
而且我不知道這方面努力的原因。
有數(shù)百家公司運行Python代碼作為連接邏輯(glue logic)--單線程同步代碼(看看Django多受歡迎)但是這些公司服務(wù)數(shù)以萬計的用戶。
我認為如果我們想要Python完全支持同步,這是方法。引入基于隊列的完全無鎖以及允許編程人員修改隊列。
Celery已經(jīng)實現(xiàn)了其中的大部分。為了在Python里擁有這些,我們需要擴展解釋器來管理任務(wù)執(zhí)行單元和隊列,添加一些語法糖衣用來進行內(nèi)嵌任務(wù)的定義和調(diào)用以便使用。
作為編程人員,我認為我們從來不需要同步代碼。我從來不需要協(xié)同程序,也從來不需要多重I/O。
我需要的是高效表達想法和我想到它的方式的工具,抽象線程、拋棄同步、使用無鎖例程解決了這個問題。
英文原文:http://blog.domanski.me/how-celery-fixed-pythons-gil-problem/
譯者:CupKnight