多線程的優勢在於並發性,即可以同時運行多個任務。但是當線程需要使用共享數據時,也可能會由於數據不同步產生“錯誤情況”,這是由系統的線程調度具有一定的隨機性造成的。
互斥鎖的作用就是解決數據不同步問題。關於互斥鎖,有一個經典的“銀行取錢”問題。銀行取錢的基本流程可以分為如下幾個步驟:
- 用戶輸入賬戶、密碼,系統判斷用戶的賬戶、密碼是否匹配。
- 用戶輸入取款金額。
- 系統判斷賬戶余額是否大於取款金額。
- 如果余額大於取款金額,則取款成功;如果余額小於取款金額,則取款失敗。
乍一看上去,這確實就是日常生活中的取款流程,這個流程沒有任何問題。但一旦將這個流程放在多線程並發的場景下,就有可能出現問題。注意,此處說的是有可能,並不是一定。也許你的程序運行了一百萬次都沒有出現問題,但沒有出現問題並不等於沒有問題!
按照上面的流程編寫取款程序,並使用兩個線程分別模擬兩個人使用同一個賬戶做並發取錢操作。此處忽略檢查賬戶和密碼的操作,僅僅模擬后面三步操作。下面先定義一個賬戶類,該賬戶類封裝了賬戶編號和余額兩個成員變量。
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 兩個類,它們都提供了如下兩個方法來加互斥鎖和釋放互斥鎖:
- acquire(blocking=True, timeout=-1):請求對 Lock 或 RLock 加鎖,其中 timeout 參數指定加鎖多少秒。
- 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