線程安全
系統的線程調度是隨機的,當多個線程可以同時修改某一資源的時候,就會產生線程安全問題,最後會導致達不到預期結果,但也因爲線程調度有隨機性,可能我們運行很多次或者很久的程序都沒有出過錯,但並不等於不存在問題
例如一個取錢的場景,一個賬戶有一定的餘額,當取錢的量大於餘額的時候,會取款失敗,小於餘額的時候則取款成功,這個邏輯在單線程情況下沒有任何問題,但是放在多線程場景下就會出現混亂,例如兩個線程取錢,第一個線程取錢可能小於賬戶餘額可以取款成功,但是第二個線程也取款,恰巧在第一個線程還沒完成流程,餘額沒有發生變動的時候,第二個線程開始取錢也判斷了是否小於餘額,恰巧也小於餘額,也能取款成功,如果這兩個線程的取款總額是大於餘額的,但是每個線程的取款都是小於餘額的,兩個都能成功,但賬面剩餘的餘額將會是負數,這顯然是不符合實際的
這就是所謂的線程不安全
該場景代碼如下
先定義一個賬戶
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(1)
# 修改餘額
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()
這段代碼只要執行次數夠多,一定會出現線程不安全的情況,導致賬面餘額爲負數,因爲系統調度線程有隨機性,總會碰到偶然的錯誤的
也可以將time.sleep(0.001)
的註釋放開,則必然導致上述情形出現,因爲一個線程等待進入阻塞狀態,則另一個線程便會繼續工作,最終兩個線程都能夠取錢成功,賬戶以供1000餘額,取出去的是1600
同步鎖(Lock)
總結上述代碼,實際上就是存在兩個併發線程在修改Accout對象,而系統恰好在time.sleep(1)
時候切換到另一個修改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是比較常用的,其結構如下
Class Demo_RLock:
# 定義需要保證線程安全的方法
def account():
# 加鎖
self.lock.acquire()
try:
# 需要線程安全的代碼
# 方法體
# 使用finally塊來保證釋放鎖
finally:
# 修改完成,釋放鎖
self.lock.release()
== 不可變類都是線程安全的,因爲他的對象狀態不可改變==
加了鎖的代碼
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()
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()
線程同步解析
通過使用Lock對象可以非常方便的實現線程安全的類,線程安全的類有幾個特點:該類的對象可以被多個線程安全地訪問,每個線程在調用該對象的任意方法之後,都將得到正確的結果;每個線程在調用該對象的任意方法之後,該對象都依然保持合理的狀態
這樣就實現了安全訪問邏輯加鎖===》修改===》釋放鎖
,通過這樣的方式保證併發線程在任何一個時刻只有一個線程可以進入修改共享資源的代碼區也稱爲臨界區,同一時刻只有一個線程處於臨界區
程序在Account中定義了draw()方法完成取錢流程,而不是在線程的執行體裏實現,這種方式更符合面向對象的思想,更體現了領域驅動設計的設計模式,這個模式認爲每個類都應該是完備的領域對象,比如說Account代表用戶賬戶,它就應該提供賬戶相關的函數,通過draw()來完成取錢,而不是將setBanlance()暴露出來,這也保證了Account對象的完成性和一致性
不可變類是線程安全的,因爲他的對象狀態是不可改變的;可變類的線程安全是以降低程序的運行效率作爲代價的,爲了減少線程安全所帶來的負面影響,程序可以採用如下策略:
- 不要對線程安全類的所有方法都進行同步,只對那些會改變競爭資源關係的的方法進行同步,例如Account類的accountNo實例變量就無需同步,只需要對draw()方法進行同步
- 如果可變類有兩種運行環境:單線程環境和多線程環境,則該可變類應提供兩個版本,即線程不安全版本和線程安全版本