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

Intro
在當(dāng)下數(shù)據(jù)科學(xué)和 AI 領(lǐng)域,憑借簡單易上手的 Python 可謂占據(jù)大半江山,然而隨著使用的深入,天下苦 GIL 久成為一眾研究人員和開發(fā)者多么痛的領(lǐng)悟,例如:
數(shù)據(jù)科學(xué)與機(jī)器學(xué)習(xí):多核 CPU 在訓(xùn)練模型和數(shù)據(jù)處理中的潛力難以被充分利用,許多團(tuán)隊(duì)(Numpy、TensorFlow 等)被迫采用其他語言(如 C++ 或 Rust)來實(shí)現(xiàn)核心邏輯,隨著而來,開發(fā)維護(hù)成本直線上升,使用上也有諸多限制。
游戲與圖形處理:實(shí)時計(jì)算與渲染任務(wù)中,Python 的多線程能力受到嚴(yán)重抑制,使其難以承擔(dān)性能要求較高的任務(wù)。
終于,Python 社區(qū)有了突破進(jìn)展,可以在 3.13 版本開始禁用 GIL 了。沒了 GIL,在當(dāng)下技術(shù)條件,只要涉及共享數(shù)據(jù),依然離不開鎖。
在 PEP 703 的長篇大論中,聊了引用計(jì)數(shù)、內(nèi)存管理、容器線程安全、鎖和原子 API,初次看云里霧里,知道它在說什么,做什么,但是怎么想到的?我讀到了三個核心思路:
- 鎖的粒度
- 要不要鎖
- 鎖的實(shí)現(xiàn)
通過這三個思路,再把幾個章節(jié)串起來理解就簡單多了。
鎖的粒度
首先是鎖的粒度,既然鎖不能完全消失,新的鎖必然是粒度更小,鎖的資源越小,鎖爭用越少,并發(fā)性能越高。
我們可以在 MySQL 上看到類似演進(jìn):早期 MyISAM 引擎階段,表級鎖在高并發(fā)下讀寫爭用嚴(yán)重,到 InnoDB 引擎,改為行級鎖提高并發(fā),MySQL 的 REDO 日志也經(jīng)歷了同樣的演變。
PEP 307 將全局解釋器鎖換成了基于對象的鎖(Per-Object Lock),這和提案中的容器線程安全部分掛鉤。
當(dāng)每個容器(如列表、字典)持有自己的鎖,讀寫操作只需要鎖定對應(yīng)的對象即可。
不過,這引入了其他問題,例如嵌套操作對象導(dǎo)致死鎖、讀操作非原子導(dǎo)致引用無法訪問等等,解決方案則涉及到了引用計(jì)數(shù)、鎖和原子 API、內(nèi)存管理。
要不要鎖
既然鎖針對單個對象了,每個對象有自己的特征、操作,是不是所有對象都需要鎖?所有操作都需要鎖?
不是所有對象都要修改引用計(jì)數(shù)
如果對象不需要回收,自然也就不需要引用計(jì)數(shù)這個共享資源了。
當(dāng)對象貫穿整個程序生命周期便不需要回收重復(fù)創(chuàng)建,提案引入了永生化(Immortalization
)的概念:
- 字符常量
- (較小的)整型
- 靜態(tài)對象
- None, 布爾值等
這些對象在其引用計(jì)數(shù)字段用 UInt32 最大值標(biāo)識,當(dāng)需要變更計(jì)數(shù)值,則不需要原子操作該字段:
-
INCREF增加引用: 先復(fù)制引用值加1再比較,如越界=0(C++的實(shí)現(xiàn)) 則為永生,直接返回 -
DECREF減少引用:直接比較是否為 UInt32MAX,是則為永生,跳過計(jì)數(shù)減法。
不是所有對象都要馬上修改引用計(jì)數(shù)
例如頂級函數(shù)、代碼對象、模塊和方法,往往會被許多線程同時頻繁訪問。
這些對象幾乎都是只讀(為什么這里說的是幾乎,讀者不妨思考一下)但又不一定在整個生命周期存在。
針對這些對象,PEP 提出了延遲引用計(jì)數(shù)。引用計(jì)數(shù)的變化只記錄在當(dāng)前線程局部數(shù)據(jù)中。在垃圾回收時這樣的時間點(diǎn),才加鎖合并到全局計(jì)數(shù)。
- 頂級函數(shù) Top-level function
def my_function():
return "Hello, World!"
# The function's behavior is fixed; you can't change its internal code.
- 代碼對象 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ù)是多線程爭用的核心區(qū),以確定對象是否需要回收。
提案引入了偏向引用計(jì)數(shù)。只對共享對象做原子操作(稱為 Slow Path),如果對象隸屬于創(chuàng)建線程,那么引用計(jì)數(shù)的修改無需加鎖 (Fast Path)。至于怎么識別,加上一個線程 ID 標(biāo)志位即可。
不是所有容器操作都需要鎖
這是單個對象鎖的更進(jìn)一步,PEP 提出了在線程安全部分提出了樂觀鎖(Optimistic Avoid Locking)。
對于容器而言,大部分操作只需要鎖單個,append、insert、repeat 等,少部分鎖兩個,extend,concat,__eq__(另一個通過線程安全迭代器訪問),修改對象如 clear() 等必須持有該鎖。
讀操作則需要分開討論:
- 不需要鎖也可以讀取的情況:
- 直接原子訪問:
len(x)
- 直接原子訪問:
- 樂觀避免鎖定:
contains,iter,dict[k],list[idx] - 需要上鎖的操作:
__repr__等
所謂樂觀地避免鎖定,是指讀取操作時沒有其他線程修改時,保持無鎖狀態(tài),檢測到?jīng)_突才回退到加鎖狀態(tài)。
對于鎖兩個容器的操作,操作者容器持有輕量鎖,另一個容器便是樂觀鎖,它只需要做迭代操作。
那為什么 __repr__ 操作不能向迭代訪問一樣用樂觀鎖呢,它返回的是快照,要么成功要么失敗,不存在部分成功的情況,因此是原子操作,必須上鎖。
鎖的實(shí)現(xiàn)
最后落實(shí)到具體鎖的實(shí)現(xiàn),有一些方案引發(fā)的問題需要解決,這里挑兩項(xiàng)闡述:
- 避免死鎖
- 頁面重用
避免死鎖
當(dāng)鎖的粒度為單個對象,線程可以同時持有多個對象的鎖,若對象是嵌套的,如果線程嘗試以不同的順序獲取相同的鎖,它們將會死鎖。
簡單復(fù)現(xiàn)一下,T1 持有鎖 A,T2 持有鎖 B,此時 T1 嵌套操作 B,需等待鎖 B,同時 T2 也嵌套操作 A,兩個線程都要等待,即造成死鎖。
T1:
Lock A
T2:
Lock B
T1:
Waiting for Lock B
T2:
Waiting for Lock A
為了解決這個問題,PEP 引入了臨界區(qū)(Critical Section)的概念,對象鎖多了一個掛起的狀態(tài)。
- 開始嵌套操作時,外層鎖掛起(Suspend)
- 嵌套操作完成,外層鎖釋放(Release)
處于掛起狀態(tài)的鎖,其他對象可以獲取,鎖用完不一定歸還給原線程。除非線程顯式結(jié)束臨界區(qū),參與競爭。
如下面時序圖中,T1 歸還 Lock B 后,其他線程可以持有 LockA,此時 T1 需要等待。

