Python 官方研討會:徹底移除 GIL 真的可行么?

在一年一度的 Python 核心開發(fā)者 sprint 會議期間,我們與 Sam Gross 舉行了一次會議,他是 nogil 的作者。nogil 是 Python 3.9 的分叉版本,移除了 GIL。這是一份非正式的會議紀要。
簡單總結(jié)
Sam 的工作證明了以他的方式刪除 GIL 是可行的,即生成的 Python 解釋器的性能良好,并且可以隨著 CPU 內(nèi)核的增加而擴展。為了最終達到正面的效果,還需要有其它看似無關(guān)的解釋器工作。
目前還不可能將 Sam 的更改合并到 CPython,因為他的更改是針對 3.9 分支進行的,便于用戶拿當前 pip 可安裝的庫和 C 擴展對 nogil 解釋器進行測試。如果要合并 nogil,就不得不基于 main 分支進行更改(目前 main 分支已規(guī)劃為 3.11)。
不要指望 Python 3.11 會移除 GIL。 將 Sam 的工作合并到 CPython 本身將是一個艱苦的過程,但這僅僅是所需的一部分:在 CPython 移除 GIL 之前,需要為社區(qū)制定一個良好的向后兼容的遷移計劃。這些都還沒有計劃好,所以我們認為時機還沒到。
有些人在談論如此巨大的變化時提到了 Python 4。核心開發(fā)人員當前沒有計劃發(fā)布 Python 4,事實上恰恰相反:我們正積極地避免發(fā)布 Python 4,因為 Python 2 到 3 的轉(zhuǎn)換對社區(qū)來說已經(jīng)足夠困難了。現(xiàn)在考慮或者擔心 Python 4,肯定還為時過早。
介紹 nogil
Sam 發(fā)布了他的代碼,同時還有一篇詳細的文章,解釋了該項目的動機和設計。
nogil 代碼地址:https://github.com/colesbury/nogil
他的設計可以總結(jié)為:
為了線程安全,將 Python 內(nèi)置的分配器
pymalloc替換成mimalloc,對字典和其它集合對象采用無鎖讀寫,同時提升效率(堆內(nèi)存布局允許在不維護顯式列表的情況下找到 GC 跟蹤的對象)用有偏見的引用計數(shù)(biased reference counting)替代非原子的急切的引用計數(shù)(non-atomic eager reference counting): 將每個對象與創(chuàng)建它的線程(稱為 owner thread)綁定; 對象在 owner thread 內(nèi)使用時,采用快速的非原子的局部型引用計數(shù); 對象在其它線程內(nèi)使用時,采用較慢的但原子的共享型引用計數(shù);
為了加快跨線程的對象訪問(因為會被原子的共享型引用計數(shù)拖慢),引入兩種技術(shù): 有些特殊對象是永生的,這意味著它們的引用計數(shù)永遠不會被計算,也永遠不會被釋放:這包含像 None、True、False 這樣的單例對象,小整數(shù)和常駐的字符串,以及靜態(tài)分配的內(nèi)置類型 PyTypeObjects; 其它全局可訪問對象使用延遲引用計數(shù)(deferred reference counting),如頂級的函數(shù)、代碼對象和模塊;它們不是永生的,并不總是在程序的生命周期內(nèi)存活;
調(diào)整循環(huán)的垃圾回收器成一個單線程的 stop-the-world 垃圾回收器: 等待所有線程在一個安全點(任何字節(jié)碼的邊界)掛起; 不等待阻塞在 I/O 的線程(使用 PyEval_ReleaseThread,相當于在當前 Python 中釋放 GIL);高效地構(gòu)造對象的列表,以便即時地釋放:得益于
mimalloc, GC 跟蹤的對象都保存在一個單獨的輕量級的堆中;將全局進程的 MRO 緩存遷移到局部線程里,避免查找 MRO 時的爭用;緩存失效仍然是全局性的;
修改內(nèi)置的集合類對象,使之成為線程安全的。
Sam 的設計文檔包含了這些設計元素的細節(jié),包含線程狀態(tài)與 GIL API 的信息,以及解釋器和字節(jié)碼的其它修改(用帶有累加器的寄存器 VM 替換堆棧VM;通過避免創(chuàng)建 C 語言的棧幀來優(yōu)化函數(shù)調(diào)用;ceval.c 的其它變更;標簽指針的使用;LOAD_ATTR、LOAD_METHOD、 LOAD_GLOBAL 操作碼的線程安全的元數(shù)據(jù);等等)。我建議你完整地閱讀它。
Python貓注:上文出現(xiàn)的“stop-the-world”,有時縮寫成“STW”,這是多數(shù)垃圾回收器的工作機制,表示在垃圾回收器工作時,其它線程全部暫時掛起,從而保證引用對象的準確更新,其缺點是對程序性能有所影響;“MRO”是“method resolution order”的縮寫,即“類方法解析順序”,表示在所有基類中搜索成員方法時的次序。
早期的基準測試
在 pyperformance 基準測試套上,作為概念驗證的 nogil 解釋器比 3.9 快 10%。據(jù)估計,在解釋器的全部修改中,移除 GIL 會導致性能變慢 9%,主要是因為有偏見的引用計數(shù)和延遲引用計數(shù)。換句話說,Python 3.9 加上 nogil 的所有更改,但不移除 GIL 本身,可以快 19%。然而,這樣并不能解決多核的可伸縮性問題。
順便說一下,nogil 的一些更改,比如將 C 調(diào)用棧與 Python 調(diào)用棧解耦,已經(jīng)在 Python 3.11 中實現(xiàn)了。事實上,我們有針對當前 main 分支的初步的基準測試 ,結(jié)果表明在單線程的性能上,Python 3.11 比 nogil 快 16%。
需要有更多的基準測試,特別是使用 Larry Hastings 在對 Gilectomy 進行測試時使用的基準測試(當時基于 Python 3.5,后來移植到 3.6 alpha 1)。
Python貓注:gilectomy 是由 GIL ectomy 兩個單詞組合而成,ectomy 是一個醫(yī)學上的術(shù)語“切除術(shù)”,可見這個項目的用意跟 nogil 是一樣的!這是 5-6 年前的項目,作者曾在 PyCon 大會上做過幾次分享。但這個項目反而導致 Python 總體性能下降了,最后無疾而終。
gilectomy 項目作者在 PyCon 上的分享:
2015年分享:https://www.youtube.com/watch?v=KVKufdTphKs
2016年分享:https://www.youtube.com/watch?v=P3AyI_u66Bw
2017年分享:https://www.youtube.com/watch?v=pLqv11ScGsQ
Sam 提醒我們,一個用戶程序在無 GIL 的 Python 上的伸縮性實際上取決于最終的代碼。如果不進行測試,就不可能預測代碼在沒有 GIL 的情況下表現(xiàn)如何。因此,如果提供一個單一的數(shù)字來說明無 GIL 的 Python 速度會提升 x 倍,這是不負責任的。
會議中向 Sam 提出的問題
為了清晰易懂,這里的問題基于會議上的內(nèi)容進行了重新排序。答案是由 Sam 的回答轉(zhuǎn)述而來的,并得到了他閱讀草稿后的認可。要注意的是,核心團隊的成員可能對其中一些主題有其它觀點。
Q:有哪些可感知的風險是阻礙 nogil 項目合入到 CPython 中的?
目前的代碼庫已經(jīng)證明了它在技術(shù)上的可行性。它可以運行,而且比普通的 CPython 解釋器和 Gilectomy 項目更具有可伸縮性和好性能。我在該項目中投入了將近兩年的全職工作。
這完全取決于社區(qū)對 C 擴展程序的改造程度,以確保它們不會導致解釋器徹底崩潰。然后,剩下的長尾就是社區(qū)要以一種既正確又可擴展的方式在應用程序中采用自由線程。這兩個是最大的挑戰(zhàn),但我們必須樂觀應對。
Q:你打算如何改進你的工作?對 commit 次序有什么建議嗎?你將如何保持你的工作與 main 分支的同步?
Sam 目前正在重構(gòu)他的工作,最初是基于 3.9.0a3,將匹配 3.9.7 最終版本。這項工作的一部分是將 commit 重構(gòu)為邏輯單元,以便更好地說明哪些內(nèi)容需要更改(哪些地方改了,以及為什么要改)。
目前還不計劃把這項工作移到 main 分支(未來的 3.11),因為這個分支太不穩(wěn)定了。相比之下,3.9 有大量已發(fā)布的可通過 pip 安裝的庫和 C 擴展,可用于測試。這使得 Sam 能夠評估該項目與真實世界的第三方代碼的行為。基于 main 的修改將花費不少時間,而這些時間本可以花在改進無 GIL 的解釋器上,所以,現(xiàn)在就基于主分支的話,還為時過早。
將工作進行分割然后再合并是可行的,但必須記住,許多更新需要在串聯(lián)起來時,性能才會提升。單獨而言,它們會導致(暫時的?)性能下降。
核心開發(fā)者注:我們現(xiàn)在不能合并對 3.9 分支所做的更改。在項目的這個階段使用 3.9 是有意義的,但關(guān)鍵的是要將它分割成可消費的數(shù)據(jù)塊,然后一個一個地合并到 main 分支中。一塊一塊地做,很有可能會損害性能,但這是唯一現(xiàn)實的集成途徑。
Q:可以只引入寄存器 VM 和編譯器而不做其它更改嗎?在不改變引用計數(shù)或 GIL 的情況下使用寄存器 VM 會有什么特殊的困難嗎?
VM 使用延遲/永生的引用計數(shù)。可以將其轉(zhuǎn)換為只使用經(jīng)典的引用計數(shù),但最終結(jié)果的效率還不清楚(例如,出于性能考慮,堆棧上的所有對象都使用了延遲引用計數(shù))。
Q:跟前一問相反的問題:只引入 nogil,而不使用新的寄存器 VM,會有什么困難呢?
雖然新的 VM 只提高了性能,而不是準確性,但它也提高了可伸縮性,使得無 GIL 的 Python 可以充分利用 CPU 內(nèi)核而不發(fā)生爭用。因此要使用 3.11 解釋器也是可行的,但最好保留一些寄存器 VM 的設計思想,這對可伸縮性和線程安全很重要。這需要做大量的工作。但是將寄存器 VM 更新成跟 main 分支一樣(以及修復遺留的 bug),也需要大量的工作。這兩種選擇都是可行的。
Q:對于那些不希望自己的代碼被其它線程并行運行的 C 擴展,有什么建議么?在適應新的自由線程環(huán)境之前,難道不需要 CPython 給它們提供一些 API 來彌補差距嗎?
這需要花時間。目標是漸進式采納,最終推廣至大多數(shù) C 擴展。GIL 可以作為解釋器啟動時的一個選項。如果沒有啟用 GIL,并且 C 擴展不支持新的操作模式,可能就要產(chǎn)生告警或者不讓其導入。Python 社區(qū)不得不適配 C 擴展,讓它們適應無 GIL 的模式。
作為概念驗證的 nogil 項目,默認使用無 GIL 模式,并接受任何 C 擴展。如果它被 CPython 采用了,那么在開始時默認應該啟用 GIL(要求在啟動 Python 時使用 -X nogil 禁用 GIL),以便讓第三方庫做適配。然后,在發(fā)布幾個版本后,默認值再切換成無 GIL 的模式。
雖然要移植全部東西并不容易(并行是很難的),但在多數(shù)情況下,移植并不會很難,特別是對于封裝外部庫的 C 擴展來說。
核心開發(fā)者注:有大量的“暗物質(zhì)” Python 代碼(和 C 擴展)不是開源的。我們需要小心不去破壞它們,因為它們的用戶可能無法做出所需的更改,或者向上游報告問題給我們。特別地,有些 C 擴展使用 GIL 來保護它們自己的內(nèi)部狀態(tài)。這是一個很大的擔憂,可能是采用無 GIL Python 的一個很大的障礙。
Q:你會添加一個 PEP-489 的“插槽”么,以便 C 擴展用來表示其支持 nogil,這樣當遇到不支持 nogil 的庫時,就不讓它導入?
很多人也提過,這可能是一個好主意,但我不完全清楚這意味著什么。選擇無 GIL 模式并不能保證沒有 bug。相反,在默認情況下,我們運行所有的擴展(現(xiàn)在的 nogil 就是這么做的)。不兼容的擴展可以使用 PyInit 模塊的代碼,主動地詢問解釋器是否啟用了 GIL,如果不兼容的話,就在導入時產(chǎn)生警告甚至異常。
Q:在運行期啟用 nogil 是一項長期可行的選擇,還是過渡性的功能呢?
理想的結(jié)局是 CPython 不再有 GIL,句號。然而,預計將有一個漫長的社區(qū)適應期。我們希望避免從 Python2 到 Python3 過渡時的斷裂。準確地說,我們希望過渡得越平滑越好,即使這意味著需要延展更長的時間。
Q: 確認一下,最終狀態(tài)是只有 nogil,并且不支持再開啟 GIL 么?
目前我們還不確定。理想的結(jié)局是只存在一個無 GIL 的 Python,但尚不清楚這能否實現(xiàn)。
Q:如果這些特性標志會持續(xù)很長一段時間,這是否意味著我們需要大幅增加測試矩陣?
是的,測試矩陣需要加倍。然而,測試無 GIL 版本可能是判斷經(jīng)典的 GIL 版本是否有效的一個很好的預測器。有必要偶爾(每晚?)運行啟用了 GIL 的測試。
核心開發(fā)者注:如果不做測試,代碼將加速退化。在 CPython 中,由于需要運行時間(例如測試引用泄漏時),我們不會在每次更改時都運行所有測試,但如果有更改導致每日測試失敗,我們會立即回退更改,因為在已經(jīng)失敗的構(gòu)建點之后,很可能會出現(xiàn)其它的回歸問題。
Q:你認為多個 Python 解釋器并行運行,每個解釋器一個 GIL 怎么樣?
Python貓注:給大家科普一下這個問題的背景,PEP-554 提議實現(xiàn)多解釋器來解決 GIL 的問題。這是在 2017 年提出的,受到挺多關(guān)注。在 2019 年時,我曾翻譯過《Has the Python GIL been slain?》介紹它。但是,目前該提案依然是草稿狀態(tài),具體的開發(fā)情況不甚明朗。
跟無 GIL 提案相比,這既是互補的,又是相互競爭的。在無 GIL 解釋器中也可以支持副解釋器。
目前還不清楚多解釋器方案能否實現(xiàn)。有了 nogil,就不需要擔心跨線程共享對象,也不需要擔心 C 擴展的兼容性,因為有了多解釋器,就沒有任何狀態(tài)是真正全局的,因此需要特別地隔離。對于可變對象,在多解釋器之間傳遞時,需要某種形式的序列化/反序列化。對于不可變對象,解釋器可能會添加特殊的支持,但如果它們不是已知的不可變的內(nèi)置類型,用戶代碼就需要適配這些對象。這是從 PyTorch 的相關(guān)工作中得到的啟發(fā),它使用了某種形式的多解釋器。
由于我最感興趣的用例實際上是科學數(shù)據(jù)(PyTorch 訓練工作流),直接而有效地共享數(shù)據(jù)的能力對多線程性能至關(guān)重要。如果采用多解釋器,這種共享只能在 C 擴展級別上開啟,與無 GIL 的 Python 相比,將導致更多使用 C/C++ 代碼。
Q:你已經(jīng)詳細介紹了字典和列表的實現(xiàn)。其它可變類型例如隊列、集合、數(shù)組等等,是如何實現(xiàn)的呢?
nogil 是一個開發(fā)中的項目。由于字典和列表在解釋器的內(nèi)部運作中很普遍,所以它們的開發(fā)最多。同樣地,隊列的開發(fā)已經(jīng)完成,但其它類型還沒有。集合是下一個要覆蓋的重要內(nèi)容。
隊列非常重要,因為它被concurrent.futures 和asyncio 用于并發(fā)線程之間的通信。隊列比字典和列表簡單,它使用細粒度的鎖而不是無鎖讀取。其它的對象很可能需要組合使用。
這項工作很棘手,因為在獲取和釋放鎖時需要小心,例如 Py_DECREFs 是可重入的。還可以考慮使用更“粗粒度”的鎖,但當然了,這些鎖都有死鎖的風險。
Q:nogil 有多依賴 mimalloc? 如果我們把它作為一個編譯期選項,可以用或不用它,那么使用平臺的 malloc 來代替沒有 C 預處理器地獄的低性能構(gòu)建是否可行?
mimalloc 不僅僅是用于線程安全。它對于啟用字典的無鎖讀取是必要的,還支持高效的 GC 追蹤。
mimalloc 的維護者對顯式地支持 CPython 很感興趣,并且樂意為實現(xiàn)這一點進行必要的更改。
其它實現(xiàn)的 malloc 據(jù)說也穩(wěn)定支持 CPython:在 Facebook 中使用的jemalloc,在谷歌中使用tcmalloc,盡管集成得較少,更像是默認分配器的簡單替換。(Python貓注:前文提到的 mimalloc 是微軟的)
核心開發(fā)者注:Christian Heimes 和 Pablo Galindo Salgado 正在評估 CPython 使用 mimalloc。早期測試在平均上(幾何平均數(shù))沒有性能衰退,大多數(shù)基準測試做得更好,少數(shù)基準測試做得稍微差一些。還有一些待評估的問題:
mimalloc 的 API 和 ABI 的穩(wěn)定性; 授權(quán)許可; 跨所有 CPython 支持的平臺的可移植性,例如 stdatomic.h 僅在 C11 中可用; 集成分析和檢測工具(Valgrind、asan、ubsan 等等); 可能還有其它。
Q:你的項目和 Larry 的 Gilectomy 有什么相似之處?你能利用他的項目嗎?
在頂層設計上,兩個項目是相似的:延遲引用計數(shù),細粒度鎖,關(guān)于返回借用的引用的挑戰(zhàn)。沒有復用 Gilectomy 的代碼。
Q:你說你的項目在頂層上類似于 Larry 的 Gilectomy。他的項目也是基于延遲引用計數(shù)。然而,他在 Gilectomy 上只得到了性能下降的結(jié)果,而你的“nogil”卻有很好的性能表現(xiàn)。你認為這種差異是怎么回事?
切換到基于寄存器的編譯器和其它優(yōu)化,比如由 mimalloc 提供的無鎖的字典讀取,以及使用延遲引用計數(shù)來避免爭用,對 nogil 的擴展性和性能都至關(guān)重要。而且,在某些情況下,Python 本身變得更快了。例如, Python 3.9 中的函數(shù)調(diào)用比 Python 3.5 的要快得多。
讓它支持擴展,肯定比預期要花更多的工作。
Q:有沒有可能在無 GIL 模式中加入一個(不兼容的) C 擴展或剔除它嗎?
顧名思義,GIL 就是一個全局鎖。為了保護任意一段共享數(shù)據(jù),它需要在所有線程上開啟,包括不兼容的擴展所處的線程。
在已經(jīng)運行的進程中,將無 GIL 的解釋器切換為使用 GIL 的解釋器是很棘手的(反之亦然)。最好的做法是在啟動時選擇:要么在進程中啟用 GIL,要么不啟用。如果 C 擴展沒有標記為兼容,就引發(fā)警告或無法導入。
或者,當訪問 C 擴展時,也可以“stop the world”,但這與移除 GIL 而所想達成的目的不符。
核心開發(fā)者注:到目前為止,還有其它的想法需要深入探討。有種想法是將 GIL 轉(zhuǎn)換為“單寫多讀”鎖。在這種情況下,無 GIL 的模式將獲取“多讀”鎖,也就是說,不會阻塞其它新代碼做同樣的事情。而歷史遺留的代碼將獲得一個“單寫”鎖,阻塞其它所有線程執(zhí)行,直到鎖釋放。這種設計需要保留獲取/釋放 GIL 的 api,nogil 已經(jīng)這樣做了,為了告知 GC 一個線程被阻塞在 I/O 上。
Q:有沒有可能將函數(shù)標記為非線程安全的(比如使用裝飾器),并讓 nogil 在運行代碼時加鎖,以防止其它線程調(diào)用它?(有點像臨時的 GIL)
如果擔心的是狀態(tài)被其它線程訪問,則需要鎖定每一次訪問。這在裝飾器層面上不是特別可行。正如之前說過,條件性地為不安全的代碼開啟 GIL 是很難實現(xiàn)的。
Q:用你自己的鎖代替 GIL 會很困難。使用 nogil,你認為與線程相關(guān)的問題會增加么?
不清楚。對于 C API 擴展,至少有一種好的設計模式:它們通常有類似的結(jié)構(gòu),并在單個結(jié)構(gòu)中保持共享狀態(tài)。目前,Pybind11 看起來與這個模式距離最遠,因此用它編寫的 C 擴展可能需要進行大量更改。
許多復雜的 C 擴展已經(jīng)不得不處理鎖和多線程,因為它們的目的是盡可能多地釋放 GIL,比如 numpy。所以,也許令人驚訝的是,那些項目可能更容易遷移。
下一步工作
在這次會議之后,核心開發(fā)者們討論了將 nogil 納入主項目的可行性,以及這對社區(qū)意味著什么。毫無疑問,這種程度的改變必須非常小心。
在作出決定之前,我們覺得先引入它的一些代碼更為可行。特別地,mimalloc 看起來很有趣,已經(jīng)有一個 open 的 pull 請求了(https://github.com/python/cpython/pull/29123),旨在探索引入它。在那里可以找到基準測試的鏈接。

請慷慨分享和點贊,感謝啦!
