列表與佇列——談談執行緒安全

dwzb發表於2018-06-02

本文首發於知乎

關鍵詞:執行緒安全、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.putQueue.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_ADDSTORE_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.dequelist的區別主要在於資料的插入與提取上。如果要將資料插入列表頭部,或者從頭部提取資料,則前者的效率遠遠高於後者,這是前者的雙向佇列特性,優勢毋庸置疑。如果對提取的順序無所謂,則沒有必要一定要用collections.deque

本節主要參考下面兩個回答

歡迎關注我的知乎專欄

專欄主頁:python程式設計

專欄目錄:目錄

版本說明:軟體及包版本說明

相關文章