本文首發於知乎
關鍵詞:執行緒安全、GIL、原子操作(atomic operation)、儲存資料型別(List、Queue.Queue、collections.deque)
當多個執行緒同時進行,且共同修改同一個資源時,我們必須保證修改不會發生衝突,資料修改不會發生錯誤,也就是說,我們必須保證執行緒安全。
同時我們知道,python中由於GIL的存在,即使開了多執行緒,同一個時間也只有一個執行緒在執行。
那麼這是否就說明python中多個執行緒執行時,不會發生衝突呢?答案是否定的。
GIL下的執行緒不安全
來看下面這段程式碼
import threading
import time
zero = 0
def change_zero():
global zero
for i in range(3000000):
zero += 1
zero -= 1
th1 = threading.Thread(target = change_zero)
th2 = threading.Thread(target = change_zero)
th1.start()
th2.start()
th1.join()
th2.join()
print(zero)
複製程式碼
兩個執行緒共同修改zero
變數,每次對變數的操作都是先加1再減1,按理說執行3000000次,zero
結果應該還是0,但是執行過這段程式碼發現,結果經常不是0,而且每次執行結果都不一樣,這就是資料修改之間發生衝突的結果。
其根本原因在於,zero += 1
這一步操作,並不是簡單的一步,而可以看做兩步的結合,如下
x = zero + 1
zero = x
複製程式碼
所以可能在一個執行緒執行時,兩步只執行了一步x = zero + 1
(即還沒來得及對zero進行修改),GIL鎖就給了另一個執行緒(不熟悉鎖的概念以及GIL的可以先看這篇文章),等到GIL鎖回到第一個執行緒,zero已經發生了改變,這時再執行接下來的zero = x
,結果就不是對zero
加1了。一次出錯的完整的模擬過程如下
初始:zero = 0
th1: x1 = zero + 1 # x1 = 1
th2: x2 = zero + 1 # x2 = 1
th2: zero = x2 # zero = 1
th1: zero = x1 # zero = 1 問題出在這裡,兩次賦值,本來應該加2變成了加1
th1: x1 = zero - 1 # x1 = 0
th1: zero = x1 # zero = 0
th2: x2 = zero - 1 # x2 = -1
th2: zero = x2 # zero = -1
結果:zero = -1
複製程式碼
為了更好地說明python在GIL下仍存線上程不安全的原因,這裡需要引入一個概念:原子操作(atomic operation)
原子操作
原子操作,指不會被執行緒排程機制打斷的操作,這種操作一旦開始,就一直執行到結束,中間不會切換到其他執行緒。
像zero += 1
這種一步可以被拆成多步的程式,就不是一個原子操作。不是原子操作的直接後果就是它沒有完全執行結束,就有可能切換到其他執行緒,此時如果其他執行緒修改的是同一個變數,就有可能發生資源修改衝突。
一個解決辦法是通過加鎖(Lock),將上面change_zero
函式的定義改為
def change_zero():
global zero
for i in range(1000000):
with lock:
zero += 1
zero -= 1
複製程式碼
加鎖後,鎖內部的程式要麼不執行,要執行就會執行結束才會切換到其他執行緒,這其實相當於實現了一種“人工原子操作”,一整塊程式碼當成一個整體執行,不會被打斷。這樣做就可以防止資源修改的衝突。讀者可以試著加鎖後重新執行程式,會發現結果zero
變數始終輸出為0。
下面我們來考慮一個問題:如果程式本身就是原子操作,是不是就自動實現了執行緒安全,就不需要加鎖了呢?答案是肯定的。
舉一個例子,現在我們要維護一個佇列,開啟多個執行緒,一部分執行緒負責向佇列中新增元素,另一部分執行緒負責提取元素進行後續處理。這時,這個佇列就是在多個執行緒之間共享的一個物件,我們必須保證在新增和提取的過程中,不會發生衝突,也就是說要保證執行緒安全。如果操作過程比較複雜,我們可以通過加鎖來使多個操作中間不會中斷。但是如果這個程式本身就是原子操作,則不需要新增額外的保護措施。
比如我們用queue
模組中的Queue
物件來維護佇列,通過Queue.put
填入元素,通過Queue.get
提取元素,因為Queue.put
和Queue.get
都是原子操作,所以要麼執行,要麼不執行,不會存在被中斷的問題,所以這裡就不需要新增多餘的保護措施。
那麼這裡自然就會產生一個問題:我們怎麼知道哪些操作是原子操作,哪些不是呢?官網上列了一個表
下面這些都是原子操作
L.append(x)
L1.extend(L2)
x = L[i]
x = L.pop()
L1[i:j] = L2
L.sort()
x = y
x.field = y
D[x] = y
D1.update(D2)
D.keys()
複製程式碼
下面這些則不是原子操作
i = i+1
L.append(L[-1])
L[i] = L[j]
D[x] = D[x] + 1
複製程式碼
這裡要注意的一點是,我們有時會聽到說python中的list物件不是執行緒安全的,這個說法是不嚴謹的,因為執行緒是否安全,針對的不是物件,而是操作。如果我們指這樣的操作L[0] = L[0] + 1
,它當然不是一個原子操作,不加以保護就會導致執行緒不安全,而L.append(i)
這樣的操作則是執行緒安全的。
因此列表是可以用作多執行緒中的儲存物件的。但是我們一般不用列表,而使用queue.Queue
,是因為後者內部實現了Condition
鎖的通訊機制,詳情請看這篇文章
下面我們回到原子操作,雖然官網上列出了一些常見的操作,但是有時我們還需要自己能夠判斷的方法,可以使用dis
模組的dis
函式,舉例如下所示
from dis import dis
a = 0
def fun():
global a
a = a + 1
dis(fun)
複製程式碼
結果如下
5 0 LOAD_GLOBAL 0 (a)
3 LOAD_CONST 1 (1)
6 BINARY_ADD
7 STORE_GLOBAL 0 (a)
10 LOAD_CONST 0 (None)
13 RETURN_VALUE
複製程式碼
我們只要關注每一行即可,每一行表示執行這個fun
函式的過程,可以被拆分成這些步驟,匯入全域性變數-->匯入常數-->執行加法-->儲存變數……,這裡每一個步驟都是指令位元組碼,可以看做原子操作。這裡列出的是fun
函式執行的過程,而我們要關心的是a = a + 1
這個過程包含了幾個指令,可以看到它包含了兩個,即BINARY_ADD
和STORE_GLOBAL
,如果在前者(運算加和)執行後,後者(賦值)還沒開始時,切換了執行緒,就會出現我們上文例子中的修改資源衝突。
下面我們來看看L.append(i)
過程的位元組碼
from dis import dis
l = []
def fun():
global l
l.append(1)
dis(fun)
複製程式碼
得到結果
5 0 LOAD_GLOBAL 0 (l)
3 LOAD_ATTR 1 (append)
6 LOAD_CONST 1 (1)
9 CALL_FUNCTION 1 (1 positional, 0 keyword pair)
12 POP_TOP
13 LOAD_CONST 0 (None)
16 RETURN_VALUE
複製程式碼
可以看到append
其實只有POP_TOP
這一步,要麼執行,要麼不執行,不會出現被中斷的問題。
原子操作我們就講到這裡,接下來,對比一下python中常用的佇列資料結構
python中常見佇列對比
List VS Queue.Queue VS collections.deque
首先,我們需要將Queue.Queue
和其他兩者區分開來,因為它的出現主要用於執行緒之間的通訊,而其他二者主要用作儲存資料的工具。當我們需要實現Condition
鎖時,就要用Queue.Queue
,單純儲存資料則用後兩者。
collections.deque
與list
的區別主要在於資料的插入與提取上。如果要將資料插入列表頭部,或者從頭部提取資料,則前者的效率遠遠高於後者,這是前者的雙向佇列特性,優勢毋庸置疑。如果對提取的順序無所謂,則沒有必要一定要用collections.deque
本節主要參考下面兩個回答
歡迎關注我的知乎專欄
專欄主頁:python程式設計
專欄目錄:目錄
版本說明:軟體及包版本說明