頁面重用
獲取容器元素,存在從借用(borrowed)引用升級為擁有(owned)引用的情況,在下面的代碼中,獲取對象到修改引用計(jì)數(shù)之間,引用對象可能已經(jīng)被其他線程修改或刪除。
PyObject *item = PyList_GetItem(list, idx);
Py_INCREF(item);
為了解決這個問題,PEP 使用了新的獲取元素 API,獲取對象后返回新的引用PyList_FetchItem(list, idx) for PyList_GetItem
同時,為了避免元素被釋放或修改,引入了頁面重用。
移除 GIL 后,PEP 采用線程安全的 Mimalloc 內(nèi)存分配器代替 pymalloc。Mimalloc 用堆、頁、塊來實(shí)現(xiàn)內(nèi)存管理。
Mimalloc 內(nèi)存層級關(guān)系
- 堆 Heap
- 頁 page 不同大小類別,如沒分配任何快,會被堆重新初始化頁面
- block 相同大小
對訪問期間的對象,被釋放的元素采用 RCU(Read-Copy-Update)移動到單獨(dú)的堆,再擇時釋放。就不用擔(dān)心元素找不到引用了。
但這些頁被重用既不能太精確——接近同步而失去意義,也不能太寬松——留到下一個 GC 周期導(dǎo)致內(nèi)存上漲
為此,PEP 實(shí)現(xiàn)類似 Linux 中的 GUS(Global Unbounded Sequences)全局無界序列。它的實(shí)現(xiàn)接近于生產(chǎn)者消費(fèi)者模式。

- 有個單增全局寫入序列號(例如某生產(chǎn)者,持續(xù)生產(chǎn)數(shù)字);
- 當(dāng)頁面為空(或元素被釋放),線程(消費(fèi)者)標(biāo)記當(dāng)前寫入序號,生產(chǎn)線程繼續(xù)原子單增寫入序列號;
- 每個線程都有一個本地讀取序列號,記錄其觀察到的最近的寫入序列號;
- 當(dāng)線程沒有在訪問列表或字典,就可以觀察寫入序列號。定期調(diào)用此函數(shù),(類似)上報(bào)消費(fèi)點(diǎn)位。
- 有一個全局讀取序列號,定期存儲所有活動線程的讀取序列號中的最小值。當(dāng)全局讀取序號 > 頁面標(biāo)簽號,空的 Mimalloc 頁面就可以被重用。
總結(jié)
PEP 703 提出的移除 GIL 的設(shè)計(jì),不僅解決了 GIL 帶來的多線程性能瓶頸,還通過細(xì)粒度鎖、樂觀鎖、RCU 和 STW 等多種機(jī)制,在性能和線程安全之間實(shí)現(xiàn)了巧妙的平衡,這些基本離不開本文導(dǎo)讀提到的三個思路:
- 鎖的粒度
- 要不要鎖
- 鎖的實(shí)現(xiàn)
希望對你閱讀 PEP 703 有幫助。
據(jù) Python 路線圖顯示,至少要到 2028 年,GIL 才會被默認(rèn)禁用
Ref: PEP 703 – Making the Global Interpreter Lock Optional in CPython