什麼是Python全局解釋器鎖(GIL)?

簡而言之,Python全局解釋器鎖或GIL是一種互斥鎖(或鎖),僅允許一個線程持有Python解釋器的控制權。

這意味着在任何時間點只有一個線程可以處於執行狀態。對於執行單線程程序的開發人員而言,GIL的影響並不明顯,但它可能是CPU綁定和多線程代碼的性能瓶頸。

由於即使在具有多個CPU內核的多線程體系結構中,GIL一次一次只允許執行一個線程,因此GIL被譽爲Python的“臭名昭著”功能。

在本文中,您將學習GIL如何影響Python程序的性能,以及如何減輕GIL對代碼的影響。

爲什麼Cpython需要GIL?

Python使用引用計數進行內存管理。

譯註:還有標記清除和分代回收

這意味着用Python創建的對象具有引用計數變量,該變量跟蹤指向該對象的引用數。當此計數達到零時,將釋放對象佔用的內存。

讓我們看一個簡短的代碼示例,以演示引用計數的工作原理:

>>> import sys
>>> a = []
>>> b = a
>>> sys.getrefcount(a)
3

在上面的示例中,空列表對象的引用計數爲[]3。列表對象被引用a,b並且參數傳遞給sys.getrefcount()。

回到GIL:

問題在於該引用計數變量需要保護,以防止兩個線程同時增加或減少其值的競爭狀態。如果發生這種情況,則可能導致從未釋放的內存泄漏,或者更糟糕的是,在仍然存在對該對象的引用的情況下,錯誤地釋放了內存。這可能會導致崩潰或Python程序中的其他“怪異”錯誤。

通過將鎖添加到跨線程共享的所有數據結構中,以確保它們不會被不一致地修改,可以保持此引用計數變量的安全。

但是,將鎖添加到每個對象或對象組意味着將存在多個鎖,這可能會引起另一個問題-死鎖(死鎖只有在有多個鎖的情況下才會發生)。另一個副作用是由於重複獲取和釋放鎖而導致性能降低。

GIL是解釋器本身的單一鎖,它添加了一個規則,即任何Python字節碼的執行都需要獲取解釋器鎖。這樣可以防止死鎖(因爲只有一個鎖)並且不會帶來太多的性能開銷。但這有效地使所有受CPU約束的Python程序成爲單線程。

儘管解釋器用於其他語言(例如Ruby),但GIL並不是解決此問題的唯一方法。某些語言通過使用引用計數以外的方法(例如垃圾回收)來避免對線程安全的內存管理使用GIL的要求。

另一方面,這意味着這些語言通常必須通過添加其他性能提升功能(例如JIT編譯器)來彌補GIL的單線程性能優勢的損失。

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

那麼,爲什麼在Python中使用了一種看起來如此阻礙的方法呢?Python開發人員是否會做出錯誤的決定?

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

自從操作系統沒有線程概念以來,Python就已經存在了。Python被設計爲易於使用,以加快開發速度,越來越多的開發人員開始使用它。

現有的C庫正在編寫許多擴展,這些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


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)

輸出結果:Time taken in seconds: 2.232430934906006

現在,我使用兩個並行線程對代碼進行了一些修改,以實現相同的倒計時:

# 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)

再次運行:Time taken in seconds: 2.353055953979492

如您所見,兩個版本花費的時間幾乎相同。在多線程版本中,GIL阻止CPU綁定的線程並行執行。

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一起刪除呢?

與單線程性能相比,刪除GIL會使Python 3的速度比Python 2慢,並且您可以想象會導致什麼。您無法反對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
>>> sys.getswitchinterval()
0.005

這種機制的問題在於,在大多數情況下,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.time()
    r1 = pool.apply_async(countdown, [COUNT//2])
    r2 = pool.apply_async(countdown, [COUNT//2])
    pool.close()
    pool.join()
    end_time = time.time()
    print('Time taken in second:', end_time-start_time)

輸出結果:Time taken in second: 1.3340442180633545
與多線程版本相比,性能提高了,對嗎?

時間並沒有減少到我們上面看到的一半,因爲流程管理有其自己的開銷。多個進程比多個線程重,因此請記住,這可能會成爲擴展瓶頸。

備選的Python解釋器: Python具有多種解釋器實現。最受歡迎的分別是用C,Java,C#和Python編寫的CPython,Jython,IronPython和PyPy。GIL僅存在於原始Python實現中,即CPython。如果您的程序及其庫可用於其他實現之一,則也可以嘗試一下。

**稍後:**許多Python用戶都利用了GIL的單線程性能優勢。多線程程序員不必煩惱,因爲Python社區中一些最聰明的人正在努力從CPython中刪除GIL。一種這樣的嘗試被稱爲“ Gilectomy”。

Python GIL通常被認爲是一個神祕而困難的話題。但是請記住,作爲Pythonista,通常只有在編寫C擴展或在程序中使用CPU綁定多線程時才受到它的影響。

在這種情況下,本文應爲您提供瞭解GIL以及在您自己的項目中如何處理GIL所需的一切。而且,如果您想了解GIL的底層內部工作原理,建議您觀看David Beazley 的“ 瞭解Python GIL”演講。

原文出處:https://realpython.com/python-gil/

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章