本文原創並首發於公衆號【Python貓】,未經受權,請勿轉載。python
原文地址:https://mp.weixin.qq.com/s/8KvQemz0SWq2hw-2aBPv2Qgit
花下貓語: Python 中最廣爲人詬病的一點,大概就是它的 GIL 了。因爲 GIL 的存在,Python 沒法實現真正的多線程編程,所以不少人都把這視做 Python 最大的軟肋。github
PEP-554 提出後(2017年9月),大夥彷佛看到了一線改善的曙光。然而,GIL 真的能夠被完全殺死麼,若是能夠的話,它會怎麼實現呢,爲何等了一年多還沒實現,仍須要咱們等待多長時間呢?編程
英文 | Has the Python GIL been slain?【1】json
做者 | Anthony Shaw數組
譯者 | 豌豆花下貓緩存
聲明 :本文得到原做者受權翻譯,轉載請保留原文出處,請勿用於商業或非法用途。網絡
2003 年初,Intel 公司推出了全新的奔騰 4 「HT」 處理器,該處理器的主頻(譯註:CPU 內核工做的時鐘頻率)爲 3 GHz,採用了「超線程」技術。多線程
在接下來的幾年中,Intel 和 AMD 激烈競爭,經過提升總線速度、L2 緩存大小和減少芯片尺寸以最大限度地減小延遲,努力地實現最佳的臺式機性能。3Ghz 的 HT 在 2004 年被「Prescott」的 580 型號取代,該型號的主頻高達 4 GHz。併發
彷佛提高性能的最好方法就是提升處理器的主頻,但 CPU 卻受到高功耗和散熱會影響全球變暖的困擾。
你電腦上有 4Ghz 的 CPU 嗎?不太可能,由於性能的前進方式是更高的總線速度和更多的內核。Intel 酷睿 2 代在 2006 年取代了奔騰 4 ,主頻遠低於此。
除了發佈消費級的多核 CPU,2006 年還發生了其它事情,Python 2.5 發佈了!Python 2.5 帶來了人見人愛的 with 語句的 beta 版本 。
在使用 Intel 的酷睿 2 或 AMD 的 Athlon X2 時,Python 2.5 有一個重要的限制——GIL 。
GIL 即全局解釋器鎖(Global Interpreter Lock),是 Python 解釋器中的一個布爾值,受到互斥保護。這個鎖被 CPython 中的核心字節碼用來評估循環,並調節用來執行語句的當前線程。
CPython 支持在單個解釋器中使用多線程,但線程們必須得到 GIL 的使用權才能執行操做碼(作低級操做)。這樣作的好處是,Python 開發人員在編寫異步代碼或多線程代碼時,徹底沒必要操心如何獲取變量上的鎖,也不需擔憂進程由於死鎖而崩潰。
GIL 使 Python 中的多線程編程變得簡單。
GIL 還意味着雖然 CPython 能夠是多線程的,但在任何給定的時間裏只能執行 1 個線程。這意味着你的四核 CPU 會像上圖同樣工做 (減去藍屏,希望如此)。
當前版本的 GIL 是在2009年編寫的 【2】,用於支持異步功能,幾乎沒被改動地存活了下來,即便曾經屢次試圖刪除它或減小對它的依賴。
全部提議移除 GIL 的訴求是,它不該該下降單線程代碼的性能。任何曾在 2003 年啓用超線程(Hyper-Threading)的人都會明白爲何 這很重要 【3】。
若是你想在 CPython 中使用真正的併發代碼,則必須使用多進程。
在 CPython 2.6 中,標準庫裏增長了 multiprocessing
模塊。multiprocessing 是 CPython 大量產生的進程的包裝器(每一個進程都有本身的GIL)——
from multiprocessing import Process def f(name): print 'hello', name if __name__ == '__main__': p = Process(target=f, args=('bob',)) p.start() p.join()
進程能夠從主進程中「孵出」,經過編譯好的 Python 模塊或函數發送命令,而後從新歸入主進程。
multiprocessing
模塊還支持經過隊列或管道共享變量。它有一個 Lock 對象,用於鎖定主進程中的對象,以便其它進程可以寫入。
多進程有一個主要的缺陷:它在時間和內存使用方面的開銷很大。CPython 的啓動時間,即便沒有非站點(no-site),也是 100-200ms(參見 這個連接 【4】)。
所以,你能夠在 CPython 中使用併發代碼,可是你必須仔細規劃那些長時間運行的進程,這些進程之間極少共享對象。
另外一種替代方案是使用像 Twisted 這樣的三方庫。
小結一下,CPython 中使用多線程很容易,但它並非真正的併發,多進程雖然是併發的,但開銷卻極大。
有沒有更好的方案呢?
繞過 GIL 的線索就在其名稱中,全局 解釋器 鎖是全局解釋器狀態的一部分。 CPython 的進程能夠有多個解釋器,所以能夠有多個鎖,可是此功能不多使用,由於它只經過 C-API 公開。
在爲 CPython 3.8 提出的特性中有個 PEP-554,提議實現子解釋器(sub-interpreter),以及在標準庫中提供一個新的帶有 API 的 interpreters
模塊。
這樣就能夠在 Python 的單個進程中建立出多個解釋器。Python 3.8 的另外一個改動是解釋器都將擁有單獨的 GIL ——
由於解釋器的狀態包含內存分配競技場(memory allocation arena),即全部指向 Python 對象(局地和全局)的指針的集合,因此 PEP-554 中的子解釋器沒法訪問其它解釋器的全局變量。
與多進程相似,在解釋器之間共享對象的方法是採用 IPC 的某種形式(網絡、磁盤或共享內存)來作序列化。在 Python 中有許多方法能夠序列化對象,例如 marshal
模塊、 pickle
模塊、以及像 json
和 simplexml
這樣更標準化的方法 。這些方法褒貶不一,但無一例外會形成額外的開銷。
最佳方案是開闢一塊共享的可變的內存空間,由主進程來控制。這樣的話,對象能夠從主解釋器發送,並由其它解釋器接收。這將是 PyObject 指針的內存管理空間,每一個解釋器均可以訪問它,同時由主進程擁有對鎖的控制權。
這樣的 API 仍在制定中,但它可能以下所示:
import _xxsubinterpreters as interpreters import threading import textwrap as tw import marshal # Create a sub-interpreter interpid = interpreters.create() # If you had a function that generated some data arry = list(range(0,100)) # Create a channel channel_id = interpreters.channel_create() # Pre-populate the interpreter with a module interpreters.run_string(interpid, "import marshal; import _xxsubinterpreters as interpreters") # Define a def run(interpid, channel_id): interpreters.run_string(interpid, tw.dedent(""" arry_raw = interpreters.channel_recv(channel_id) arry = marshal.loads(arry_raw) result = [1,2,3,4,5] # where you would do some calculating result_raw = marshal.dumps(result) interpreters.channel_send(channel_id, result_raw) """), shared=dict( channel_id=channel_id ), ) inp = marshal.dumps(arry) interpreters.channel_send(channel_id, inp) # Run inside a thread t = threading.Thread(target=run, args=(interpid, channel_id)) t.start() # Sub interpreter will process. Feel free to do anything else now. output = interpreters.channel_recv(channel_id) interpreters.channel_release(channel_id) output_arry = marshal.loads(output) print(output_arry)
此示例使用了 numpy ,並經過使用 marshal 模塊對其進行序列化來在通道上發送 numpy 數組 ,而後由子解釋器來處理數據(在單獨的 GIL 上),所以這會是一個計算密集型(CPU-bound)的併發問題,適合用子解釋器來處理。
marshal
模塊至關快,但仍不如直接從內存中共享對象那樣快。
PEP-574 提出了一種新的 pickle 【5】協議(v5),它支持將內存緩衝區與 pickle 流的其他部分分開處理。對於大型數據對象,將它們一次性序列化,再由子解釋器反序列化,這會增長不少開銷。
新的 API 能夠( 假想 ,並無合入)像這樣提供接口:
import _xxsubinterpreters as interpreters import threading import textwrap as tw import pickle # Create a sub-interpreter interpid = interpreters.create() # If you had a function that generated a numpy array arry = [5,4,3,2,1] # Create a channel channel_id = interpreters.channel_create() # Pre-populate the interpreter with a module interpreters.run_string(interpid, "import pickle; import _xxsubinterpreters as interpreters") buffers=[] # Define a def run(interpid, channel_id): interpreters.run_string(interpid, tw.dedent(""" arry_raw = interpreters.channel_recv(channel_id) arry = pickle.loads(arry_raw) print(f"Got: {arry}") result = arry[::-1] result_raw = pickle.dumps(result, protocol=5) interpreters.channel_send(channel_id, result_raw) """), shared=dict( channel_id=channel_id, ), ) input = pickle.dumps(arry, protocol=5, buffer_callback=buffers.append) interpreters.channel_send(channel_id, input) # Run inside a thread t = threading.Thread(target=run, args=(interpid, channel_id)) t.start() # Sub interpreter will process. Feel free to do anything else now. output = interpreters.channel_recv(channel_id) interpreters.channel_release(channel_id) output_arry = pickle.loads(output) print(f"Got back: {output_arry}")
確實,這個例子使用的是低級的子解釋器 API。若是你使用了多進程庫,你將會發現一些問題。它不像 threading
那麼簡單,你不能想着在不一樣的解釋器中使用同一串輸入來運行同一個函數(目前還不行)。
一旦合入了這個 PEP,我認爲 PyPi 中的其它一些 API 也會採用它。
簡版回答 :大於一個線程,少於一個進程。
詳版回答 :解釋器有本身的狀態,所以雖然 PEP-554 可使建立子解釋器變得方便,但它還須要克隆並初始化如下內容:
核心配置能夠很容易地從內存克隆,但導入的模塊並不那麼簡單。在 Python 中導入模塊的速度很慢,所以,若是每次建立子解釋器都意味着要將模塊導入另外一個命名空間,那麼收益就會減小。
標準庫中 asyncio
事件循環的當前實現是建立須要求值的幀(frame),但在主解釋器中共享狀態(所以共享 GIL)。
在 PEP-554 被合入後,極可能是在 Python 3.9,事件循環的替代實現 可能 是這樣(儘管尚未人這樣幹):在子解釋器內運行 async 方法,所以會是併發的。
額,還不能夠。
由於 CPython 已經使用單解釋器的實現方案很長時間了,因此代碼庫的許多地方都在使用「運行時狀態」(Runtime State)而不是「解釋器狀態」(Interpreter State),因此假如要將當前的 PEP-554 合入的話,將會致使不少問題。
例如,垃圾收集器(在 3.7 版本前)的狀態就屬於運行時。
在 PyCon sprint 期間(譯註:PyCon 是由 Python 社區舉辦的大型活動,做者指的是官方剛在美國舉辦的這場,時間是2019年5月1日至5月9日。sprint 是爲期 1-4 天的活動,開發者們自願加入某個項目,進行「衝刺」開發。該詞被敏捷開發團隊使用較多,含義與形式會略有不一樣),更改已經開始 【6】將垃圾收集器的狀態轉到解釋器,所以每一個子解釋器將擁有它本身的 GC(本該如此)。
另外一個問題是在 CPython 代碼庫和許多 C 擴展中仍殘存着一些「全局」變量。所以,當人們忽然開始正確地編寫併發代碼時,咱們可能會遭遇到一些問題。
還有一個問題是文件句柄屬於進程,所以當你在一個解釋器中讀寫一個文件時,子解釋器將沒法訪問該文件(不對 CPython 做進一步更改的話)。
簡而言之,還有許多其它事情須要解決。
對於單線程的應用程序,GIL 仍然存活。所以,即使是合併了 PEP-554,若是你有單線程的代碼,它也不會忽然變成併發的。
若是你想在 Python 3.8 中使用併發代碼,那麼你就會遇到計算密集型的併發問題,那麼這多是張入場券!
Pickle v5 和用於多進程的共享內存多是在 Python 3.8(2019 年 10 月)實現,子解釋器將介於 3.8 和 3.9 之間。
若是你如今想要使用個人示例,我已經構建了一個分支,其中包含全部 必要的代碼 【7】
[1] Has the Python GIL been slain? https://hackernoon.com/has-th...
[2] 是在2009年編寫的: https://github.com/python/cpy...
[3] 這很重要: https://arstechnica.com/featu...
[4] 這個連接 : https://hackernoon.com/which-...
[5] PEP-574 提出了一種新的 pickle : https://www.python.org/dev/pe...
[6] 更改已經開始: https://github.com/python/cpy...
[7] 必要的代碼 : https://github.com/tonybalone...
公衆號【Python貓】, 本號連載優質的系列文章,有喵星哲學貓系列、Python進階系列、好書推薦系列、技術寫做、優質英文推薦與翻譯等等,歡迎關注哦。後臺回覆「愛學習」,免費得到一份學習大禮包。