作者:orenwang,騰訊IEG應用開發工程師
| 導語 GIL,即全局解釋器鎖,是阻礙 Python/ target=_blank class=infotextkey>Python 多線程并發計算性能提升的最大原因,也是眾多 Python 開發者的心頭之癢,而 Sam Gross 大神的新項目 nogil 卻在過去幾個月的時間里硬生生地撬開了這把鎖。
There should be one- and preferably only one -obvious way to do it.
- Zen of Python
1992年的一天,Python 之父 Guido van Rossum 為 Python 引入了一種簡單而又優美的機制:讓程序運行無需再擔心死鎖,因為全局只有一把鎖;功能實現更加簡潔,無需再針對單個對象加鎖和解鎖,因為全局只有一把鎖;甚至大幅提高了計算速度,這來源于程序本身的低 Overhead 和獨特的 Garbage Collection 機制,因為全局只有一把鎖。這把鎖就是 GIL,即全局解釋器鎖。
我們快進到1998年,硬件行業在這一年發生了一個重要的變化:多核處理器被研制出來了。大家很快意識到 GIL 在單線程領域的強大,卻成為了多核計算時代的絆腳石。因此在1999年 Python 1.4 版本期間出現了一個叫 "free-threading" 的包,大刀闊斧地移除了 GIL,然而單線程計算速度卻慢了4到7倍。
而我所知道的最近一次移除 GIL 的嘗試是 2016 年 Larry Hastings 大神提出的 Gilectomy 項目,其移除了 GIL 之后單線程計算僅慢了 30%,然而該項目的主要問題在于核越多,計算越慢(7核下慢19倍)。
由此可見,Guido 爸爸寫的這段代碼,盡管每天被全球開發者吐槽,但真搞起來,想比人家做得更好并不容易。
GIL 的問題
舉一個簡單的例子:給你一張紙,上面有100個格子,讓你從數字1寫到100,一個格子一個數字,你覺得需要多久?我閑來試了一下,花了我82秒(好像真的很閑)。那好,現在假設你們有五個人,每個人只要寫其中20個數字即可,你覺得需要多久?簡單地看,82秒除以5,五個人大約16秒即可完成。但如果你們五個人只有一支筆呢?算上你們互相傳遞筆的時間,恐怕82秒也不夠了。
紙上畫數字的例子(動畫內可以想象成100個人+100支筆同時寫字)
上面的例子里,這支筆,在 Python 的世界里就是 GIL:無論你們有多少人,只能有一個人拿著筆,其他人只能等著這個人把筆放下,才能開始寫字;無論你的 Python 程序起了多少個線程,真正吃 CPU 干活的只有一個線程。之所以這里強調了一下吃 CPU,是因為 GIL 的設計僅對 CPU-bound 的程序有限制,而在處理 IO-bound 計算時,是不需要 GIL 這支筆的,大家可以同時干 I/O 的活。
這時很多人會好奇,為什么不直接使用 multiprocessing 庫進行多進程計算呢?當然可以,但是 multiprcessing 的實現實際上是"fork"了個新的進程,性能犧牲了不說,死鎖的問題也將會暴露出來,更不用說如 CUDA 等很多第三方庫是不支持“fork”的。
再說一點,實際上,大部分人吐槽 GIL 的點,并非是 Python 程序本身并發效率的問題,而是大多數對于計算速度有要求的庫都是 Python 調用 C/C++,而 GIL 限制了你在調用 C/C++ 時也只能真正同時運行一個線程。也難怪 Sam 大神想要移除掉 GIL,他作為 PyTorch 的核心作者,自稱因性能問題曾大面積地把 Python 代碼完全重寫成了 C/C++ ,也因此很多人說 PyTorch 跟 Python 關系已經不大了。相比之下,Swift 團隊曾寫過一篇 “Why Swift for Tensorflow” 點出了相比 Python 的 GIL 性能瓶頸, Swift 在訓練 AI 的性能方面具備優勢;而Python 的第一競品 Julia 開發者和愛好者們更是揪著 GIL 這一點屢屢不放手,“慫恿”大家轉向使用 Julia 做數據科學工作。
綜上,現今 GIL 怕是過大于功了。
一些不那么基礎的基礎知識
接下來,本文會講解一些技術細節,雖然盡可能寫得通俗易懂(從而不暴露自己其實也不懂),但如果不夠熟悉 Python 的話可能還是會覺得有些不知所云...
- CPython
簡單來講,CPython 就是我們用的 Python。只是為了更容易地與“Python這門語言”進行區分,我們一般把運行 Python 解釋器的這個引擎叫做 CPython(我一開始就把 CPython 跟 Cython 項目搞混了,但其實 Cython 和我們本文說的就不是一回事了,它只是個把 Python 變成 C 的工具)。那除了用 C 寫的 CPython,其實還有用 JAVA 和 C# 分別寫的 Jython 和 IronPython。值得注意的是,后兩者并沒有 GIL,因此 GIL 并不是 Python 這個語言的特性/問題,而是 CPython 實現中包含的,因此下文與 GIL 相關的都會用 CPython 這個名字進行闡述。
- Reference Counting
當你有了變量的時候,CPython 就已經開始計數(counting)了,而當這個變量出現在任一列表(list)或者字典(dict)或者函數(function)內的時候,計數都會增加。當使用變量的函數執行完畢,或這個變量被 pop 出了某一個列表的時候,CPython就會把這個變量的計數對應減少。而當某個變量計數為零的時候,這個變量所在的內存就可以被釋放掉了,可以看CPython 源碼這里(https://github.com/python/cpython/blob/main/Include/object.h#L520)就是這么寫的。
具體計數方式也可見下面的代碼例子,我個人覺得看代碼更容易理解:
而這個 Reference Counting 有意思的地方就在于:程序釋放變量對應的內存空間無需等待GC工作時再進行操作了!因為只要計數為零,就滿足了條件。那為什么 CPython 還是需要 GC 呢?我這里一下子也沒想通,查閱了一下資料發現原因其實很簡單,因為如果有幾個變量沒其他地方用到了,但是它們互相之間是有 reference 的,那這個時候僅靠 Reference Counting 去釋放內存自然就會發生內存泄漏。
- Atomicity
繼續用上面“紙上寫數字”的例子,當你在寫數字“17”時,你要先寫個“1”,再寫個“7”吧。可能在你寫“7”之前,會有人把你的筆搶走,這個OK。但你起碼不能在寫“1”寫到一半的時候,允許別人打斷你。換句話說,你要有一個最“原子”的行為,這個行為無法進一步再拆分,你就Atomic了。
這個概念,有一些數據庫知識基礎的話,應該不需要解釋,跟大部分數據庫所保證的 Atomic 是不同場景下的同一個意思。
- Concurrent Collection Protections
列表(list)或者字典(dict)這類對象,我們都可以稱之為 Collection,這些對象往往在類似 Python 這種語言內都有各自獨特的內存結構,有的結構傾向于計算速度,而有的結果是出于內存占用考慮進行了優化。但無論哪種結構,在出現并發的的情況下,這些 Collection 都存在線程安全問題,因此處理時底層往往有一定的并發鎖邏輯進行保護,這個相信不難理解。
改寫歷史的 nogil 項目的技術細節
Sam 大神的新項目 nogil 之所以獲得了如此大的關注度,也首次引起 CPython 核心團隊好評的原因不僅是它成功移除掉了 GIL (而非類似 per-interpreter GIL 那種半移不移的設計),同時也克服了絕大多數前人未能解決的問題,而且最終性能分數驚人。通讀了 Sam 的原 paper 后,我又翻閱了幾篇大神們對該工作的討論和文章,感覺這個項目成功的核心倒不是設計上有多么巧妙(當然人家非常非常非常巧妙),難得的是 nogil 把 Python 目前版本里幾個 “浪費” 掉的地方拎出來逐一進行了深度優化,只不過“深”到已經把 Python 的內存分配器 PyMalloc 都直接換掉了。正像 Larry Hastings 所說,難的不是移除掉 GIL,難的是移除掉了 GIL 還能保證以前的東西就像沒移除掉一樣好用... 那 Sam 是怎么做到呢?這里討論下我覺得比較有意思的幾點:
Biased Reference Counting
這其實是2018年ACM上的一篇論文 Biased Reference Counting 提出的一種全新 Reference Counting 理論:并發多個線程同時進行 Reference Counting 操作時,我們往往需要把每一次操作 Atomic 化,這樣才能保證各個線程之間得到的 count 值保持一致;但我們忽略了一個因素,如果一個對象經常會被某一個線程操作,而被其他線程操作的頻次很少,那我們是不是可以給這一個類似 "owner" 的線程一些特殊的優化,即便讓其他的線程慢一點也影響不大?
而事實上,絕大多數對象都是面臨這樣一種情況。所以,這里我們就 “Biased” 了,讓 “owner” 線程的 Reference Counting 操作速度達到極致,而不用保證 Atomic ,只需要讓其他所有的線程 Atomic 即可(好吧,這里我也不是很懂,為什么 Non-Atomic 就一定比 Atomic 要快,但我知道為了做到 Atomic 顯然要做某些犧牲,等有時間我再具體看看為啥,然后補充到這里)。這一點非常關鍵,是整個 nogil 項目對性能貢獻最大的一點,我畫了個動畫幫助理解:
Immortalization
上面的 Biased Reference Counting 好用的前提是“大多數變量只有一個線程會經常使用”,但對于那些 0、1、True、False、 None之類的變量呢?這些變量可是幾乎每一個線程都要頻繁使用的。為了提高這類變量的操作速度,Sam 很巧妙地把這些變量 Immortalize(永久化)了,使得這類變量的引用不再需要做計數!我看到了這里,就有種強烈的“md我怎么沒想到”的感覺。
不過實現 Immortalization 也不是沒有犧牲的:計數值的 LSB(最低有效位,Least Significant Bit)不能再用了,因為 LSB 被用來代表這個變量是不是可以永久化掉了。這里會結合下面的 Deferred Reference Counting 再多討論一些。
Deferred Reference Counting
繼續揪著 Reference Counting 不放:那些既不能被永久化掉的同時又需要頻繁使用的對象怎么辦(怎么有點諧音梗...)?這個第二低有效位也被拿來征用了,被用來表示某個對象是否需要“Defer”它的引用計數。這個“Defer”的意思我個人感覺有一點誤導,因為它其實并非“延后”,根本就是不再計數了,把所有釋放相關的工作都交給 GC (Garbage Collector)了,畢竟很多引用的 top-level functions 或者 modules 本來就是只能被 GC 給釋放掉。
這里的具體實現我也不是很懂,但知道大概是因為局部變量一般是在內存的 Stack 上,Deferred Reference Counting 是完全不用管 Stack 上的計數變化,但如果一個對象的引用是被放在 Heap 上的,這個時候計數其實是照常的,只不過不會因為 Heap 上的計數為 0 而直接釋放掉它,畢竟這個時候有可能有 Stack 內存還在引用它。
Immortalization 和 Deferred Reference Counting 加起來一下就用掉了兩個最低位,也就是說以后每次調用 Py_INCREF 和 Py_DECREF,Reference Count每次變化就是 4 了,感覺怪怪的。不過按 Sam 的原話,這里其實變化是 1 還是 4 并不重要,畢竟我們大部分情況下只關心這個計數是不是零就夠了。這么說,也確實有些道理。
Mimalloc
Python 的內存分配器 PyMalloc 被換成 mimalloc 了。看 mimalloc 文檔看到第二段就感覺好厲害:
mimalloc is a drop-in replacement for malloc and can be used in other programs without code changes
這哪里是換掉 PyMalloc,這原來是可以直接換掉 malloc 了。。。
具體實現細節我就沒有看了,因為我知道我肯定看不懂。但是這里使用它的原因就很明顯了:因為 PyMalloc 有 GIL 的保護,所以不需要也做不到 thread-safe,而 mimalloc 可以讓 Python 做到 thread-safe 同時性能大幅提升。
Collection Read-only Access
寫到這里,終于寫到了碼農們熟悉的 list 和 dict 對象了。
當我們引用或一個 list 或 dict 對象,發生的過程大致可以簡單地分成三個步驟:
- 加載這個對象的地址
- 修改對象的 Reference Count
- 返回這個對象的地址
這一切在 GIL 的保護下沒什么問題。然鵝現在我們沒有 GIL 了,這里會出現一個問題:當有一個線程執行寫操作時,在步驟 2 把這個對象釋放掉了(Reference Count 減少到 0),而這個時候又有一個線程已經完成了步驟 1,開始直接步驟 2 時,就崩潰了,因為這個對象已經被釋放掉了。
Sam 的設計是,既然我們沒有 GIL 這把全局鎖了,我們就要給單個對象加局部鎖,不過我們只對寫操作加鎖。具體實現簡單來說就是增加了 Reference Counting 版本控制和更多的檢查判斷并重試機制,比如在執行上述的步驟 2 時首先檢查對象是否 Reference Count 已經為 0 了,如果是的話,從步驟 1 開始重試(重試之后我理解就可以讀到一個新的地址或是可以識別出是空地址從而保證安全性)。
目前對于 list 和 dict 的重新設計,主要是對單線程處理速度進行了優化,對于多線程處理只能保證安全而速度上有一定程度的犧牲。也許以后會出現一些特殊的 collection 類型,以應對那種多線程頻繁調用的情況。
Python 4.0 是否真的會移除GIL?
我個人感覺,出現一個沒有GIL版本的 Python 4.0 的可能性是比較大的,畢竟 CPython 核心團隊其實已經在著手將 Sam 大神的 nogil 項目合入 Python 3.11 了,而且該項目的性能分數已經達到甚至部分超過了 Guido 爸爸之前對于拿掉 GIL 的基本條件,這一次沒有“借口”可以拒絕了。當然 Python 3.11 多半不會是一個無 GIL 版本,nogil 項目無論多強大它也還只是個實驗項目,其仍存在諸多大小問題,以及很多仍待討論的架構決策,都不是一個小版本就能夠解決掉的。
至于 Python 4.0,它自己本身就是個未知數。核心團隊自己已經重申了多次他們想盡量延后 Python 4.0 的時間,因為 Python 2.0 到 3.0 大家已經很傷了,這么快又搞一波怕大家心里承受不了。。。Guido 爸爸很久就曾發推解釋過一次:
這里我想吐槽一下,從 Python 3.5 開始,每個版本就已經很傷了好么還不如趕緊上 nogil 也算是個痛并快樂著。
無論結果如何,我作為一個被 Python 領進門的、被 Python 各種騷操作種草的、到現在不管后端服務還是客戶端腳本還是各種AI“小研究”都首選 Python的忠實粉絲,衷心祝愿 Python 未來...越來越妖!