程序師世界是廣大編程愛好者互助、分享、學習的平台,程序師世界有你更精彩!
首頁
編程語言
C語言|JAVA編程
Python編程
網頁編程
ASP編程|PHP編程
JSP編程
數據庫知識
MYSQL數據庫|SqlServer數據庫
Oracle數據庫|DB2數據庫
您现在的位置: 程式師世界 >> 編程語言 >  >> 更多編程語言 >> Python

Python 線程同步(一) -- 競爭條件與線程鎖

編輯:Python

1. 引言

上一篇文章中我們介紹了 Python 中的線程與用法。 python 的線程

一旦引入並發,就有可能會出現競爭條件,有時會出現意想不到的狀況。

上圖中,線程A讀取變量然後給變量賦予一個新值,然後寫入內存,但是,與此同時,B從內存中讀取相同變量,此時可能A尚未將改變後的變量寫入內存,導致B讀到的是原值,也有可能A已經寫入導致B讀取到的是新的值,由此程序運行出現了不確定性。 本文我們就來討論如何解決上述問題。

2. 單例模式與競爭條件

2.1. 單例模式

此前在介紹裝飾器時,我們看到過一種單例模式的實現。 python 魔術方法(二) 對象的創建與單例模式的實現

class SingleTon:
_instance = {}
def __new__(cls, *args, **kwargs):
if cls not in cls._instance:
cls._instance[cls] = super(SingleTon, cls).__new__(cls, *args, **kwargs)
return cls._instance[cls]
class TechTest(SingleTon):
testfield = 12

2.2. 多線程下的單例

下面我們將上面單例模式的代碼改造成多線程模式,並且加入 time.sleep(1) 來模擬創建時有一些 IO 操作的場景。

import time
from threading import Thread
class SingleTon:
_instance = {}
def __new__(cls, *args, **kwargs):
if cls not in cls._instance:
time.sleep(1)
cls._instance[cls] = super(SingleTon, cls).__new__(cls, *args, **kwargs)
return cls._instance[cls]
class TechTest(SingleTon):
testfield = 12
def createTechTest():
print(TechTest())
if __name__ == '__main__':
threads = list()
for i in range(5):
t = Thread(target=createTechTest)
threads.append(t)
for thread in threads:
thread.start()

打印出了:

<__main__.TechTest object at 0x000001F5D7E8EEF0> <__main__.TechTest object at 0x000001F5D60830B8> <__main__.TechTest object at 0x000001F5D60830F0> <__main__.TechTest object at 0x000001F5D6066048> <__main__.TechTest object at 0x000001F5D6083240>

從運行結果看,我們的單例模式類不止創建出了一個對象,這已經不是單例了。 這是為什麼呢? 在我們的單例類 __new__ 方法中,先檢查了字典中是否存在對象,如果不存在則創建,當多個線程同時執行到判斷,而均沒有執行到創建的語句,則結果是多個線程均判斷需要創建單例的對象,於是多個對象就被這樣創建出來了,這就構成了競爭條件。

3. Python 線程鎖

解決上述問題最簡單的方法就是加鎖。

上圖中,線程A將讀取變量、寫入變量、寫入內存的一系列操作鎖定,而線程B則必須在線程A完成所有操作釋放鎖以前一直阻塞等待,直到獲取到鎖,讀取到完成一系列操作後的值。

3.1. threading.Lock

threading.Lock 使用的是 _thread 模塊實現的鎖機制,從本質上,他實際返回的是操作系統所提供的鎖。 鎖對象創建後不屬於任何特定線程,他只有兩個狀態 — 鎖定與未鎖定,同時他有兩個方法用來在這兩個狀態之間切換。

3.1.1. 獲取鎖 — acquire

acquire(blocking=True, timeout=-1)

這個方法嘗試獲取鎖,如果鎖的狀態是未鎖定狀態,則立即返回,否則,根據 blocking 參數決定是否阻塞等待。 一旦 blocking 參數為 True,且鎖是鎖定狀態,那麼該方法會一直阻塞,直到達到 timeout 秒數,timeout 為 -1 表示不限制超時。 如果獲取成功則返回 True,如果因為超時或非阻塞獲取鎖失敗等原因沒有獲取成功,則返回 False。

3.1.2. 釋放鎖 — release

release()

這個方法用來釋放鎖,無論當前線程是否持有鎖,他都可以調用這個方法來釋放鎖。 但如果一個鎖並沒有處於鎖定狀態,那麼該方法會拋出 RuntimeError 異常。

3.1.3. 實例

有了鎖機制,我們的單例模式類可以改造為下面的樣子:

