Python 移除 GIL 了!但是換了一種鎖...—— PEP307導(dǎo)讀

PEP 703 正式宣布,從 Python 3.13 起全局解釋器鎖(GIL)將成為可選配置!

Intro

在當(dāng)下數(shù)據(jù)科學(xué)和 AI 領(lǐng)域,憑借簡(jiǎn)單易上手的 Python 可謂占據(jù)大半江山,然而隨著使用的深入,天下苦 GIL 久成為一眾研究人員和開(kāi)發(fā)者多么痛的領(lǐng)悟,例如:

  • 數(shù)據(jù)科學(xué)與機(jī)器學(xué)習(xí):多核 CPU 在訓(xùn)練模型和數(shù)據(jù)處理中的潛力難以被充分利用,許多團(tuán)隊(duì)(Numpy、TensorFlow 等)被迫采用其他語(yǔ)言(如 C++ 或 Rust)來(lái)實(shí)現(xiàn)核心邏輯,隨著而來(lái),開(kāi)發(fā)維護(hù)成本直線上升,使用上也有諸多限制。

  • 游戲與圖形處理:實(shí)時(shí)計(jì)算與渲染任務(wù)中,Python 的多線程能力受到嚴(yán)重抑制,使其難以承擔(dān)性能要求較高的任務(wù)。

終于,Python 社區(qū)有了突破進(jìn)展,可以在 3.13 版本開(kāi)始禁用 GIL 了。沒(méi)了 GIL,在當(dāng)下技術(shù)條件,只要涉及共享數(shù)據(jù),依然離不開(kāi)鎖。

在 PEP 703 的長(zhǎng)篇大論中,聊了引用計(jì)數(shù)、內(nèi)存管理、容器線程安全、鎖和原子 API,初次看云里霧里,知道它在說(shuō)什么,做什么,但是怎么想到的?我讀到了三個(gè)核心思路:

  • 鎖的粒度
  • 要不要鎖
  • 鎖的實(shí)現(xiàn)

通過(guò)這三個(gè)思路,再把幾個(gè)章節(jié)串起來(lái)理解就簡(jiǎn)單多了。

鎖的粒度

首先是鎖的粒度,既然鎖不能完全消失,新的鎖必然是粒度更小,鎖的資源越小,鎖爭(zhēng)用越少,并發(fā)性能越高。

我們可以在 MySQL 上看到類似演進(jìn):早期 MyISAM 引擎階段,表級(jí)鎖在高并發(fā)下讀寫(xiě)爭(zhēng)用嚴(yán)重,到 InnoDB 引擎,改為行級(jí)鎖提高并發(fā),MySQL 的 REDO 日志也經(jīng)歷了同樣的演變。

PEP 307 將全局解釋器鎖換成了基于對(duì)象的鎖(Per-Object Lock),這和提案中的容器線程安全部分掛鉤。

當(dāng)每個(gè)容器(如列表、字典)持有自己的鎖,讀寫(xiě)操作只需要鎖定對(duì)應(yīng)的對(duì)象即可。

不過(guò),這引入了其他問(wèn)題,例如嵌套操作對(duì)象導(dǎo)致死鎖、讀操作非原子導(dǎo)致引用無(wú)法訪問(wèn)等等,解決方案則涉及到了引用計(jì)數(shù)、鎖和原子 API、內(nèi)存管理。

要不要鎖

既然鎖針對(duì)單個(gè)對(duì)象了,每個(gè)對(duì)象有自己的特征、操作,是不是所有對(duì)象都需要鎖?所有操作都需要鎖?

不是所有對(duì)象都要修改引用計(jì)數(shù)

如果對(duì)象不需要回收,自然也就不需要引用計(jì)數(shù)這個(gè)共享資源了。

當(dāng)對(duì)象貫穿整個(gè)程序生命周期便不需要回收重復(fù)創(chuàng)建,提案引入了永生化(Immortalization
)的概念:

  • 字符常量
  • (較小的)整型
  • 靜態(tài)對(duì)象
  • None, 布爾值等

這些對(duì)象在其引用計(jì)數(shù)字段用 UInt32 最大值標(biāo)識(shí),當(dāng)需要變更計(jì)數(shù)值,則不需要原子操作該字段:

  • INCREF增加引用: 先復(fù)制引用值加1再比較,如越界=0(C++的實(shí)現(xiàn)) 則為永生,直接返回
  • DECREF減少引用:直接比較是否為 UInt32MAX,是則為永生,跳過(guò)計(jì)數(shù)減法。

