通俗易懂:說說 Python 裏的線程安全、原子操做

首發於微信公衆號:Python編程時光html

在線博客地址:http://python.iswbm.com/en/latest/c01/c01_42.htmlpython


在併發編程時,若是多個線程訪問同一資源,咱們須要保證訪問的時候不會產生衝突,數據修改不會發生錯誤,這就是咱們常說的 線程安全數據庫

那什麼狀況下,訪問數據時是安全的?什麼狀況下,訪問數據是不安全的?如何知道你的代碼是否線程安全?要如何訪問數據才能保證數據的安全?編程

本篇文章會一一回答你的問題。安全

1. 線程不安全是怎樣的?

要搞清楚什麼是線程安全,就要先了解線程不安全是什麼樣的。微信

好比下面這段代碼,開啓兩個線程,對全局變量 number 各自增 10萬次,每次自增 1。多線程

from threading import Thread, Lock

number = 0

def target():
    global number
    for _ in range(1000000):
        number += 1

thread_01 = Thread(target=target)
thread_02 = Thread(target=target)
thread_01.start()
thread_02.start()

thread_01.join()
thread_02.join()

print(number)

正常咱們的預期輸出結果,一個線程自增100萬,兩個線程就自增 200 萬嘛,輸出確定爲 2000000 。併發

可事實卻並非你想的那樣,無論你運行多少次,每次輸出的結果都會不同,而這些輸出結果都有一個特色是,都小於 200 萬。app

如下是執行三次的結果函數

1459782
1379891
1432921

這種現象就是線程不安全,究其根因,實際上是咱們的操做 number += 1 ,不是原子操做,纔會致使的線程不安全。

2. 什麼是原子操做?

原子操做(atomic operation),指不會被線程調度機制打斷的操做,這種操做一旦開始,就一直運行到結束,中間不會切換到其餘線程。

它有點相似數據庫中的 事務

在 Python 的官方文檔上,列出了一些常見原子操做

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

像上面的我使用自增操做 number += 1,其實等價於 number = number + 1,能夠看到這種能夠拆分紅多個步驟(先讀取相加再賦值),並不屬於原子操做。

這樣就致使多個線程同時讀取時,有可能讀取到同一個 number 值,讀取兩次,卻只加了一次,最終致使自增的次數小於預期。

當咱們仍是沒法肯定咱們的代碼是否具備原子性的時候,能夠嘗試經過 dis 模塊裏的 dis 函數來查看

當咱們執行這段代碼時,能夠看到 number += 1 這一行代碼,由兩條字節碼實現。

  • BINARY_ADD :將兩個值相加
  • STORE_GLOBAL: 將相加後的值從新賦值

每一條字節碼指令都是一個總體,沒法分割,他實現的效果也就是咱們所說的原子操做。

當一行代碼被分紅多條字節碼指令的時候,就表明在線程線程切換時,有可能只執行了一條字節碼指令,此時若這行代碼裏有被多個線程共享的變量或資源時,而且拆分的多條指令裏有對於這個共享變量的寫操做,就會發生數據的衝突,致使數據的不許確。

爲了對比,咱們從上面列表的原子操做拿一個出來也來試試,是否是真如官網所說的原子操做。

這裏我拿字典的 update 操做舉例,代碼和執行過程以下圖

從截圖裏能夠看到,info.update(new) 雖然也分爲好幾個操做

  • LOAD_GLOBAL:加載全局變量
  • LOAD_ATTR: 加載屬性,獲取 update 方法
  • LOAD_FAST:加載 new 變量
  • CALL_FUNCTION:調用函數
  • POP_TOP:執行更新操做

但咱們要知道真正會引導數據衝突的,其實不是讀操做,而是寫操做。

上面這麼多字節碼指令,寫操做都只有一個(POP_TOP),所以字典的 update 方法是原子操做。

3. 實現人工原子操做

在多線程下,咱們並不能保證咱們的代碼都具備原子性,所以如何讓咱們的代碼變得具備 「原子性」 ,就是一件很重要的事。

方法也很簡單,就是當你在訪問一個多線程間共享的資源時,加鎖能夠實現相似原子操做的效果,一個代碼要嘛不執行,執行了的話就要執行完畢,才能接受線程的調度。

所以,咱們使用加鎖的方法,對例子一進行一些修改,使其具有原子性。

from threading import Thread, Lock


number = 0
lock = Lock()


def target():
    global number
    for _ in range(1000000):
        with lock:
            number += 1

thread_01 = Thread(target=target)
thread_02 = Thread(target=target)
thread_01.start()
thread_02.start()

thread_01.join()
thread_02.join()

print(number)

此時,無論你執行多少遍,輸出都是 2000000.

4. 爲何 Queue 是線程安全的?

Python 的 threading 模塊裏的消息通訊機制主要有以下三種:

  1. Event
  2. Condition
  3. Queue

使用最多的是 Queue,而咱們都知道它是線程安全的。當咱們對它進行寫入和提取的操做不會被中斷而致使錯誤,這也是咱們在使用隊列時,不須要額外加鎖的緣由。

他是如何作到的呢?

其根本緣由就是 Queue 實現了鎖原語,所以他能像第三節那樣實現人工原子操做。

原語指由若干個機器指令構成的完成某種特定功能的一段程序,具備不可分割性;即原語的執行必須是連續的,在執行過程當中不容許被中斷。

關注公衆號,獲取最新干貨!

相關文章
相關標籤/搜索