深入理解Python中的全局解釋鎖GIL
轉自:https://zhuanlan.zhihu.com/p/75780308
注:本文為蝸牛學院資深講師卿淳俊老師原創,首發自公眾號https://mp.weixin.qq.com/s/TBiqbSCsjIbNIk8ATky-tg,如需轉載請私聊我處獲得授權并注明出處。
Python是門古老的語言,要想了解這門語言的多線程和多進程以及協程,以及明白什么時候應該用多線程,什么時候應該使用多進程或協程,我們不得不談到的一個東西是Python中的GIL(全局解釋器鎖)。這篇我們就來看看這個GIL究竟是怎么回事。
1. GIL是什么?
首先來看看GIL究竟是什么。我們需要明確的一點是GIL并不是Python的特性,它是在實現Python解析器(CPython)時所引入的一個概念。就好比C++是一套語言(語法)標準,但是可以用不同的編譯器來編譯成可執行代碼。有名的編譯器例如GCC,INTEL C++,Visual C++等。Python也一樣,同樣一段代碼可以通過CPython,PyPy,Psyco等不同的Python執行環境來執行。像其中的JPython就沒有GIL。然而因為CPython是大部分環境下默認的Python執行環境。所以在很多人的概念里CPython就是Python,也就想當然的把GIL歸結為Python語言的缺陷。所以這里要先明確一點:GIL并不是Python的特性,Python完全可以不依賴于GIL。
那么CPython實現中的GIL又是什么呢?GIL全稱Global Interpreter Lock為了避免誤導,我們還是來看一下官方給出的解釋:
In CPython, the global interpreter lock, or GIL, is a mutex that prevents multiple native threads from executing Python bytecodes at once. This lock is necessary mainly because CPython’s memory management is not thread-safe. (However, since the GIL exists, other features have grown to depend on the guarantees that it enforces.)
這看起來像一個Bug一般的防止多線程并發執行機器碼的互斥鎖(mutex),究竟為什么會存在?
2. GIL為什么會存在?
GIL的問題其實是由于近十幾年來應用程序和操作系統逐步從多任務單核心演進到多任務多核心導致的 , 在一個古老的單核CPU上調度多個線程任務,大家相互共享一個全局鎖,誰在CPU執行,誰就占有這把鎖,直到這個線程因為IO操作或者Timer Tick到期讓出CPU,沒有在執行的線程就安靜的等待著這把鎖(除了等待之外,他們應該也無事可做)。下面這個圖演示了一個單核CPU的線程調度方式:
很明顯,在一個現代多核心的處理器上,上面的模型就有很大優化空間了,原來只能等待的線程任務,現在可以在其它空閑的核心上調度并發執行。由于古老GIL機制,如果線程2需要在CPU 2 上執行,它需要先等待在CPU 1 上執行的線程1釋放GIL(記住:GIL是全局的)。如果線程1是因為 i/o 阻塞讓出的GIL,那么線程2必定拿到Gil。但如果線程1是因為timer ticks計數滿100讓出GIL,那么這個時候線程1和線程2公平競爭。但要命的是,在Python 2.x, 線程1不會動態的調整自身的優先級,所以很大概率下次被選中執行的還是線程1,在很多個這樣的選舉周期內,線程2只能安靜的看著線程1拿著GIL在CPU 1上歡快的執行。
在稍微極端一點的情況下,比如線程1使用了while True在CPU 1 上執行,那就真是“一核有難,八核圍觀”了,如下圖所示:
接下來,我們用實際代碼來看看GIL的存在對多線程運行的影響(此處使用循環模擬耗時操作):
# coding:utf-8
import threading, timedef my_counter():i = 0for _ in range(100000000):i = i+1return Truedef main1():thread_ary = {}start_time = time.time()for tid in range(2):t = threading.Thread(target=my_counter)t.start()t.join() # 第一次循環的時候join方法引起主線程阻塞,但第二個線程并沒有啟動,所以兩個線程是順序執行的print("單線程順序執行total_time: {}".format(time.time() - start_time))def main2():thread_ary = {}start_time = time.time()for tid in range(2):t = threading.Thread(target=my_counter)t.start()thread_ary[tid] = tfor i in range(2):thread_ary[i].join() # 兩個線程均已啟動,所以兩個線程是并發的print("并發執行total_time: {}".format(time.time() - start_time))if __name__ == "__main__":main1()main2()
上面這段代碼,在Python3上運行,不管是并發執行還是順序執行,運行時間都差不多,這充分說明了GIL確實會在這種情況下對多線程程序的運行效率產生影響。如果是在Python2上運行,則差距更明顯。
(Python3.2以后對GIL做了較大的優化)
3. GIL是否意味著線程安全
有GIL并不意味著python一定是線程安全的,那什么時候安全,什么時候不安全,我們必須搞清楚。之前我們已經說過,一個線程有兩種情況下會釋放全局解釋器鎖,一種情況是在該線程進入IO操作之前,會主動釋放GIL,另一種情況是解釋器不間斷運行了1000字節碼(Py2)或運行15毫秒(Py3)后,該線程也會放棄GIL。既然一個線程可能隨時會失去GIL,那么這就一定會涉及到線程安全的問題。GIL雖然從設計的出發點就是考慮到線程安全,但這種線程安全是粗粒度的線程安全,即不需要程序員自己對線程進行加鎖處理(同理,所謂細粒度就是指程序員需要自行加、解鎖來保證線程安全,典型代表是 Java , 而 CPthon 中是粗粒度的鎖,即語言層面本身維護著一個全局的鎖機制,用來保證線程安全)。那么什么時候需要加鎖,什么時候不需要加鎖,這個需要具體情況具體分析。下面我們就來針對每種可能的情況進行分析和總結。
首先來看第一種線程釋放GIL的情況。假設現在線程A因為進入IO操作而主動釋放了GIL,那么在這種情況下,由于線程A的IO操作等待時間不確定,那么等待的線程B一定會得到GIL鎖,這種比較“禮貌的”情況我們一般稱為“協同式多任務處理”,相當于大家按照協商好的規則來,線程是安全的,不需要額外加鎖。
接下來,我們來看另外一種情況,即線程A是因為解釋器不間斷執行了1000字節碼的指令或不間斷運行了15毫秒而放棄了GIL,那么此時實際上線程A和線程B將同時競爭GIL鎖。在同時競爭的情況下,實際上誰會競爭成功是不確定的一個結果,所以一般被稱為“搶占式多任務處理”,這種情況下當然就看誰搶得厲害了。當然,在python3上由于對GIL做了優化,并且會動態調整線程的優先級,所以線程B的優先級會比較高,但仍然無法肯定線程B就一定會拿到GIL。那么在這種情況下,線程可能就會出現不安全的狀態。針對這種純計算的操作,我們用一段代碼來演示下這種線程不安全的狀態。代碼如下:
import threadingn = 0def add():global nfor i in range(1000000):n = n + 1def sub():global nfor i in range(1000000):n = n - 1if __name__ == "__main__":t1 = threading.Thread(target=add)t2 = threading.Thread(target=sub)t1.start()t2.start()t1.join()t2.join()print('n =', n)
上面的代碼很簡單,分別用線程1和線程2對全局變量n進行了1000000次的加和減操作。如果線程安全的話,那么最終的結果n應該還是為0。但實際上,我們運行之后,會發現這個n的值有時大有時小,完全不確定。這就是典型的多個線程操作同一個全局變量造成的線程不安全的問題。我們明白了這個問題,在這里我們只討論產生問題的原因,在后面的文章中再來討論如何通過加鎖來解決這個問題。
接下來,我們從代碼層面分析下產生這個問題的原因。在線程中,我們主要是執行了一個加法和減法的操作。為了方便說明問題,我們把函數最簡化到一個加法函數和一個減法函數,來分析它們的字節碼執行過程,來看看釋放GIL鎖是怎么引起這個問題的。演示代碼如下:
import dis
n = 0def add():global nn = n + 1print(dis.dis(add))def sub():global nn = n - 1
print(dis.dis(sub))
dis模塊中的dis方法可以打印出一個函數對應的字節碼執行過程,所以非常方便我們進行分析。運行結果如下:
不管是加法還是減法運算,都會分為4步完成。以加法為例,第一步是LOAD_GLOBAL(加載全局變量n),第二步LOAD_CONST(加載常量1),第三步進行二進制的加法,第四步將計算結果存儲到全局變量n中,加法計算結束。這四個指令如果能夠保證被作為一個整體完整地運行,那么是不會產生問題的,但根據前面說的線程釋放GIL的原則,那么很有可能在線程正在執行這四步中的任何一步的時候釋放掉GIL而進入等待狀態,這個時候發生的事情就比較有意思了。為了方便大家理解,我拿一種比較極端的情況來說明一下。比如我們在加法運算中,正準備執行第四步的時候,很不幸失去了GIL,進入等待狀態(注意此時n值仍然為0)。減法運算的線程開始執行,它加載了全局變量n(值為0),并進行減法相關的計算,它也在執行第三步的時候失去了GIL,此時它進入等待狀態,加法運算繼續。上一次加法計算繼續運行第4步,即把加法運算結果賦值給全局變量n,那么此時n的值為1。同樣道理,減法操作拿回GIL時,它之前已經加載了為0的n的值,所以它繼續操作到最后賦值那步時,n的值就為0-1=-1。換句話說,n的值要么為1,要么為-1,但我們期望的應該是0。這就造成了線程不安全的情形。最終,經過百萬次這樣不確定的加減操作,那么結果一定是不確定的。這就是引起這個問題的過程和原因。
接下來,我們還要解決另外一個問題,也就是既然GIL從粗粒度情況下存在線程不安全的可能性,那么是不是所有非IO操作引起的GIL釋放都要加鎖來解決線程安全的問題。這個問題同樣要分情況,因為python跟其他線程自由的語言比如 Java相比,它有很多操作是原子級的,針對原子級的操作,由于方法本身是單個字節碼,所以線程沒有辦法在調用期間放棄GIL。典型的例子比如sort方法,我們同樣可以看看這種原子級的操作在python的字節碼中是什么樣子,代碼演示如下:
import dislst = [4, 1, 3, 2]def foo():lst.sort()print(dis.dis(foo))
運行后結果如下:
從字節碼的角度,調用sort操作是原子級無法再分的,所以線程不會在執行期間發生GIL釋放的情況,也就是說我們可以認為sort操作是線程安全的,不需要加鎖。而我們上面演示的加法和減法操作則不是原子級的,所以我們必須要加鎖才能保證線程安全。
所以,總結一下,如果多線程的操作中不是IO密集型,并且計算操作不是原子級的操作時,那么我們需要考慮線程安全問題,否則都不需要考慮線程安全。當然,為了避免擔心哪個操作是原子的,我們可以遵循一個簡單的原則:始終圍繞共享可變狀態的讀取和寫入加鎖。畢竟,在 Python 中獲取一個 threading.Lock 也就是一行代碼的事。
4. 如何避免GIL的影響
有兩個建議:
-
在以IO操作為主的IO密集型應用中,多線程和多進程的性能區別并不大,原因在于即使在Python中有GIL鎖的存在,由于線程中的IO操作會使得線程立即釋放GIL,切換到其他非IO線程繼續操作,提高程序執行效率。相比進程操作,線程操作更加輕量級,線程之間的通訊復雜度更低,建議使用多線程。
-
如果是計算密集型的應用,盡量使用多進程或者協程來代替多線程。