不是所有對(duì)象都要馬上修改引用計(jì)數(shù)

例如頂級(jí)函數(shù)、代碼對(duì)象、模塊和方法,往往會(huì)被許多線程同時(shí)頻繁訪問(wèn)。

這些對(duì)象幾乎都是只讀(為什么這里說(shuō)的是幾乎,讀者不妨思考一下)但又不一定在整個(gè)生命周期存在。

針對(duì)這些對(duì)象,PEP 提出了延遲引用計(jì)數(shù)。引用計(jì)數(shù)的變化只記錄在當(dāng)前線程局部數(shù)據(jù)中。在垃圾回收時(shí)這樣的時(shí)間點(diǎn),才加鎖合并到全局計(jì)數(shù)。

  • 頂級(jí)函數(shù) Top-level function
def my_function():  
    return "Hello, World!"  

# The function's behavior is fixed; you can't change its internal code.
  • 代碼對(duì)象 Code objects
code_obj = my_function.__code__  # Get the code object of the function  
# You cannot modify code_obj; it is read-only.
  • 模塊 Modules
import math  
# You can access math functions, but you can't change the math module's internal implementation.
  • 方法 Methods
class MyClass:  
    def my_method(self):  
        return "This is a method."  

# The method's implementation cannot be changed after it's defined.

不是所有引用計(jì)數(shù)的修改都要加鎖

引用計(jì)數(shù)是多線程爭(zhēng)用的核心區(qū),以確定對(duì)象是否需要回收。

提案引入了偏向引用計(jì)數(shù)。只對(duì)共享對(duì)象做原子操作(稱為 Slow Path),如果對(duì)象隸屬于創(chuàng)建線程,那么引用計(jì)數(shù)的修改無(wú)需加鎖 (Fast Path)。至于怎么識(shí)別,加上一個(gè)線程 ID 標(biāo)志位即可。

不是所有容器操作都需要鎖

這是單個(gè)對(duì)象鎖的更進(jìn)一步,PEP 提出了在線程安全部分提出了樂(lè)觀鎖(Optimistic Avoid Locking)。

對(duì)于容器而言,大部分操作只需要鎖單個(gè),append、insert、repeat 等,少部分鎖兩個(gè),extendconcat,__eq__(另一個(gè)通過(guò)線程安全迭代器訪問(wèn)),修改對(duì)象如 clear() 等必須持有該鎖。

讀操作則需要分開(kāi)討論:

  • 不需要鎖也可以讀取的情況:
    • 直接原子訪問(wèn):len(x)
  • 樂(lè)觀避免鎖定:contains, iter, dict[k], list[idx]
  • 需要上鎖的操作:__repr__

所謂樂(lè)觀地避免鎖定,是指讀取操作時(shí)沒(méi)有其他線程修改時(shí),保持無(wú)鎖狀態(tài),檢測(cè)到?jīng)_突才回退到加鎖狀態(tài)。

對(duì)于鎖兩個(gè)容器的操作,操作者容器持有輕量鎖,另一個(gè)容器便是樂(lè)觀鎖,它只需要做迭代操作。

那為什么 __repr__ 操作不能向迭代訪問(wèn)一樣用樂(lè)觀鎖呢,它返回的是快照,要么成功要么失敗,不存在部分成功的情況,因此是原子操作,必須上鎖。

鎖的實(shí)現(xiàn)

最后落實(shí)到具體鎖的實(shí)現(xiàn),有一些方案引發(fā)的問(wèn)題需要解決,這里挑兩項(xiàng)闡述:

  • 避免死鎖
  • 頁(yè)面重用

避免死鎖

當(dāng)鎖的粒度為單個(gè)對(duì)象,線程可以同時(shí)持有多個(gè)對(duì)象的鎖,若對(duì)象是嵌套的,如果線程嘗試以不同的順序獲取相同的鎖,它們將會(huì)死鎖。

簡(jiǎn)單復(fù)現(xiàn)一下,T1 持有鎖 A,T2 持有鎖 B,此時(shí) T1 嵌套操作 B,需等待鎖 B,同時(shí) T2 也嵌套操作 A,兩個(gè)線程都要等待,即造成死鎖。

T1:
Lock A

T2: 
Lock B

T1:
Waiting for Lock B

T2:
Waiting for Lock A

