Python互斥鎖(Lock):解決多線程安全問題


多線程的優勢在於並發性,即可以同時運行多個任務。但是當線程需要使用共享數據時,也可能會由於數據不同步產生“錯誤情況”,這是由系統的線程調度具有一定的隨機性造成的。

互斥鎖的作用就是解決數據不同步問題。關於互斥鎖,有一個經典的“銀行取錢”問題。銀行取錢的基本流程可以分為如下幾個步驟:

  1. 用戶輸入賬戶、密碼,系統判斷用戶的賬戶、密碼是否匹配。
  2. 用戶輸入取款金額。
  3. 系統判斷賬戶余額是否大於取款金額。
  4. 如果余額大於取款金額,則取款成功;如果余額小於取款金額,則取款失敗。


乍一看上去,這確實就是日常生活中的取款流程,這個流程沒有任何問題。但一旦將這個流程放在多線程並發的場景下,就有可能出現問題。注意,此處說的是有可能,並不是一定。也許你的程序運行了一百萬次都沒有出現問題,但沒有出現問題並不等於沒有問題!

按照上面的流程編寫取款程序,並使用兩個線程分別模擬兩個人使用同一個賬戶做並發取錢操作。此處忽略檢查賬戶和密碼的操作,僅僅模擬后面三步操作。下面先定義一個賬戶類,該賬戶類封裝了賬戶編號和余額兩個成員變量。

class Account:
    # 定義構造器
    def __init__(self, account_no, balance):
        # 封裝賬戶編號、賬戶余額的兩個成員變量
        self.account_no = account_no
        self.balance = balance

接下來程序會定義一個模擬取錢的函數,該函數根據執行賬戶、取錢數量進行取錢操作,取錢的邏輯是當賬戶余額不足時無法提取現金,當余額足夠時系統吐出鈔票,余額減少。

程序的主程序非常簡單,僅僅是創建一個賬戶,並啟動兩個線程從該賬戶中取錢。程序如下:

import threading
import time
import Account
# 定義一個函數來模擬取錢操作
def draw(account, draw_amount):
    # 賬戶余額大於取錢數目
    if account.balance >= draw_amount:
        # 吐出鈔票
        print(threading.current_thread().name\
            + "取錢成功!吐出鈔票:" + str(draw_amount))
#        time.sleep(0.001)
        # 修改余額
        account.balance -= draw_amount
        print("\t余額為: " + str(account.balance))
    else:
        print(threading.current_thread().name\
            + "取錢失敗!余額不足!")
# 創建一個賬戶
acct = Account.Account("1234567" , 1000)
# 模擬兩個線程對同一個賬戶取錢
threading.Thread(name='', target=draw , args=(acct , 800)).start()
threading.Thread(name='', target=draw , args=(acct , 800)).start()

先不要管程序中第 12 行被注釋掉的代碼,上面程序是一個非常簡單的取錢邏輯,這個取錢邏輯與實際的取錢操作也很相似。

多次運行上面程序,很有可能都會看到如圖 1 所示的錯誤結果。

甲取錢成功!吐出鈔票:800
乙取錢成功!吐出鈔票:800
余額為: 200
余額為: -600

所示的運行結果並不是銀行所期望的結果(不過有可能看到正確的運行結果),這正是多線程編程突然出現的“偶然” 錯誤因為線程調度的不確定性。

假設系統線程調度器在注釋代碼處暫停,讓另一個線程執行(為了強制暫停,只要取消程序中注釋代碼前的注釋即可)。取消注釋后,再次運行程序,將總可以看到如圖 1 所示的錯誤結果。

問題出現了,賬戶余額只有 1000 元時取出了 1600 元,而且賬戶余額出現了負值,遠不是銀行所期望的結果。雖然上面程序是人為地使用 time.sleep(0.001) 來強制線程調度切換,但這種切換也是完全可能發生的(100000 次操作只要有 1 次出現了錯誤,那就是由編程錯誤引起的)。

Python互斥鎖同步線程

之所以出現如圖 1 所示的錯誤結果,是因為 run() 方法的方法體不具有線程安全性,程序中有兩個並發線程在修改 Account 對象,而且系統恰好在注釋代碼處執行線程切換,切換到另一個修改 Account 對象的線程,所以就出現了問題。

為了解決這個問題,Python 的 threading 模塊引入了互斥鎖(Lock)。threading 模塊提供了 Lock 和 RLock 兩個類,它們都提供了如下兩個方法來加互斥鎖和釋放互斥鎖:

  1. acquire(blocking=True, timeout=-1):請求對 Lock 或 RLock 加鎖,其中 timeout 參數指定加鎖多少秒。
  2. release():釋放鎖。


Lock 和 RLock 的區別如下:

  • threading.Lock:它是一個基本的鎖對象,每次只能鎖定一次,其余的鎖請求,需等待鎖釋放后才能獲取。
  • threading.RLock:它代表可重入鎖(Reentrant Lock)。對於可重入鎖,在同一個線程中可以對它進行多次鎖定,也可以多次釋放。如果使用 RLock,那么 acquire() 和 release() 方法必須成對出現。如果調用了 n 次 acquire() 加鎖,則必須調用 n 次 release() 才能釋放鎖。


由此可見,RLock 鎖具有可重入性。也就是說,同一個線程可以對已被加鎖的 RLock 鎖再次加鎖,RLock 對象會維持一個計數器來追蹤 acquire() 方法的嵌套調用,線程在每次調用 acquire() 加鎖后,都必須顯式調用 release() 方法來釋放鎖。所以,一段被鎖保護的方法可以調用另一個被相同鎖保護的方法。