from threading import Thread, Lock
class SingleTon:
_instance_lock = Lock()
_instance = {}
def __new__(cls, *args, **kwargs):
cls._instance_lock.acquire()
try:
if cls not in cls._instance:
time.sleep(1)
cls._instance[cls] = super(SingleTon, cls).__new__(cls, *args, **kwargs)
return cls._instance[cls]
finally:
cls._instance_lock.release()

這樣就再也不會出現前文所說的問題了。 但是,這樣的實現因為加鎖的粒度太大而存在性能的問題,這不在我們本文討論范圍內,會單獨抽出一篇文章來介紹單例模式的優化。

3.1.4. 上下文管理器

每次都必須執行 acquire 和 release 兩個方法看上去非常繁瑣,也十分容易出錯,因為一旦由於疏忽,線程沒有 release 就退出,那麼其他線程將永遠無法獲取到鎖而引發嚴重的問題。 好在 python 有一個非常易用的特性 — 上下文管理協議,threading.Lock 是支持上下文管理協議的,上面的代碼可以改造為:

from threading import Thread, Lock
class SingleTon:
_instance_lock = Lock()
_instance = {}
def __new__(cls, *args, **kwargs):
with cls._instance_lock:
if cls not in cls._instance:
time.sleep(1)
cls._instance[cls] = super(SingleTon, cls).__new__(cls, *args, **kwargs)
return cls._instance[cls]

3.2. 可重入鎖 — threading.RLock

3.2.1. 為什麼需要可重入鎖

對於 threading.Lock,同一個線程兩次獲取鎖就會發生死鎖,因為前一個鎖被自己占用,而自己又去等待鎖的釋放,陷入了死循環中。 這種死鎖的情況看上去很容易避免,但事實上,在面向對象的程序中,這卻很容易發生。

from threading import Lock
class TechlogTest:
def __init__(self):
self.lock = Lock()
def lockAndPrint(self):
with self.lock:
print('[%s] locked' % 'TechlogTest')
class TechlogTestSon(TechlogTest):
def lockAndPrint(self):
with self.lock:
print('[%s] locked' % 'TechlogTestSon')
super(TechlogTestSon, self).lockAndPrint()
if __name__ == '__main__':
son = TechlogTestSon()
son.lockAndPrint()

上面的例子中,子類嘗試調用父類的同名方法,打印出 “[TechlogTestSon] locked” 後就一直阻塞等待,而實際上,父類與子類一樣對方法進行了鎖定,而根據多態性,父類與子類獲取到的鎖對象實際上都是子類創建的對象,於是死鎖發生了。 為了避免這樣的情況,就需要使用可重入鎖。

3.2.2. threading.RLock

與 threading.Lock 一樣,RLock 也提供兩個方法分別用於加鎖與解鎖,而其加鎖方法也同樣是一個工廠方法,返回操作系統中可重入鎖的實例。 此前我們研究過 Java 中可重入鎖 ReentrantLock 的源碼。 ReentrantLock 用法詳解

ReentrantLock 源碼分析 -- ReentrantLock 的加鎖與解鎖

實際上,操作系統中可重入鎖的實現與上文中 Java 可重入鎖的實現非常類似,通常在鎖對象中維護當前加鎖線程標識與一個數字用來表示加鎖次數,同一線程每次調用加鎖方法則讓加鎖次數 + 1,解鎖則 - 1,只有變為 0 才釋放鎖。

3.2.3. 加鎖與解鎖

acquire(blocking=True, timeout=-1) release()

可以看到,這兩個方法的參數與 threading.Lock 中的同名方法是完全一致的,用法也完全相同,這裡就不再贅述了。

3.2.4. 上下文管理器

threading.RLock 也完全實現了上下文管理協議,上面那個死鎖的例子,我們稍加改造就可以解決死鎖問題了。

from threading import RLock
class TechlogTest:
def __init__(self):
self.lock = RLock()
def lockAndPrint(self):
with self.lock:
print('[%s] locked' % 'TechlogTest')
class TechlogTestSon(TechlogTest):
def lockAndPrint(self):
with self.lock:
print('[%s] locked' % 'TechlogTestSon')
super(TechlogTestSon, self).lockAndPrint()
if __name__ == '__main__':
son = TechlogTestSon()
son.lockAndPrint()

打印出了:

[TechlogTestSon] locked [TechlogTest] locked

4. 後記

在多線程環境中,性能提升的同時會出現許多棘手的新問題,上述問題只是冰山一角,加鎖也只能解決其中一些最基本的場景,還有更多復雜的場景需要更為合適的工具來處理。 敬請期待下一篇日志,我們來詳細介紹 python 線程同步的其他工具。

5. 參考資料

https://docs.python.org/zh-cn/3.6/library/threading.html。


  1. 上一篇文章:
  2. 下一篇文章:
Copyright © 程式師世界 All Rights Reserved