為了解決這個(gè)問(wèn)題,PEP 引入了臨界區(qū)(Critical Section)的概念,對(duì)象鎖多了一個(gè)掛起的狀態(tài)。

  • 開(kāi)始嵌套操作時(shí),外層鎖掛起(Suspend)
  • 嵌套操作完成,外層鎖釋放(Release)

處于掛起狀態(tài)的鎖,其他對(duì)象可以獲取,鎖用完不一定歸還給原線程。除非線程顯式結(jié)束臨界區(qū),參與競(jìng)爭(zhēng)。

如下面時(shí)序圖中,T1 歸還 Lock B 后,其他線程可以持有 LockA,此時(shí) T1 需要等待。

時(shí)序圖

頁(yè)面重用

獲取容器元素,存在從借用(borrowed)引用升級(jí)為擁有(owned)引用的情況,在下面的代碼中,獲取對(duì)象到修改引用計(jì)數(shù)之間,引用對(duì)象可能已經(jīng)被其他線程修改或刪除。

PyObject *item = PyList_GetItem(list, idx);
Py_INCREF(item);

為了解決這個(gè)問(wèn)題,PEP 使用了新的獲取元素 API,獲取對(duì)象后返回新的引用PyList_FetchItem(list, idx) for PyList_GetItem
同時(shí),為了避免元素被釋放或修改,引入了頁(yè)面重用。

移除 GIL 后,PEP 采用線程安全的 Mimalloc 內(nèi)存分配器代替 pymalloc。Mimalloc 用堆、頁(yè)、塊來(lái)實(shí)現(xiàn)內(nèi)存管理。

Mimalloc 內(nèi)存層級(jí)關(guān)系
- 堆 Heap
  - 頁(yè) page 不同大小類別,如沒(méi)分配任何快,會(huì)被堆重新初始化頁(yè)面
    - block 相同大小

對(duì)訪問(wèn)期間的對(duì)象,被釋放的元素采用 RCU(Read-Copy-Update)移動(dòng)到單獨(dú)的堆,再擇時(shí)釋放。就不用擔(dān)心元素找不到引用了。

但這些頁(yè)被重用既不能太精確——接近同步而失去意義,也不能太寬松——留到下一個(gè) GC 周期導(dǎo)致內(nèi)存上漲

為此,PEP 實(shí)現(xiàn)類似 Linux 中的 GUS(Global Unbounded Sequences)全局無(wú)界序列。它的實(shí)現(xiàn)接近于生產(chǎn)者消費(fèi)者模式。

GUS-消費(fèi)者生產(chǎn)者
  1. 有個(gè)單增全局寫(xiě)入序列號(hào)(例如某生產(chǎn)者,持續(xù)生產(chǎn)數(shù)字);
  2. 當(dāng)頁(yè)面為空(或元素被釋放),線程(消費(fèi)者)標(biāo)記當(dāng)前寫(xiě)入序號(hào),生產(chǎn)線程繼續(xù)原子單增寫(xiě)入序列號(hào);
  3. 每個(gè)線程都有一個(gè)本地讀取序列號(hào),記錄其觀察到的最近的寫(xiě)入序列號(hào);
  4. 當(dāng)線程沒(méi)有在訪問(wèn)列表或字典,就可以觀察寫(xiě)入序列號(hào)。定期調(diào)用此函數(shù),(類似)上報(bào)消費(fèi)點(diǎn)位。
  5. 有一個(gè)全局讀取序列號(hào),定期存儲(chǔ)所有活動(dòng)線程的讀取序列號(hào)中的最小值。當(dāng)全局讀取序號(hào) > 頁(yè)面標(biāo)簽號(hào),空的 Mimalloc 頁(yè)面就可以被重用。

總結(jié)

PEP 703 提出的移除 GIL 的設(shè)計(jì),不僅解決了 GIL 帶來(lái)的多線程性能瓶頸,還通過(guò)細(xì)粒度鎖、樂(lè)觀鎖、RCU 和 STW 等多種機(jī)制,在性能和線程安全之間實(shí)現(xiàn)了巧妙的平衡,這些基本離不開(kāi)本文導(dǎo)讀提到的三個(gè)思路:

  • 鎖的粒度
  • 要不要鎖
  • 鎖的實(shí)現(xiàn)

希望對(duì)你閱讀 PEP 703 有幫助。

據(jù) Python 路線圖顯示,至少要到 2028 年,GIL 才會(huì)被默認(rèn)禁用

Ref: PEP 703 – Making the Global Interpreter Lock Optional in CPython

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書(shū)系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容