列表與隊列——談談線程安全

本文首發於知乎html

關鍵詞:線程安全、GIL、原子操做(atomic operation)、存儲數據類型(List、Queue.Queue、collections.deque)python

當多個線程同時進行,且共同修改同一個資源時,咱們必須保證修改不會發生衝突,數據修改不會發生錯誤,也就是說,咱們必須保證線程安全。編程

同時咱們知道,python中因爲GIL的存在,即便開了多線程,同一個時間也只有一個線程在執行。安全

那麼這是否就說明python中多個線程執行時,不會發生衝突呢?答案是否認的。bash

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這一步操做,並非簡單的一步,而能夠看作兩步的結合,以下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.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編程

專欄目錄:目錄

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

相關文章
相關標籤/搜索