Lock 是控制多個線程對共享資源進行訪問的工具。通常,鎖提供了對共享資源的獨占訪問,每次只能有一個線程對 Lock 對象加鎖,線程在開始訪問共享資源之前應先請求獲得 Lock 對象。當對共享資源訪問完成后,程序釋放對 Lock 對象的鎖定。

在實現線程安全的控制中,比較常用的是 RLock。通常使用 RLock 的代碼格式如下:

class X:
    #定義需要保證線程安全的方法
    def m () :
        #加鎖
        self.lock.acquire()
        try :
            #需要保證線程安全的代碼
            #...方法體
        #使用finally 塊來保證釋放鎖
        finally :
            #修改完成,釋放鎖
            self.lock.release()

使用 RLock 對象來控制線程安全,當加鎖和釋放鎖出現在不同的作用范圍內時,通常建議使用 finally 塊來確保在必要時釋放鎖。

通過使用 Lock 對象可以非常方便地實現線程安全的類,線程安全的類具有如下特征:

  • 該類的對象可以被多個線程安全地訪問。
  • 每個線程在調用該對象的任意方法之后,都將得到正確的結果。
  • 每個線程在調用該對象的任意方法之后,該對象都依然保持合理的狀態。


總的來說,不可變類總是線程安全的,因為它的對象狀態不可改變;但可變對象需要額外的方法來保證其線程安全。例如,上面的 Account 就是一個可變類,它的 self.account_no和self._balance(為了更好地封裝,將 balance 改名為 _balance)兩個成員變量都可以被改變,當兩個錢程同時修改 Account 對象的 self._balance 成員變量的值時,程序就出現了異常。下面將 Account 類對 self.balance 的訪問設置成線程安全的,那么只需對修改 self.balance 的方法增加線程安全的控制即可。

將 Account 類改為如下形式,它就是線程安全的:

import threading
import time
class Account:
    # 定義構造器
    def __init__(self, account_no, balance):
        # 封裝賬戶編號、賬戶余額的兩個成員變量
        self.account_no = account_no
        self._balance = balance
        self.lock = threading.RLock()
    # 因為賬戶余額不允許隨便修改,所以只為self._balance提供getter方法
    def getBalance(self):
        return self._balance
    # 提供一個線程安全的draw()方法來完成取錢操作
    def draw(self, draw_amount):
        # 加鎖
        self.lock.acquire()
        try:
            # 賬戶余額大於取錢數目
            if self._balance >= draw_amount:
                # 吐出鈔票
                print(threading.current_thread().name\
                    + "取錢成功!吐出鈔票:" + str(draw_amount))
                time.sleep(0.001)
                # 修改余額
                self._balance -= draw_amount
                print("\t余額為: " + str(self._balance))
            else:
                print(threading.current_thread().name\
                    + "取錢失敗!余額不足!")
        finally:
            # 修改完成,釋放鎖
            self.lock.release()

上面程序中的定義了一個 RLock 對象。在程序中實現 draw() 方法時,進入該方法開始執行后立即請求對 RLock 對象加鎖,當執行完 draw() 方法的取錢邏輯之后,程序使用 finally 塊來確保釋放鎖。

程序中 RLock 對象作為同步鎖,線程每次開始執行 draw() 方法修改 self.balance 時,都必須先對 RLock 對象加鎖。當該線程完成對 self._balance 的修改,將要退出 draw() 方法時,則釋放對 RLock 對象的鎖定。這樣的做法完全符合“加鎖→修改→釋放鎖”的安全訪問邏輯。

當一個線程在 draw() 方法中對 RLock 對象加鎖之后,其他線程由於無法獲取對 RLock 對象的鎖定,因此它們同時執行 draw() 方法對 self._balance 進行修改。這意味着,並發線程在任意時刻只有一個線程可以進入修改共享資源的代碼區(也被稱為臨界區),所以在同一時刻最多只有一個線程處於臨界區內,從而保證了線程安全。

為了保證 Lock 對象能真正“鎖定”它所管理的 Account 對象,程序會被編寫成每個 Account 對象有一個對應的 Lock(就像一個房間有一個鎖一樣)。

上面的 Account 類增加了一個代表取錢的 draw() 方法,並使用 Lock 對象保證該 draw() 方法的線程安全,而且取消了 setBalance() 方法(避免程序直接修改 self._balance 成員變量),因此線程執行體只需調用 Account 對象的 draw() 方法即可執行取錢操作。

下面程序創建並啟動了兩個取錢線程:

import threading
import Account
# 定義一個函數來模擬取錢操作
def draw(account, draw_amount):
    # 直接調用account對象的draw()方法來執行取錢操作
    account.draw(draw_amount)
# 創建一個賬戶
acct = Account.Account("1234567" , 1000)
# 模擬兩個線程對同一個賬戶取錢
threading.Thread(name='', target=draw , args=(acct , 800)).start()
threading.Thread(name='', target=draw , args=(acct , 800)).start()

上面程序中代表線程執行體的 draw() 函數無須自己實現取錢操作,而是直接調用 account 的 draw() 方法來執行取錢操作。由於 draw() 方法己經使用 RLock 對象實現了線程安全,因此上面程序就不會導致線程安全問題。

多次重復運行上面程序,總可以看到如圖 2 所示的運行結果。

甲取錢成功!吐出鈔票:800
    余額為: 200
乙取錢失敗!余額不足!

Process finished with exit code 0

 

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM