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