1. 程式人生 > 其它 >GIL全域性直譯器鎖

GIL全域性直譯器鎖

轉載:https://realpython.com/python-gil/

Python 新提案:刪除全域性直譯器鎖 GIL,解放多執行緒效能

據 Python 基金會部落格介紹,開發者 Sam Gross 在 2022 Python 語言峰會上帶來了一個新提案:完全移除 CPython 直譯器的 GIL- 全域性直譯器鎖,使 Python 程式獲得更快的效能 —— 尤其是多執行緒程式。

Python 有多個版本,包括 JVM 、 .NET CLR  直譯器以及編譯器,但該語言的核心實現仍是 CPython 直譯器。由於 CPython 的記憶體管理非執行緒安全,因此設計了 CPython 的 GIL (Global Interpreter Lock - 全域性直譯器鎖),以防止競爭條件並確保執行緒安全。 GIL 是一個互斥鎖,只允許一個執行緒持有 Python 直譯器的控制權,從而保護對 Python 物件的訪問,防止多個執行緒同時執行 Python 位元組碼。 

但事後看來,GIL 並不理想,因為它阻止了多執行緒的 CPython 程式充分利用多核處理器的效能。但由於 GIL 長期存在,許多官方和非官方 Python 包和模組都深度融合了 GIL 模組,移除 GIL 功能的工作變得任重而道遠。此前,開發者 Larry Hastings 在其 “Gilectomy” (GIL 切除手術)專案中試圖完成 CPython GIL 功能的移除,但該專案失敗了,因為它使單執行緒 Python 程式碼顯著變慢。

而此次 Python 語言峰會帶來了另外一個專案 “nogil”該專案由 Meta 開發人員 Sam Gross 主持,從專案名稱不難看出,這也是一個專注於移除 GIL 的專案。參考了 Gilectomy 專案的失敗經驗, Sam Gross 意識到 :如果要使 Python 在沒有 GIL 的情況下有效工作,則需要新增新的鎖,以確保它仍然是執行緒安全的。然而,向現有程式碼新增新鎖可能非常困難,因為新的鎖可能會導致在部分領域的效能大幅下降。

據  Python 基金會的介紹,Gross 將發明一種新型鎖,一種 “更吉利” 的鎖。如果順利的話,這個新鎖很可能在 Python 3.12 版本亮相,因為 Gross 的提案就是 “在 Python 3.12 中引入一個新的編譯器標誌,該標誌將禁用 GIL。”

為什麼選擇 GIL 作為解決方案?

那麼,為什麼在 Python 中使用了一種看似如此阻礙的方法呢?Python 的開發人員做出了一個錯誤的決定嗎?

好吧,用Larry Hastings 的話來說, GIL 的設計決策是讓 Python 像今天這樣流行的原因之一。

自從作業系統沒有執行緒概念以來,Python 就一直存在。Python 被設計為易於使用,以使開發更快,越來越多的開發人員開始使用它。

為現有的 C 庫編寫了許多擴充套件,這些庫的特性在 Python 中是必需的。為了防止不一致的更改,這些 C 擴充套件需要 GIL 提供的執行緒安全記憶體管理。

GIL 易於實現,並且很容易新增到 Python 中。它為單執行緒程式提供了效能提升,因為只需要管理一個鎖。

非執行緒安全的 C 庫變得更容易整合。而這些 C 擴充套件成為 Python 被不同社群欣然採用的原因之一。

如您所見,GIL 是CPython開發人員在 Python 早期面臨的難題的實用解決方案。

對多執行緒 Python 程式的影響

當您檢視一個典型的 Python 程式(或任何計算機程式)時,在效能上受 CPU 限制的程式與受 I/O 限制的程式之間存在差異。

CPU 繫結程式是那些將 CPU 推到極限的程式。這包括進行數學計算的程式,如矩陣乘法、搜尋、影象處理等。

I/O 繫結程式是那些花費時間等待來自使用者、檔案、資料庫、網路等的輸入/輸出的程式。I/O 繫結程式有時必須等待大量時間,直到它們從源獲取他們需要的東西,因為源可能需要在輸入/輸出準備好之前進行自己的處理,例如,使用者考慮輸入什麼內容到輸入提示或在其執行的資料庫查詢自己的過程。

讓我們看一個執行倒計時的簡單 CPU 密集型程式:

# single_threaded.py
import time
from threading import Thread

COUNT = 50000000

def countdown(n):
    while n>0:
        n -= 1

start = time.time()
countdown(COUNT)
end = time.time()

print('Time taken in seconds -', end - start)

在我的 4 核系統上執行此程式碼會得到以下輸出:

$ python single_threaded.py
Time taken in seconds - 6.20024037361145

現在我稍微修改了程式碼以使用兩個並行執行緒來執行相同的倒計時:

# multi_threaded.py
import time
from threading import Thread

COUNT = 50000000

def countdown(n):
    while n>0:
        n -= 1

