什麼是python的全域性解釋鎖(GIL)?
我們所說的Python全域性解釋鎖(GIL)簡單來說就是一個互斥體(或者說鎖),這樣的機制只允許一個執行緒來控制Python直譯器。
這就意味著在任何一個時間點只有一個執行緒處於執行狀態。GIL對執行單執行緒任務的程式設計師們來說並沒什麼顯著影響,但是它成為了計算密集型(CPU-bound)和多執行緒任務的效能瓶頸。
由於GIL即使在擁有多個CPU核的多執行緒框架下都只允許一次執行一個執行緒,所以在Python眾多功能中其聲譽可謂是“臭名昭著”。
在這篇文章中,你將瞭解到GIL是如何影響到你的Python程式效能的以及如何減輕它對程式碼帶來的影響。
GIL解決了Python中的什麼問題?
Python利用引用計數來進行記憶體管理,這就意味著在Python中建立的物件都有一個引用計數變數來追蹤指向該物件的引用數量。當數量為0時,該物件佔用的記憶體即被釋放。
我們來通過一個簡單的程式碼演示引用計數是如何工作的:
在上述例子中,空列表物件[ ]的引用計數為3。該列表物件被a、b和傳遞給sys.getrefcount( )的引數引用。
回到GIL本身:
問題在於,這個引用計數變數需要在兩個執行緒同時增加或減少時從競爭條件中得到保護。如果發生了這種情況,可能會導致洩露的記憶體永遠不會被釋放,抑或更嚴重的是當一個物件的引用仍然存在的情況下錯誤地釋放記憶體。這可能會導致Python程式崩潰或帶來各種詭異的bug。
通過對跨執行緒分享的資料結構新增鎖定以至於資料不會不一致地被修改,這樣做可以很好的保證引用計數變數的安全。
但是對每一個物件或者物件組新增鎖意味著會存在多個鎖這也就導致了另外一個問題——死鎖(只有當存在多個鎖時才會發生)。而另一個副作用是由於重複獲取和釋放鎖而導致的效能下降。
GIL是直譯器本身的一個單一鎖,它增加的一條規則表明任何Python位元組碼的執行都需要獲取解釋鎖。這有效地防止了死鎖(因為只存在一個鎖)並且不會帶來太多的效能開銷。但是這的確使每一個計算密集型任務變成了單執行緒。
GIL雖然也被其他語言直譯器使用(如Ruby),但是這不是解決這個問題的唯一辦法。一些程式語言通過使用除引用計數以外的方法(如垃圾收集)來避免GIL對執行緒安全記憶體管理的請求。
從另一方面來看,這也意味著這些語言通常需要新增其他效能提升功能(如JIT編譯器)來彌補GIL單執行緒效能優勢的損失。
為什麼選取GIL作為解決方案?
那麼為什麼在Python中使用了這樣一種看似絆腳石的技術呢?這是Python開發人員的一個錯誤決定麼?
正如Larry Hasting所說,GIL的設計決定是Python如今受到火熱追捧的重要原因之一。
當作業系統還沒有執行緒的概念的時候Python就一直存在著。Python設計的初衷是易於使用以便更快捷地開發,這也使得越來越多的程式設計師開始使用Python。
人們針對於C庫中那些被Python所需的功能寫了許多擴充套件,為了防止不一致變化,這些C擴充套件需要執行緒安全記憶體管理,而這些正是GIL所提供的。
GIL是非常容易實現而且很容易新增到Python中。因為只需要管理一個鎖所以對於單執行緒任務來說帶來了效能提升。
非執行緒安全的C庫變得更容易整合,而這些C擴充套件則成為Python被不同社群所接受的原因之一。
正如您所看到的,GIL是CPython開發者在早期Python生涯中面對困難問題的一種實用解決方案。
對多執行緒Python程式的影響
當你留意一些典型的Python程式或任何計算機程式時你會發現一個程式針對計算密集型和I/O密集型任務之間的效能表現是有所差異的。
計算密集型任務是那些促使CPU達到極限的任務。這其中包括了進行數學計算的程式,如矩陣相乘、搜尋、影象處理等。
I/O密集型任務是一些需要花費時間來等待來自使用者、檔案、資料庫、網路等的輸入輸出的任務。I/O密集型任務有時需要等待非常久直到他們從資料來源獲取到他們所需要的內容為止。這是因為在準備好輸入輸出之前資料來源本身需要先進行自身處理。舉例來說,一個使用者考慮在輸入提示中輸入什麼或者在其自己程序中執行的資料庫查詢。
讓我們先來看一個執行倒計時的簡單的計算密集型程式:
在我的4核系統上執行得到以下輸出:
接下來我對程式碼做出微調,使用兩個執行緒並行處理來完成倒計時:
接下來我再次執行:
正如你所看到的,兩個版本的完成時間相差無幾。在多執行緒版本中GIL阻止了計算密集型任務執行緒並行執行。
GIL對I/O密集型任務多執行緒程式的效能沒有太大的影響,因為在等待I/O時鎖可以在多執行緒之間共享。
但是對於一個執行緒是完全計算密集型的任務來說(例如,利用執行緒進行部分影象處理)不僅會由於鎖而變成單執行緒任務而且還會明顯的增加執行時間。正如上例中多執行緒與完全單執行緒相比的結果。
這種執行時間的增加是由於鎖帶來的獲取和釋放開銷。
為什麼GIL還沒有被刪除?
Python的開發者收到了許許多多關於這方面的抱怨,但是像Python這樣極受歡迎的語言無法做出去除GIL這樣的鉅變同時還不造成向後不相容問題。
GIL顯然是可以被刪除的,而且在過去這項任務也被開發者和研究人員多次完成。但是所有的嘗試打破了在很大程度上取決於由GIL提供解決方案的C擴充套件市場。
當然,還有許多其他解決方案可以解決GIL問題,但是其中一些以犧牲單執行緒和多執行緒I/O密集型任務的效能表現為代價,而另外一些解決方法又過於複雜。畢竟新版本釋出後你不會希望你的Python跑得慢了些。
BDFL of Python的創始人Guido van Rossum在2007年09月的文章《It isn’t Easy to remove the GIL》中向社群做出回答:
“如果單執行緒任務和多執行緒I/O密集型任務的效能表現不會下降,那麼我十分希望Py3k中能出現一組修補程式。”
當然了,此後的每一次嘗試都沒有滿足這個條件。
為什麼在Python 3 中GIL沒有被移除?
Python3中的確有機會使得許多功能從零開始,並且在這個過程中打破了那些需要更改和更新的C擴充套件並且將其移植到Python 3中。這也是為什麼Python 3的早期版本被社群採納的較慢的原因。
但是為什麼GIL沒有被刪除?
刪除GIL會使得Python 3在處理單執行緒任務方面比Python 2慢,可以想像會產生什麼結果。你不能否認GIL帶來的單執行緒效能優勢,這也就是為什麼Python 3中仍然還有GIL。
但是Python 3的確對現有GIL做了重大改進。
我們僅僅討論了GIL對“僅計算密集型任務”和“僅I/O密集型任務”的影響,但是對於那些一部分執行緒是計算密集型一部分執行緒是I/O密集型的程式來說會怎麼樣呢?
在這樣的程式中,Python的GIL通過不讓I/O密集型執行緒從計算密集型執行緒獲取GIL而使I/O密集型執行緒陷入癱瘓。
這是因為Python中內嵌了一種機制,這個機制在固定連續使用時間後強迫執行緒釋放GIL,並且如果沒人獲取這個GIL,那麼同一執行緒可以繼續使用。
這個機制面臨的問題是大多數計算密集型執行緒會在別的執行緒獲取GIL之前再次獲取GIL。這個研究工作由David Beazley進行,並且你可以在這裡得到視覺化資源。
Antoine Pitrou於2009年在Python3.2中解決了這個問題,他添加了一種機制來檢視其他執行緒請求GIL的訪問數量,當數量下降時不允許當前執行緒在其他執行緒有機會執行之前重新獲取GIL。
如何處理Python中的GIL?
如果GIL給你帶來困擾,你可嘗試一下方法:
多程序vs多執行緒:最流行的方法是應用多程序方法,在這個方法中你使用多個程序而不是多個執行緒。每一個Python程序都有自己的Python直譯器和記憶體空間,因此GIL不會成為問題。Python擁有一個multiprocessing模組可以幫助我們輕鬆建立多程序:
在系統上執行得到
相比於多執行緒版本,效能有所提升。
但是時間並沒有下降到我們之前版本的一半,這是因為程序管理有自己的開銷。多程序比多執行緒更“重”,因此請記住,這可能成為規模瓶頸。
替代Python直譯器:Python中有多個直譯器實現辦法,分別用C,Java,C#和Python編寫的CPython,JPython,IronPython和PyPy是最受歡迎的。GIL只存在於傳統的Python實現方法如CPython中。如果你的程式及其庫檔案可以通過別的實現方式實現,那麼你也可以嘗試一下。
等等看吧:許多使用者利用GIL提升了單執行緒任務效能表現。當然多執行緒程式設計師們也不必為此煩惱,因為Python社群內的一些聰明大腦們正在致力於從CPython中刪除GIL。其中一種嘗試為Giletomy。
Python GIL經常被認為是一個神祕而困難的話題。但是請記住作為一名Python支持者,只有當您正在編寫C擴充套件或者您的程式中有計算密集型的多執行緒任務時才會被GIL影響。
在這種情況下,這篇文章應該給了你需要的一切去了解GIL是什麼以及如何在自己的專案中處理它。如果您希望瞭解GIL的低層次內部執行,我建議您觀看David Beazley的Understanding the Python GIL。