關(guān)鍵詞:線程安全、GIL、原子操作(atomic operation)、存儲(chǔ)數(shù)據(jù)類型(List、Queue.Queue、collections.deque)
當(dāng)多個(gè)線程同時(shí)進(jìn)行,且共同修改同一個(gè)資源時(shí),我們必須保證修改不會(huì)發(fā)生沖突,數(shù)據(jù)修改不會(huì)發(fā)生錯(cuò)誤,也就是說,我們必須保證線程安全。
同時(shí)我們知道,python中由于GIL的存在,即使開了多線程,同一個(gè)時(shí)間也只有一個(gè)線程在執(zhí)行。
那么這是否就說明python中多個(gè)線程執(zhí)行時(shí),不會(huì)發(fā)生沖突呢?答案是否定的。
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)
復(fù)制代碼
兩個(gè)線程共同修改zero變量,每次對(duì)變量的操作都是先加1再減1,按理說執(zhí)行3000000次,zero結(jié)果應(yīng)該還是0,但是運(yùn)行過這段代碼發(fā)現(xiàn),結(jié)果經(jīng)常不是0,而且每次運(yùn)行結(jié)果都不一樣,這就是數(shù)據(jù)修改之間發(fā)生沖突的結(jié)果。
其根本原因在于,zero += 1這一步操作,并不是簡(jiǎn)單的一步,而可以看做兩步的結(jié)合,如下
x = zero + 1
zero = x
復(fù)制代碼
所以可能在一個(gè)線程執(zhí)行時(shí),兩步只執(zhí)行了一步x = zero + 1(即還沒來得及對(duì)zero進(jìn)行修改),GIL鎖就給了另一個(gè)線程(不熟悉鎖的概念以及GIL的可以先看這篇文章),等到GIL鎖回到第一個(gè)線程,zero已經(jīng)發(fā)生了改變,這時(shí)再執(zhí)行接下來的zero = x,結(jié)果就不是對(duì)zero加1了。一次出錯(cuò)的完整的模擬過程如下
初始:zero = 0
th1: x1 = zero + 1 # x1 = 1
th2: x2 = zero + 1 # x2 = 1
th2: zero = x2 # zero = 1
th1: zero = x1 # zero = 1 問題出在這里,兩次賦值,本來應(yīng)該加2變成了加1
th1: x1 = zero - 1 # x1 = 0
th1: zero = x1 # zero = 0
th2: x2 = zero - 1 # x2 = -1
th2: zero = x2 # zero = -1
結(jié)果:zero = -1
復(fù)制代碼
為了更好地說明python在GIL下仍存在線程不安全的原因,這里需要引入一個(gè)概念:原子操作(atomic operation)
原子操作
原子操作,指不會(huì)被線程調(diào)度機(jī)制打斷的操作,這種操作一旦開始,就一直運(yùn)行到結(jié)束,中間不會(huì)切換到其他線程。
像zero += 1這種一步可以被拆成多步的程序,就不是一個(gè)原子操作。不是原子操作的直接后果就是它沒有完全執(zhí)行結(jié)束,就有可能切換到其他線程,此時(shí)如果其他線程修改的是同一個(gè)變量,就有可能發(fā)生資源修改沖突。
一個(gè)解決辦法是通過加鎖(Lock),將上面change_zero函數(shù)的定義改為
def change_zero():
global zero
for i in range(1000000):
with lock:
zero += 1
zero -= 1
復(fù)制代碼
加鎖后,鎖內(nèi)部的程序要么不執(zhí)行,要執(zhí)行就會(huì)執(zhí)行結(jié)束才會(huì)切換到其他線程,這其實(shí)相當(dāng)于實(shí)現(xiàn)了一種“人工原子操作”,一整塊代碼當(dāng)成一個(gè)整體運(yùn)行,不會(huì)被打斷。這樣做就可以防止資源修改的沖突。讀者可以試著加鎖后重新運(yùn)行程序,會(huì)發(fā)現(xiàn)結(jié)果zero變量始終輸出為0。
下面我們來考慮一個(gè)問題:如果程序本身就是原子操作,是不是就自動(dòng)實(shí)現(xiàn)了線程安全,就不需要加鎖了呢?答案是肯定的。
舉一個(gè)例子,現(xiàn)在我們要維護(hù)一個(gè)隊(duì)列,開啟多個(gè)線程,一部分線程負(fù)責(zé)向隊(duì)列中添加元素,另一部分線程負(fù)責(zé)提取元素進(jìn)行后續(xù)處理。這時(shí),這個(gè)隊(duì)列就是在多個(gè)線程之間共享的一個(gè)對(duì)象,我們必須保證在添加和提取的過程中,不會(huì)發(fā)生沖突,也就是說要保證線程安全。如果操作過程比較復(fù)雜,我們可以通過加鎖來使多個(gè)操作中間不會(huì)中斷。但是如果這個(gè)程序本身就是原子操作,則不需要添加額外的保護(hù)措施。
比如我們用queue模塊中的Queue對(duì)象來維護(hù)隊(duì)列,通過Queue.put填入元素,通過Queue.get提取元素,因?yàn)?code>Queue.put和Queue.get都是原子操作,所以要么執(zhí)行,要么不執(zhí)行,不會(huì)存在被中斷的問題,所以這里就不需要添加多余的保護(hù)措施。
那么這里自然就會(huì)產(chǎn)生一個(gè)問題:我們?cè)趺粗滥男┎僮魇窃硬僮?,哪些不是呢?a target="_blank" rel="nofollow">官網(wǎng)上列了一個(gè)表
下面這些都是原子操作
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()
復(fù)制代碼
下面這些則不是原子操作
i = i+1
L.append(L[-1])
L[i] = L[j]
D[x] = D[x] + 1
復(fù)制代碼
這里要注意的一點(diǎn)是,我們有時(shí)會(huì)聽到說python中的list對(duì)象不是線程安全的,這個(gè)說法是不嚴(yán)謹(jǐn)?shù)模驗(yàn)榫€程是否安全,針對(duì)的不是對(duì)象,而是操作。如果我們指這樣的操作L[0] = L[0] + 1,它當(dāng)然不是一個(gè)原子操作,不加以保護(hù)就會(huì)導(dǎo)致線程不安全,而L.append(i)這樣的操作則是線程安全的。
因此列表是可以用作多線程中的存儲(chǔ)對(duì)象的。但是我們一般不用列表,而使用queue.Queue,是因?yàn)楹笳邇?nèi)部實(shí)現(xiàn)了Condition鎖的通信機(jī)制,詳情請(qǐng)看這篇文章
下面我們回到原子操作,雖然官網(wǎng)上列出了一些常見的操作,但是有時(shí)我們還需要自己能夠判斷的方法,可以使用dis模塊的dis函數(shù),舉例如下所示
from dis import dis
a = 0
def fun():
global a
a = a + 1
dis(fun)
復(fù)制代碼
結(jié)果如下
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
復(fù)制代碼
我們只要關(guān)注每一行即可,每一行表示執(zhí)行這個(gè)fun函數(shù)的過程,可以被拆分成這些步驟,導(dǎo)入全局變量-->導(dǎo)入常數(shù)-->執(zhí)行加法-->存儲(chǔ)變量……,這里每一個(gè)步驟都是指令字節(jié)碼,可以看做原子操作。這里列出的是fun函數(shù)執(zhí)行的過程,而我們要關(guān)心的是a = a + 1這個(gè)過程包含了幾個(gè)指令,可以看到它包含了兩個(gè),即BINARY_ADD和STORE_GLOBAL,如果在前者(運(yùn)算加和)執(zhí)行后,后者(賦值)還沒開始時(shí),切換了線程,就會(huì)出現(xiàn)我們上文例子中的修改資源沖突。
下面我們來看看L.append(i)過程的字節(jié)碼
from dis import dis
l = []
def fun():
global l
l.append(1)
dis(fun)
復(fù)制代碼
得到結(jié)果
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
復(fù)制代碼
可以看到append其實(shí)只有POP_TOP這一步,要么執(zhí)行,要么不執(zhí)行,不會(huì)出現(xiàn)被中斷的問題。
原子操作我們就講到這里,接下來,對(duì)比一下python中常用的隊(duì)列數(shù)據(jù)結(jié)構(gòu)
python中常見隊(duì)列對(duì)比
List VS Queue.Queue VS collections.deque
首先,我們需要將Queue.Queue和其他兩者區(qū)分開來,因?yàn)樗某霈F(xiàn)主要用于線程之間的通信,而其他二者主要用作存儲(chǔ)數(shù)據(jù)的工具。當(dāng)我們需要實(shí)現(xiàn)Condition鎖時(shí),就要用Queue.Queue,單純存儲(chǔ)數(shù)據(jù)則用后兩者。
collections.deque與list的區(qū)別主要在于數(shù)據(jù)的插入與提取上。如果要將數(shù)據(jù)插入列表頭部,或者從頭部提取數(shù)據(jù),則前者的效率遠(yuǎn)遠(yuǎn)高于后者,這是前者的雙向隊(duì)列特性,優(yōu)勢(shì)毋庸置疑。如果對(duì)提取的順序無所謂,則沒有必要一定要用collections.deque
本節(jié)主要參考下面兩個(gè)回答
作者:dwzb
鏈接:https://juejin.im/post/5b129a1be51d45068a6c91d4
來源:掘金
著作權(quán)歸作者所有。商業(yè)轉(zhuǎn)載請(qǐng)聯(lián)系作者獲得授權(quán),非商業(yè)轉(zhuǎn)載請(qǐng)注明出處。