t1 = Thread(target=countdown, args=(COUNT//2,))
t2 = Thread(target=countdown, args=(COUNT//2,))

start = time.time()
t1.start()
t2.start()
t1.join()
t2.join()
end = time.time()

print('Time taken in seconds -', end - start)

當我再次執行它時:

$ python multi_threaded.py
Time taken in seconds - 6.924342632293701

如您所見,兩個版本都需要幾乎相同的時間來完成。在多執行緒版本中,GIL 阻止了 CPU-bound 執行緒並行執行。

GIL 對 I/O 繫結的多執行緒程式的效能沒有太大影響,因為線上程等待 I/O 時,鎖是線上程之間共享的。

但是一個執行緒完全受 CPU 限制的程式,例如,一個使用執行緒處理部分影象的程式,不僅會因為鎖而變成單執行緒,而且還會看到執行時間增加,如上面的示例所示,與將其編寫為完全單執行緒的情況相比。

這種增加是鎖增加的獲取和釋放開銷的結果。

為什麼 GIL 還沒有被刪除?

Python 的開發人員收到了很多關於這一點的抱怨,但是像 Python 這樣流行的語言不能帶來像刪除 GIL 這樣重大的變化而不會導致向後不相容的問題。

GIL 顯然可以被刪除,過去開發人員和研究人員已經多次這樣做,但所有這些嘗試都破壞了現有的 C 擴充套件,這些擴充套件嚴重依賴於 GIL 提供的解決方案。

當然,GIL 解決的問題還有其他解決方案,但其中一些會降低單執行緒和多執行緒 I/O 繫結程式的效能,其中一些太難了。畢竟,您不希望現有的 Python 程式在新版本釋出後執行得更慢,對吧?

Python 的建立者和 BDFL,Guido van Rossum,於 2007 年 9 月在他的文章“移除 GIL 並不容易”中對社群做出了回答:

“只有在單執行緒程式(以及多執行緒但受 I/O 繫結的程式)的效能不降低的情況下,我才會歡迎 Py3k 中的一組補丁

從那以後所做的任何嘗試都沒有滿足這一條件。

為什麼它沒有在 Python 3 中被刪除?

Python 3 確實有機會從頭開始很多功能,並且在此過程中,破壞了一些現有的 C 擴充套件,這些擴充套件隨後需要更新並移植以與 Python 3 一起使用。這就是早期版本的原因Python 3 的社群採用速度較慢。

但是為什麼 GIL 沒有一起被移除呢?

與 Python 2 相比,刪除 GIL 在單執行緒效能方面會使 Python 3 變慢,您可以想象這會導致什麼結果。您無法與 GIL 的單執行緒效能優勢爭論。所以結果是 Python 3 仍然有 GIL。

但 Python 3 確實為現有的 GIL 帶來了重大改進——

我們討論了 GIL 對“僅受 CPU 限制”和“僅受 I/O 限制”的多執行緒程式的影響,但是對於某些執行緒受 I/O 限制而某些執行緒受 CPU 限制的程式呢?

在這樣的程式中,眾所周知,Python 的 GIL 會餓死 I/O 密集型執行緒,因為它們不給它們從 CPU 密集型執行緒獲取 GIL 的機會。

這是因為 Python 內建的一種機制,強制執行緒在連續使用固定間隔後釋放 GIL ,如果沒有其他人獲得 GIL,同一個執行緒可以繼續使用它。

>>>
>>> import sys
>>> # The interval is set to 100 instructions:
>>> sys.getcheckinterval()
100

這種機制的問題在於,大多數情況下,受 CPU 限制的執行緒會在其他執行緒獲取 GIL 之前重新獲取 GIL 本身。這是由 David Beazley 研究的,可以在此處找到視覺化。

這個問題在 2009 年的 Python 3.2 中由 Antoine Pitrou 修復,他添加了一種機制,可以檢視其他執行緒被丟棄的 GIL 獲取請求的數量,並且不允許當前執行緒在其他執行緒有機會執行之前重新獲取 GIL。

如何處理 Python 的 GIL

如果 GIL 給您帶來問題,您可以嘗試以下幾種方法:

多處理與多執行緒:最流行的方法是使用多處理方法,在這種方法中使用多個程序而不是執行緒。每個 Python 程序都有自己的 Python 直譯器和記憶體空間,因此 GIL 不會成為問題。Python 有一個multiprocessing模組可以讓我們像這樣輕鬆地建立程序:

from multiprocessing import Pool
import time

COUNT = 50000000
def countdown(n):
    while n>0:
        n -= 1

if __name__ == '__main__':
    pool = Pool(processes=2)
    start = time.time()
    r1 = pool.apply_async(countdown, [COUNT//2])
    r2 = pool.apply_async(countdown, [COUNT//2])
    pool.close()
    pool.join()
    end = time.time()
    print('Time taken in seconds -', end - start)

在我的系統上執行它會給出以下輸出:

$ python multiprocess.py
Time taken in seconds - 4.060242414474487

與多執行緒版本相比,效能得到了不錯的提升,對吧?

時間並沒有減少到我們上面看到的一半,因為流程管理有它自己的開銷。多程序比多執行緒重,因此請記住,這可能會成為擴充套件瓶頸。

替代 Python 直譯器: Python 有多個直譯器實現。分別用CJava 、C# 和 Python編寫的CPython、Jython、IronPython 和PyPy是最受歡迎的。GIL 僅存在於 CPython 的原始 Python 實現中。如果您的程式及其庫可用於其他實現之一,那麼您也可以嘗試它們。

等等吧:雖然許多 Python 使用者利用了 GIL 的單執行緒效能優勢。多執行緒程式設計師不必擔心,因為 Python 社群中一些最聰明的人正在努力從 CPython 中刪除 GIL。一種這樣的嘗試被稱為Gilectomy

Python GIL 通常被認為是一個神祕而困難的話題。但請記住,作為 Pythonista,您通常只有在編寫 C 擴充套件或在程式中使用受 CPU 限制的多執行緒時才會受到它的影響。

在這種情況下,本文應該為您提供瞭解 GIL 是什麼以及如何在您自己的專案中處理它所需的一切。如果您想了解 GIL 的低階內部工作原理,我建議您觀看 David Beazley 的“瞭解 Python GIL”演講。