本文首發於知乎html
關鍵詞:線程安全、GIL、原子操做(atomic operation)、存儲數據類型(List、Queue.Queue、collections.deque)python
當多個線程同時進行,且共同修改同一個資源時,咱們必須保證修改不會發生衝突,數據修改不會發生錯誤,也就是說,咱們必須保證線程安全。編程
同時咱們知道,python中因爲GIL的存在,即便開了多線程,同一個時間也只有一個線程在執行。安全
那麼這是否就說明python中多個線程執行時,不會發生衝突呢?答案是否認的。bash
來看下面這段代碼數據結構
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
這一步操做,並非簡單的一步,而能夠看作兩步的結合,以下app
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中經常使用的隊列數據結構
List VS Queue.Queue VS collections.deque
首先,咱們須要將Queue.Queue
和其餘二者區分開來,由於它的出現主要用於線程之間的通訊,而其餘兩者主要用做存儲數據的工具。當咱們須要實現Condition
鎖時,就要用Queue.Queue
,單純存儲數據則用後二者。
collections.deque
與list
的區別主要在於數據的插入與提取上。若是要將數據插入列表頭部,或者從頭部提取數據,則前者的效率遠遠高於後者,這是前者的雙向隊列特性,優點毋庸置疑。若是對提取的順序無所謂,則沒有必要必定要用collections.deque
本節主要參考下面兩個回答
專欄主頁:python編程
專欄目錄:目錄
版本說明:軟件及包版本說明