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

豌豆花下貓發表於2021-11-14

作者:Ł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,肯定還為時過早。

介紹 nogil

Sam 釋出了他的程式碼,同時還有一篇詳細的文章,解釋了該專案的動機和設計。

nogil 程式碼地址:https://github.com/colesbury/nogil

他的設計可以總結為:

  • 為了執行緒安全,將 Python 內建的分配器pymalloc替換成mimalloc ,對字典和其它集合物件採用無鎖讀寫,同時提升效率(堆記憶體佈局允許在不維護顯式列表的情況下找到 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 的執行緒(使用PyEval_ReleaseThread ,相當於在當前 Python 中釋放 GIL);
    • 高效地構造物件的列表,以便即時地釋放:得益於mimalloc, 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 提出的問題

為了清晰易懂,這裡的問題基於會議上的內容進行了重新排序。答案是由 Sam 的回答轉述而來的,並得到了他閱讀草稿後的認可。要注意的是,核心團隊的成員可能對其中一些主題有其它觀點。

Q:有哪些可感知的風險是阻礙 nogil 專案合入到 CPython 中的?

目前的程式碼庫已經證明了它在技術上的可行性。它可以執行,而且比普通的 CPython 直譯器和 Gilectomy 專案更具有可伸縮性和好效能。我在該專案中投入了將近兩年的全職工作。

這完全取決於社群對 C 擴充套件程式的改造程度,以確保它們不會導致直譯器徹底崩潰。然後,剩下的長尾就是社群要以一種既正確又可擴充套件的方式在應用程式中採用自由執行緒。這兩個是最大的挑戰,但我們必須樂觀應對。

Q:你打算如何改進你的工作?對 commit 次序有什麼建議嗎?你將如何保持你的工作與 main 分支的同步?

Sam 目前正在重構他的工作,最初是基於 3.9.0a3,將匹配 3.9.7 最終版本。這項工作的一部分是將 commit 重構為邏輯單元,以便更好地說明哪些內容需要更改(哪些地方改了,以及為什麼要改)。

目前還不計劃把這項工作移到 main 分支(未來的 3.11),因為這個分支太不穩定了。相比之下,3.9 有大量已釋出的可通過 pip 安裝的庫和 C 擴充套件,可用於測試。這使得 Sam 能夠評估該專案與真實世界的第三方程式碼的行為。基於 main 的修改將花費不少時間,而這些時間本可以花在改進無 GIL 的直譯器上,所以,現在就基於主分支的話,還為時過早。

將工作進行分割然後再合併是可行的,但必須記住,許多更新需要在串聯起來時,效能才會提升。單獨而言,它們會導致(暫時的?)效能下降。

核心開發者注:我們現在不能合併對 3.9 分支所做的更改。在專案的這個階段使用 3.9 是有意義的,但關鍵的是要將它分割成可消費的資料塊,然後一個一個地合併到 main 分支中。一塊一塊地做,很有可能會損害效能,但這是唯一現實的整合途徑。

Q:可以只引入暫存器 VM 和編譯器而不做其它更改嗎?在不改變引用計數或 GIL 的情況下使用暫存器 VM 會有什麼特殊的困難嗎?

VM 使用延遲/永生的引用計數。可以將其轉換為只使用經典的引用計數,但最終結果的效率還不清楚(例如,出於效能考慮,堆疊上的所有物件都使用了延遲引用計數)。

Q:跟前一問相反的問題:只引入 nogil,而不使用新的暫存器 VM,會有什麼困難呢?

雖然新的 VM 只提高了效能,而不是準確性,但它也提高了可伸縮性,使得無 GIL 的 Python 可以充分利用 CPU 核心而不發生爭用。因此要使用 3.11 直譯器也是可行的,但最好保留一些暫存器 VM 的設計思想,這對可伸縮性和執行緒安全很重要。這需要做大量的工作。但是將暫存器 VM 更新成跟 main 分支一樣(以及修復遺留的 bug),也需要大量的工作。這兩種選擇都是可行的。

Q:對於那些不希望自己的程式碼被其它執行緒並行執行的 C 擴充套件,有什麼建議麼?在適應新的自由執行緒環境之前,難道不需要 CPython 給它們提供一些 API 來彌補差距嗎?

這需要花時間。目標是漸進式採納,最終推廣至大多數 C 擴充套件。GIL 可以作為直譯器啟動時的一個選項。如果沒有啟用 GIL,並且 C 擴充套件不支援新的操作模式,可能就要產生告警或者不讓其匯入。Python 社群不得不適配 C 擴充套件,讓它們適應無 GIL 的模式。

作為概念驗證的 nogil 專案,預設使用無 GIL 模式,並接受任何 C 擴充套件。如果它被 CPython 採用了,那麼在開始時預設應該啟用 GIL(要求在啟動 Python 時使用 -X nogil 禁用 GIL),以便讓第三方庫做適配。然後,在釋出幾個版本後,預設值再切換成無 GIL 的模式。

雖然要移植全部東西並不容易(並行是很難的),但在多數情況下,移植並不會很難,特別是對於封裝外部庫的 C 擴充套件來說。

核心開發者注:有大量的“暗物質” Python 程式碼(和 C 擴充套件)不是開源的。我們需要小心不去破壞它們,因為它們的使用者可能無法做出所需的更改,或者向上遊報告問題給我們。特別地,有些 C 擴充套件使用 GIL 來保護它們自己的內部狀態。這是一個很大的擔憂,可能是採用無 GIL Python 的一個很大的障礙。

Q:你會新增一個 PEP-489 的“插槽”麼,以便 C 擴充套件用來表示其支援 nogil,這樣當遇到不支援 nogil 的庫時,就不讓它匯入?

很多人也提過,這可能是一個好主意,但我不完全清楚這意味著什麼。選擇無 GIL 模式並不能保證沒有 bug。相反,在預設情況下,我們執行所有的擴充套件(現在的 nogil 就是這麼做的)。不相容的擴充套件可以使用 PyInit 模組的程式碼,主動地詢問直譯器是否啟用了 GIL,如果不相容的話,就在匯入時產生警告甚至異常。

Q:在執行期啟用 nogil 是一項長期可行的選擇,還是過渡性的功能呢?

理想的結局是 CPython 不再有 GIL,句號。然而,預計將有一個漫長的社群適應期。我們希望避免從 Python2 到 Python3 過渡時的斷裂。準確地說,我們希望過渡得越平滑越好,即使這意味著需要延展更長的時間。

Q: 確認一下,最終狀態是隻有 nogil,並且不支援再開啟 GIL 麼?

目前我們還不確定。理想的結局是隻存在一個無 GIL 的 Python,但尚不清楚這能否實現。

Q:如果這些特性標誌會持續很長一段時間,這是否意味著我們需要大幅增加測試矩陣?

是的,測試矩陣需要加倍。然而,測試無 GIL 版本可能是判斷經典的 GIL 版本是否有效的一個很好的預測器。有必要偶爾(每晚?)執行啟用了 GIL 的測試。

核心開發者注:如果不做測試,程式碼將加速退化。在 CPython 中,由於需要執行時間(例如測試引用洩漏時),我們不會在每次更改時都執行所有測試,但如果有更改導致每日測試失敗,我們會立即回退更改,因為在已經失敗的構建點之後,很可能會出現其它的迴歸問題。

Q:你認為多個 Python 直譯器並行執行,每個直譯器一個 GIL 怎麼樣?

Python貓注:給大家科普一下這個問題的背景,PEP-554 提議實現多直譯器來解決 GIL 的問題。這是在 2017 年提出的,受到挺多關注。在 2019 年時,我曾翻譯過《Has the Python GIL been slain?》介紹它。但是,目前該提案依然是草稿狀態,具體的開發情況不甚明朗。

跟無 GIL 提案相比,這既是互補的,又是相互競爭的。在無 GIL 直譯器中也可以支援副直譯器。

目前還不清楚多直譯器方案能否實現。有了 nogil,就不需要擔心跨執行緒共享物件,也不需要擔心 C 擴充套件的相容性,因為有了多直譯器,就沒有任何狀態是真正全域性的,因此需要特別地隔離。對於可變物件,在多直譯器之間傳遞時,需要某種形式的序列化/反序列化。對於不可變物件,直譯器可能會新增特殊的支援,但如果它們不是已知的不可變的內建型別,使用者程式碼就需要適配這些物件。這是從 PyTorch 的相關工作中得到的啟發,它使用了某種形式的多直譯器。

由於我最感興趣的用例實際上是科學資料(PyTorch 訓練工作流),直接而有效地共享資料的能力對多執行緒效能至關重要。如果採用多直譯器,這種共享只能在 C 擴充套件級別上開啟,與無 GIL 的 Python 相比,將導致更多使用 C/C++ 程式碼。

Q:你已經詳細介紹了字典和列表的實現。其它可變型別例如佇列、集合、陣列等等,是如何實現的呢?

nogil 是一個開發中的專案。由於字典和列表在直譯器的內部運作中很普遍,所以它們的開發最多。同樣地,佇列的開發已經完成,但其它型別還沒有。集合是下一個要覆蓋的重要內容。

佇列非常重要,因為它被concurrent.futuresasyncio 用於併發執行緒之間的通訊。佇列比字典和列表簡單,它使用細粒度的鎖而不是無鎖讀取。其它的物件很可能需要組合使用。

這項工作很棘手,因為在獲取和釋放鎖時需要小心,例如 Py_DECREFs 是可重入的。還可以考慮使用更“粗粒度”的鎖,但當然了,這些鎖都有死鎖的風險。

Q:nogil 有多依賴 mimalloc? 如果我們把它作為一個編譯期選項,可以用或不用它,那麼使用平臺的 malloc 來代替沒有 C 前處理器地獄的低效能構建是否可行?

mimalloc 不僅僅是用於執行緒安全。它對於啟用字典的無鎖讀取是必要的,還支援高效的 GC 追蹤。

mimalloc 的維護者對顯式地支援 CPython 很感興趣,並且樂意為實現這一點進行必要的更改。

其它實現的 malloc 據說也穩定支援 CPython:在 Facebook 中使用的jemalloc,在谷歌中使用tcmalloc,儘管整合得較少,更像是預設分配器的簡單替換。(Python貓注:前文提到的 mimalloc 是微軟的)

核心開發者注:Christian Heimes 和 Pablo Galindo Salgado 正在評估 CPython 使用 mimalloc。早期測試在平均上(幾何平均數)沒有效能衰退,大多數基準測試做得更好,少數基準測試做得稍微差一些。還有一些待評估的問題:

  • mimalloc 的 API 和 ABI 的穩定性;
  • 授權許可;
  • 跨所有 CPython 支援的平臺的可移植性,例如 stdatomic.h 僅在 C11 中可用;
  • 整合分析和檢測工具(Valgrind、asan、ubsan 等等);
  • 可能還有其它。

Q:你的專案和 Larry 的 Gilectomy 有什麼相似之處?你能利用他的專案嗎?

在頂層設計上,兩個專案是相似的:延遲引用計數,細粒度鎖,關於返回借用的引用的挑戰。沒有複用 Gilectomy 的程式碼。

Q:你說你的專案在頂層上類似於 Larry 的 Gilectomy。他的專案也是基於延遲引用計數。然而,他在 Gilectomy 上只得到了效能下降的結果,而你的“nogil”卻有很好的效能表現。你認為這種差異是怎麼回事?

切換到基於暫存器的編譯器和其它優化,比如由 mimalloc 提供的無鎖的字典讀取,以及使用延遲引用計數來避免爭用,對 nogil 的擴充套件性和效能都至關重要。而且,在某些情況下,Python 本身變得更快了。例如, Python 3.9 中的函式呼叫比 Python 3.5 的要快得多。

讓它支援擴充套件,肯定比預期要花更多的工作。

Q:有沒有可能在無 GIL 模式中加入一個(不相容的) C 擴充套件或剔除它嗎?

顧名思義,GIL 就是一個全域性鎖。為了保護任意一段共享資料,它需要在所有執行緒上開啟,包括不相容的擴充套件所處的執行緒。

在已經執行的程式中,將無 GIL 的直譯器切換為使用 GIL 的直譯器是很棘手的(反之亦然)。最好的做法是在啟動時選擇:要麼在程式中啟用 GIL,要麼不啟用。如果 C 擴充套件沒有標記為相容,就引發警告或無法匯入。

或者,當訪問 C 擴充套件時,也可以“stop the world”,但這與移除 GIL 而所想達成的目的不符。

核心開發者注:到目前為止,還有其它的想法需要深入探討。有種想法是將 GIL 轉換為“單寫多讀”鎖。在這種情況下,無 GIL 的模式將獲取“多讀”鎖,也就是說,不會阻塞其它新程式碼做同樣的事情。而歷史遺留的程式碼將獲得一個“單寫”鎖,阻塞其它所有執行緒執行,直到鎖釋放。這種設計需要保留獲取/釋放 GIL 的 api,nogil 已經這樣做了,為了告知 GC 一個執行緒被阻塞在 I/O 上。

Q:有沒有可能將函式標記為非執行緒安全的(比如使用裝飾器),並讓 nogil 在執行程式碼時加鎖,以防止其它執行緒呼叫它?(有點像臨時的 GIL)

如果擔心的是狀態被其它執行緒訪問,則需要鎖定每一次訪問。這在裝飾器層面上不是特別可行。正如之前說過,條件性地為不安全的程式碼開啟 GIL 是很難實現的。

Q:用你自己的鎖代替 GIL 會很困難。使用 nogil,你認為與執行緒相關的問題會增加麼?

不清楚。對於 C API 擴充套件,至少有一種好的設計模式:它們通常有類似的結構,並在單個結構中保持共享狀態。目前,Pybind11 看起來與這個模式距離最遠,因此用它編寫的 C 擴充套件可能需要進行大量更改。

許多複雜的 C 擴充套件已經不得不處理鎖和多執行緒,因為它們的目的是儘可能多地釋放 GIL,比如 numpy。所以,也許令人驚訝的是,那些專案可能更容易遷移。

下一步工作

在這次會議之後,核心開發者們討論了將 nogil 納入主專案的可行性,以及這對社群意味著什麼。毫無疑問,這種程度的改變必須非常小心。

在作出決定之前,我們覺得先引入它的一些程式碼更為可行。特別地,mimalloc 看起來很有趣,已經有一個 open 的 pull 請求了(https://github.com/python/cpython/pull/29123),旨在探索引入它。在那裡可以找到基準測試的連結。

在個人層面上,我們對 Sam 所做的工作印象深刻,並邀請他加入 CPython 專案。我很高興地告訴大家,他對此很感興趣,為了幫助他成為一名核心開發者,我將為他提供指導。Guido 和 Neil Schemenauer 將幫我檢視我不熟悉的直譯器部分的程式碼。

相關文章