python進階(16)深入瞭解GIL鎖(最詳細)

Silent丿丶黑羽發表於2021-04-23

前言

python的使用者都知道Cpython直譯器有一個弊端,真正執行時同一時間只會有一個執行緒執行,這是由於設計者當初設計的一個缺陷,裡面有個叫GIL鎖的,但他到底是什麼?我們只知道因為他導致python使用多執行緒執行時,其實一直是單執行緒,但是原理卻不知道,那麼接下來我們就認識一下GIL鎖
 

什麼是GIL鎖

GIL(Global Interpreter Lock)不是Python獨有的特性,它只是在實現CPython(Python直譯器)時,引入的一個概念。在官方網站中定義如下:

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

由定義可知,GIL是一個互斥鎖(mutex)。它阻止了多個執行緒同時執行Python位元組碼,毫無疑問,這降低了執行效率。理解GIL的必要性,需要了解CPython對於執行緒安全的記憶體管理機制。
 

CPython對執行緒安全的記憶體管理機制

Python使用引用計數來進行記憶體管理,在Python中建立的物件都會有引用計數,來記錄有多少個指標指向它。當引用計數的值為0時,就會自動釋放記憶體
 
我們來看一個小例子,來解釋引用計數的原理

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

可以看到,a 的引用計數值為 3,因為有 a、b 和作為引數傳遞的 getrefcount 都引用了一個空列表。
如果有2個python執行緒同時引用a,那麼2個執行緒都會嘗試對其進行資料操作,多個執行緒同時對一個資料進行增加或減少的操作,如果發生這種情況,則可能導致記憶體洩漏
 

GIL鎖的產生

由於多個執行緒同時對資料進行操作,會引發資料不一致,導致記憶體洩漏,我們可以對其進行加鎖,所以Cpython就建立了GIL鎖
但是既然有了鎖,一個物件就需要一把鎖,那麼多個物件就會有多把鎖,可能會給我們帶來2個問題

  • 1.死鎖(執行緒之間互相爭搶鎖的資源)
  • 2.反覆獲取和釋放鎖而導致效能降低。

為了保證單執行緒情況下python的正常執行和效率,GIL鎖(單一鎖)由此產生了,它新增了一個規則,即任何Python位元組碼的執行都需要獲取直譯器鎖。這樣可以防止死鎖(因為只有一個鎖),並且不會帶來太多的效能開銷。但這實際上使所有受CPU約束的Python程式(指的是CPU密集型程式)都是單執行緒的。
 

GIL鎖的底層原理


上面這張圖,就是 GIL 在 Python 程式的工作示例。其中,Thread 1、2、3 輪流執行,每一個執行緒在開始執行時,都會鎖住 GIL,以阻止別的執行緒執行;同樣的,每一個執行緒執行完一段後,會釋放 GIL,以允許別的執行緒開始利用資源。
 
執行緒釋放GIL鎖有兩種情況,一是遇到IO操作,二是Time Tick到期。IO操作很好理解,比如發出一個http請求,等待響應。那麼Time Tick到期是什麼呢?Time Tick規定了執行緒的最長執行時間,超過時間後自動釋放GIL鎖。Python 3 以後,間隔時間大致為15毫秒
 
雖然都是釋放GIL鎖,但這兩種情況是不一樣的。比如,Thread1遇到IO操作釋放GIL,由Thread2和Thread3來競爭這個GIL鎖,Thread1不再參與這次競爭。如果是Thread1因為Time Tick到期釋放GIL(多數是CPU密集型任務),那麼三個執行緒可以同時競爭這把GIL鎖,可能出現Thread1在競爭中勝出,再次執行的情況。單核CPU下,這種情況不算特別糟糕。因為只有1個CPU,所以CPU的利用率是很高的。
 
在多核CPU下,由於GIL鎖的全域性特性,無法發揮多核的特性,GIL鎖會使得多執行緒任務的效率大大降低。

Thread1在CPU1上執行,Thread2在CPU2上執行。GIL是全域性的,CPU2上的Thread2需要等待CPU1上的Thread1讓出GIL鎖,才有可能執行。如果在多次競爭中,Thread1都勝出,Thread2沒有得到GIL鎖,意味著CPU2一直是閒置的,無法發揮多核的優勢。
 
為了避免同一執行緒霸佔CPU,在python3.2版本之後,執行緒會自動的調整自己的優先順序,使得多執行緒任務執行效率更高。
既然GIL降低了多核的效率,那保留它的目的是什麼呢?這就和執行緒執行的安全有關。
 

Python GIL不能絕對保證執行緒安全

def add():
    global n
    for i in range(10**1000):
        n = n +1
def sub():
    global n
    for i in range(10**1000):
        n = n - 1
n = 0
import threading
a = threading.Thread(target=add,)
b = threading.Thread(target=sub,)
a.start()
b.start()
a.join()
b.join()
print n

上面的程式對n做了同樣數量的加法和減法,那麼n理論上是0。但執行程式,列印n,發現它不是0。問題出在哪裡呢,問題在於python的每行程式碼不是原子化的操作。比如n = n+1這步,不是一次性執行的。如果去檢視python編譯後的位元組碼執行過程,可以看到如下結果。

19 LOAD_GLOBAL              1 (n)
22 LOAD_CONST               3 (1)
25 BINARY_ADD          
26 STORE_GLOBAL             1 (n)

從過程可以看出,n = n +1 操作分成了四步完成。因此,n = n+1不是一個原子化操作。

  • 1.載入全域性變數n
  • 2.載入常數1
  • 3.進行二進位制加法運算
  • 4.將運算結果存入變數n。

根據前面的執行緒釋放GIL鎖原則,執行緒a執行這四步的過程中,有可能會讓出GIL。如果這樣,n=n+1的運算過程就被打亂了。最後的結果中,得到一個非零的n也就不足為奇。
 

總結

對於IO密集型應用,多執行緒的應用和多程式應用區別不大。即便有GIL存在,由於IO操作會導致GIL釋放,其他執行緒能夠獲得執行許可權。由於多執行緒的通訊成本低於多程式,因此偏向使用多執行緒。
 
對於計算密集型應用,由於CPU一直處於被佔用狀態,GIL鎖直到規定時間才會釋放,然後才會切換狀態,導致多執行緒處於絕對的劣勢,此時可以採用多程式+協程。

參考資料
https://realpython.com/python-gil/
https://zhuanlan.zhihu.com/p/97218985

相關文章