深入理解 python 虛擬機器器:GIL 原始碼分析——天使還是魔鬼?

2023-10-15 06:00:36

深入理解 python 虛擬機器器:GIL 原始碼分析——天使還是魔鬼?

在目前的 CPython 當中一直有一個臭名昭著的問題就是 GIL (Global Interpreter Lock ),就是全域性直譯器鎖,他限制了 Python 在多核架構當中的效能,在本篇文章當中我們將詳細分析一下 GIL 的利弊和 GIL 的 C 的原始碼。

選擇 GIL 的原因

GIL 對 Python 程式碼的影響

簡單來說,Python 全域性直譯器鎖或 GIL 是一個互斥鎖,只允許一個執行緒保持 Python 直譯器的控制權,也就是說在同一個時刻只能夠有一個執行緒執行 Python 程式碼,如果整個程式是單執行緒的話,這也無傷大雅,但是如果你的程式是多執行緒計算密集型的程式的話,這對程式的影響就很大了。

因為整個虛擬機器器都有一把大鎖進行保護,所以虛擬的程式碼就可以認為是單執行緒執行的,因此不需要做執行緒安全的防護,直接按照單執行緒的邏輯就行了。不僅僅是虛擬機器器,Python 層面的程式碼也是這樣,對於有些 Python 層面的多執行緒程式碼也可以不用鎖保護,因為本身就是執行緒安全的:

import threading

data = []


def add_data(n):
	for i in range(n):
		data.append(i)


if __name__ == '__main__':
	ts = [threading.Thread(target=add_data, args=(10,)) for _ in range(10)]
	for t in ts:
		t.start()
	for t in ts:
		t.join()

	print(data)
	print(len(data))
	print(sum(data))

在上面的程式碼當中,當程式執行完之後 len(data) 的值永遠都是 100,sum(data) 的值永遠都是 450,因為上面的程式碼是執行緒安全的,可能你會有所疑惑,上面的程式碼啟動了 10 個執行緒同時往列表當中增加資料,如果兩個執行緒同時增加資料的時候就有可能存線上程之間覆蓋的情況,最終的 len(data) 的長度應該小於 100 ?

上面的程式碼之所以是執行緒安全的原因是因為 data.append(i) 執行 append 只需要虛擬機器器的一條位元組碼,而在前面介紹 GIL 時候已經談到了,每個時刻只能夠有一個執行緒在執行虛擬機器器的位元組碼,這就保證了每個 append 的操作都是原子的,因為只有一個 append 操作執行完成之後其他的執行緒才能夠執行 append 操作。

我們來看一下上面程式的位元組碼:

  5           0 LOAD_GLOBAL              0 (range)
              2 LOAD_FAST                0 (n)
              4 CALL_FUNCTION            1
              6 GET_ITER
        >>    8 FOR_ITER                14 (to 24)
             10 STORE_FAST               1 (i)

  6          12 LOAD_GLOBAL              1 (data)
             14 LOAD_METHOD              2 (append)
             16 LOAD_FAST                1 (i)
             18 CALL_METHOD              1
             20 POP_TOP
             22 JUMP_ABSOLUTE            8
        >>   24 LOAD_CONST               0 (None)
             26 RETURN_VALUE

在上面的位元組碼當中 data.append(i) 對應的位元組碼為 (14, 16, 18) 這三條位元組碼,而 (14, 16) 是不會產生資料競爭的問題的,因為他只是載入物件的方法和區域性變數 i 的值,讓 append 執行的方法是位元組碼 CALL_METHOD,而同一個時刻只能夠有一個位元組碼在執行,因此這條位元組碼也是執行緒安全的,所以才會有上面的程式碼是執行緒安全的情況出現。

我們再來看一個非執行緒安全的例子:

import threading
data = 0
def add_data(n):
	global data
	for i in range(n):
		data += 1

if __name__ == '__main__':
	ts = [threading.Thread(target=add_data, args=(100000,)) for _ in range(20)]
	for t in ts:
		t.start()
	for t in ts:
		t.join()
	print(data)

在上面的程式碼當中對於 data += 1 這個操作就是非執行緒安全的,因為這行程式碼組合編譯成 3 條位元組碼:

  9          12 LOAD_GLOBAL              1 (data)
             14 LOAD_CONST               1 (1)
             16 INPLACE_ADD

首先 LOAD_GLOBAL,載入 data 資料,LOAD_CONST 載入常數 1,最後執行 INPLACE_ADD 進行加法操作,這就可能出現執行緒1執行完 LOAD_GLOBAL 之後,執行緒 2 連續執行 3 條位元組碼,那麼這個時候 data 的值已經發生變化了,而執行緒 1 拿的還是舊的資料,因此最終執行的之後會出現執行緒不安全的情況。(實際上虛擬機器器在執行的過程當中,發生資料競爭比這個複雜很多,這裡只是簡單說明一下)

GIL 對於虛擬機器器的影響

除了上面 GIL 對於 Python 程式碼層面的影響,GIL 對於虛擬機器器來說還有一個非常好的作用就是他不會讓虛擬機器器產生死鎖的現象,因為整個虛擬機器器只有一把鎖