在多執行緒程式設計中,確保執行緒安全地分享資源至關重要,否則可能導致死鎖、活鎖或資料不一致等問題。本文將探討如何使用鎖、條件變數和讀寫鎖等同步機制來避免這些問題,並以 Python 程式碼示範實際應用。首先,我們以夫妻共用一隻叉子吃飯的例子來說明死鎖的概念:如果夫妻兩人同時拿起叉子,卻又等待對方放下叉子才能吃飯,就會形成死鎖。為避免這種情況,可以使用條件變數,讓其中一人先等待,直到另一人吃完放下叉子後再開始吃飯。接著,我們將探討讀者-寫者問題,即多個讀取執行緒和寫入執行緒如何安全地分享資源。我們將提供讀者優先和寫者優先兩種解決方案,並分析其優缺點。最後,我們將介紹讀寫鎖的概念,它允許多個讀取執行緒同時存取資源,但寫入執行緒需要獨佔資源,以確保資料一致性。
死鎖(Deadlocks)與多執行緒同步
在多執行緒程式設計中,死鎖是一種常見的問題,發生在多個執行緒之間因為競爭資源而導致的僵局。下面我們將透過一個簡單的例子來瞭解死鎖的概念和如何避免它。
Spouse 類別與死鎖
import threading
import time
class Spouse(threading.Thread):
def __init__(self, name, partner):
threading.Thread.__init__(self)
self.name = name
self.partner = partner
self.hungry = True
def run(self):
while self.hungry:
print(f'{self.name} is hungry and wants to eat.')
if self.partner.hungry:
print(f'{self.name} is waiting for their partner to eat first...')
else:
with fork: # 使用 lock 物件
print(f'{self.name} has started eating.')
time.sleep(5)
print(f'{self.name} is now full.')
self.hungry = False
在這個例子中,Spouse
類別繼承自 threading.Thread
,並實作了兩個伴侶之間的同步邏輯。每個 Spouse
物件都有一個 hungry
屬性,初始值為 True
,表示伴侶是否餓了。run
方法指定了當執行緒啟動時的邏輯:只要伴侶的 hungry
屬性為 True
,就會嘗試使用 fork
(一個 lock 物件)來吃東西。但是,它總是會檢查伴侶的 hungry
屬性是否為 True
,如果是,則不會繼續嘗試取得 lock,反而會等待伴侶先吃。
主程式與死鎖
fork = threading.Lock()
spouse1 = Spouse('Alice', None)
spouse2 = Spouse('Bob', None)
spouse1.partner = spouse2
spouse2.partner = spouse1
spouse1.start()
spouse2.start()
spouse1.join()
spouse2.join()
在主程式中,我們首先建立一個 fork
lock 物件,然後建立兩個 Spouse
物件,spouse1
和 spouse2
。我們設定每個伴侶的 partner
屬性為另一個伴侶,然後啟動兩個執行緒。最後,我們等待兩個執行緒完成。
死鎖的發生
在這個例子中,當 spouse1
和 spouse2
都餓了(hungry
屬性為 True
),並且都嘗試使用 fork
lock 物件吃東西時,就會發生死鎖。因為 spouse1
等待 spouse2
吃完,而 spouse2
也等待 spouse1
吃完,導致兩個執行緒都無法繼續執行。
解決死鎖
要解決死鎖,可以使用以下方法:
- 避免巢狀 lock:盡量避免在一個 lock 中再次取得另一個 lock。
- 使用 lock 時間:設定 lock 的時間限制,避免無限等待。
- 使用 condition variable:使用 condition variable 來同步執行緒之間的狀態變化。
在這個例子中,可以透過修改 Spouse
類別的 run
方法,避免巢狀 lock,來解決死鎖問題。例如,可以使用一個 condition variable 來同步伴侶之間的狀態變化。
import threading
import time
class Spouse(threading.Thread):
def __init__(self, name, partner):
threading.Thread.__init__(self)
self.name = name
self.partner = partner
self.hungry = True
self.cond = threading.Condition()
def run(self):
while self.hungry:
print(f'{self.name} is hungry and wants to eat.')
with self.cond:
if self.partner.hungry:
print(f'{self.name} is waiting for their partner to eat first...')
self.cond.wait()
else:
print(f'{self.name} has started eating.')
time.sleep(5)
print(f'{self.name} is now full.')
self.hungry = False
self.cond.notify_all()
在這個修改過的版本中,使用了一個 condition variable cond
來同步伴侶之間的狀態變化。當 spouse1
餓了,並且 spouse2
也餓了,spouse1
會等待 spouse2
吃完,並且使用 cond.wait()
方法等待 spouse2
通知。當 spouse2
吃完了,會使用 cond.notify_all()
方法通知 spouse1
可以繼續執行。這樣就避免了死鎖的發生。
平行程式設計中的死結與活結
在平行程式設計中,死結(deadlock)和活結(livelock)是兩種常見的問題。死結發生在兩個或多個執行緒無法繼續執行,因為它們正在等待彼此釋放資源。活結則是指兩個或多個執行緒不斷地嘗試獲得資源,但始終無法成功。
死結的例子
下面是一個簡單的例子,展示了死結的情況:
import threading
class Spouse(threading.Thread):
def __init__(self, name, partner):
super().__init__()
self.name = name
self.partner = partner
def run(self):
while True:
print(f"{self.name} is hungry and wants to eat.")
self.partner.partner.wait()
print(f"{self.name} is waiting for their partner to eat first...")
self.partner.partner.notify()
fork = threading.Lock()
partner1 = Spouse('Wife', None)
partner2 = Spouse('Husband', partner1)
partner1.partner = partner2
partner1.start()
partner2.start()
partner1.join()
partner2.join()
print('Finished.')
在這個例子中,兩個執行緒 (partner1
和 partner2
) 相互等待對方吃飯,導致死結。
活結的例子
下面是一個簡單的例子,展示了活結的情況:
import threading
import time
class Spouse(threading.Thread):
def __init__(self, name, partner):
super().__init__()
self.name = name
self.partner = partner
def run(self):
while True:
print(f"{self.name} is hungry and wants to eat.")
time.sleep(1)
print(f"{self.name} is waiting for their partner to eat first...")
time.sleep(1)
fork = threading.Lock()
partner1 = Spouse('Wife', None)
partner2 = Spouse('Husband', partner1)
partner1.partner = partner2
partner1.start()
partner2.start()
partner1.join()
partner2.join()
print('Finished.')
在這個例子中,兩個執行緒 (partner1
和 partner2
) 不斷地嘗試吃飯,但始終無法成功,導致活結。
內容解密:
在上面的例子中,我們使用了 threading
模組來建立兩個執行緒 (partner1
和 partner2
)。每個執行緒都有一個 run
方法,該方法包含了執行緒的執行邏輯。在死結的例子中,兩個執行緒相互等待對方吃飯,導致死結。在活結的例子中,兩個執行緒不斷地嘗試吃飯,但始終無法成功,導致活結。
圖表翻譯:
graph LR A[partner1] -->|等待|> B[partner2] B -->|等待|> A C[partner1] -->|嘗試吃飯|> D[partner2] D -->|嘗試吃飯|> C
在這個圖表中,兩個執行緒 (partner1
和 partner2
) 相互等待對方吃飯,導致死結。在活結的例子中,兩個執行緒不斷地嘗試吃飯,但始終無法成功,導致活結。
什麼是饑餓(Starvation)?
饑餓是指在並發系統中,某個程式或執行緒無法獲得所需的資源,從而無法執行其指令,導致無法取得進展。
饑餓的成因
饑餓可能由多種原因引起,包括:
- 高優先順序的程式或執行緒佔據CPU和資源,導致低優先順序的程式或執行緒無法執行。
- 高優先順序的程式或執行緒佔據非分享資源,導致低優先順序的程式或執行緒無法執行。
- 低優先順序的程式或執行緒等待資源,但當資源可用時,高優先順序的程式或執行緒立即佔據它們。
饑餓與死鎖(Deadlock)的關係
死鎖情況也可能導致饑餓,因為當程式或執行緒無法獲得所需的資源時,它們就無法執行其指令。
讀者-寫者問題(Readers-Writers Problem)
讀者-寫者問題是一個並發程式設計中的經典問題,描述了在多個程式或執行緒競爭資源的情況下可能出現的問題。這個問題有多種變體,包括:
- 單個讀者和單個寫者
- 多個讀者和單個寫者
- 單個讀者和多個寫者
- 多個讀者和多個寫者
這些變體都可能導致饑餓和其他並發問題。
解決饑餓的方法
解決饑餓的方法包括:
- 使用優先順序排程演算法,確保低優先順序的程式或執行緒也能獲得執行機會。
- 使用資源分配演算法,確保資源被合理分配,避免高優先順序的程式或執行緒佔據資源。
- 使用同步機制,例如鎖定和訊號量,確保程式或執行緒之間的同步和通訊。
讀者-寫者問題:一個典型的同步化挑戰
在並發系統中,讀者-寫者問題是一個常見的同步化挑戰。這個問題涉及多個讀者和寫者執行緒共同存取一個分享資源,例如一個文字檔案。讀者執行緒只需要讀取檔案的內容,而寫者執行緒則需要修改檔案的內容。
問題描述
讀者-寫者問題的目標是設計一個正確且高效的同步化機制,允許讀者和寫者執行緒安全地存取分享資源。這個機制需要確保:
- 寫者執行緒不能與讀者執行緒同時存取分享資源。
- 多個讀者執行緒可以同時存取分享資源。
- 沒有執行緒會因為其他執行緒而被阻塞或餓死。
基本解決方案
一個基本的解決方案是使用鎖機制(lock)來保護分享資源。當一個寫者執行緒需要存取分享資源時,它會先鎖定資源,然後進行修改。當修改完成後,它會解鎖資源。讀者執行緒也會先鎖定資源,然後讀取內容,最後解鎖資源。
然而,這個解決方案存在一個問題:如果多個讀者執行緒同時存取分享資源,它們會不斷地鎖定和解鎖資源,從而導致效率低下。
改進解決方案
一個改進的解決方案是使用一個讀者計數器(reader counter)來追蹤目前有多少個讀者執行緒正在存取分享資源。當第一個讀者執行緒存取分享資源時,它會鎖定資源,並將計數器設為1。之後的讀者執行緒可以直接存取分享資源,而不需要鎖定資源。當最後一個讀者執行緒完成存取後,它會解鎖資源,並將計數器設為0。
這個解決方案可以確保多個讀者執行緒可以同時存取分享資源,而寫者執行緒則需要等待所有讀者執行緒完成存取後才能存取分享資源。
Python 實作
以下是使用 Python 實作的讀者-寫者問題解決方案:
import threading
# 分享資源
text = ""
# 鎖機制
resource = threading.Lock()
# 讀者計數器
rcount = 0
# 讀者計數器鎖
rcounter = threading.Lock()
def writer():
global text
while True:
with resource:
print(f"Writing being done by {threading.current_thread().name}.")
text += f"Writing was done by {threading.current_thread().name}. "
def reader():
global rcount
while True:
with rcounter:
rcount += 1
if rcount == 1:
resource.acquire()
print(f"Reading being done by {threading.current_thread().name}:")
print(text)
with rcounter:
rcount -= 1
if rcount == 0:
resource.release()
# 建立讀者和寫者執行緒
reader_threads = []
writer_threads = []
for i in range(5):
reader_thread = threading.Thread(target=reader)
reader_threads.append(reader_thread)
reader_thread.start()
for i in range(3):
writer_thread = threading.Thread(target=writer)
writer_threads.append(writer_thread)
writer_thread.start()
這個實作使用鎖機制和讀者計數器來解決讀者-寫者問題。讀者執行緒可以同時存取分享資源,而寫者執行緒則需要等待所有讀者執行緒完成存取後才能存取分享資源。
內容解密:
這個實作使用了鎖機制和讀者計數器來解決讀者-寫者問題。鎖機制用於保護分享資源,而讀者計數器用於追蹤目前有多少個讀者執行緒正在存取分享資源。當第一個讀者執行緒存取分享資源時,它會鎖定資源,並將計數器設為1。之後的讀者執行緒可以直接存取分享資源,而不需要鎖定資源。當最後一個讀者執行緒完成存取後,它會解鎖資源,並將計數器設為0。
圖表翻譯:
flowchart TD A[讀者執行緒] --> B[鎖定資源] B --> C[讀取內容] C --> D[解鎖資源] D --> E[讀者計數器] E --> F[寫者執行緒] F --> G[鎖定資源] G --> H[修改內容] H --> I[解鎖資源]
這個圖表描述了讀者-寫者問題的解決方案。讀者執行緒先鎖定資源,然後讀取內容,最後解鎖資源。寫者執行緒需要等待所有讀者執行緒完成存取後才能存取分享資源。
讀者-寫者問題的解決方案
在解決讀者-寫者問題時,我們需要確保讀者和寫者之間的存取分享資源的順序是公平的。以下是兩種解決方案:
第一種解決方案:讀者優先
在這種解決方案中,讀者優先存取分享資源。如果有一個寫者正在等待存取分享資源,而有一個讀者想要存取分享資源,則讀者將被優先處理。這種解決方案可能會導致寫者饑餓(starvation),因為寫者可能永遠無法存取分享資源。
import threading
# 分享資源
text = 'This is some text.'
# 讀者數量
rcount = 0
# 讀者鎖
rcounter = threading.Lock()
# 分享資源鎖
resource = threading.Lock()
# 讀者函式
def reader():
global rcount
while True:
with rcounter:
rcount += 1
if rcount == 1:
resource.acquire()
print('Reading being done by 玄貓:')
print(text)
with rcounter:
rcount -= 1
if rcount == 0:
resource.release()
# 寫者函式
def writer():
global text
while True:
resource.acquire()
text = 'This is new text.'
print('Writing being done by 玄貓:')
print(text)
resource.release()
# 建立讀者和寫者執行緒
threads = [threading.Thread(target=reader) for _ in range(3)] + [threading.Thread(target=writer) for _ in range(2)]
# 啟動執行緒
for thread in threads:
thread.start()
第二種解決方案:寫者優先
在這種解決方案中,寫者優先存取分享資源。如果有一個寫者正在等待存取分享資源,而有一個讀者想要存取分享資源,則寫者將被優先處理。這種解決方案可以避免寫者饑餓,但可能會導致讀者饑餓。
import threading
# 分享資源
text = 'This is some text.'
# 讀者數量
rcount = 0
# 讀者鎖
rcounter = threading.Lock()
# 分享資源鎖
resource = threading.Lock()
# 寫者數量
wcount = 0
# 寫者鎖
wcounter = threading.Lock()
# 讀者嘗試鎖
read_try = threading.Lock()
# 讀者函式
def reader():
global rcount
while True:
with read_try:
if wcount > 0:
read_try.release()
read_try.acquire()
with rcounter:
rcount += 1
if rcount == 1:
resource.acquire()
print('Reading being done by 玄貓:')
print(text)
with rcounter:
rcount -= 1
if rcount == 0:
resource.release()
# 寫者函式
def writer():
global text
global wcount
while True:
with wcounter:
wcount += 1
if wcount == 1:
read_try.acquire()
resource.acquire()
text = 'This is new text.'
print('Writing being done by 玄貓:')
print(text)
resource.release()
with wcounter:
wcount -= 1
if wcount == 0:
read_try.release()
# 建立讀者和寫者執行緒
threads = [threading.Thread(target=reader) for _ in range(3)] + [threading.Thread(target=writer) for _ in range(2)]
# 啟動執行緒
for thread in threads:
thread.start()
多執行緒同步機制:讀寫鎖
在多執行緒環境中,當多個執行緒分享同一資源時,需要使用同步機制來避免資料不一致或其他競爭條件。其中,讀寫鎖是一種常見的同步機制,允許多個讀取執行緒同時存取資源,但寫入執行緒需要獨佔資源。
實作讀寫鎖
以下是使用 Python 的 threading
模組實作讀寫鎖的範例:
import threading
# 分享資源
text = ""
wcount = 0
# 讀寫鎖
wcounter = threading.Lock()
read_try = threading.Lock()
def writer():
global text
global wcount
while True:
with wcounter:
wcount += 1
if wcount == 1:
read_try.acquire()
with wcounter:
print(f"Writing being done by {threading.current_thread().name}.")
text += f"Writing was done by {threading.current_thread().name}. "
with wcounter:
wcount -= 1
if wcount == 0:
read_try.release()
def reader():
# ...
在這個範例中,writer()
函式代表寫入執行緒,reader()
函式代表讀取執行緒。wcounter
鎖用於保護寫入計數器 wcount
,而 read_try
鎖用於保護讀取執行緒的存取。
鎖的使用
當寫入執行緒需要存取分享資源時,它會先鎖定 wcounter
鎖,然後增加寫入計數器 wcount
。如果 wcount
等於 1,表示這是第一個寫入執行緒,則會鎖定 read_try
鎖,以防止讀取執行緒存取分享資源。
當寫入執行緒完成存取分享資源後,會鎖定 wcounter
鎖,然後減少寫入計數器 wcount
。如果 wcount
等於 0,表示所有寫入執行緒都完成了存取,則會釋放 read_try
鎖,允許讀取執行緒存取分享資源。
內容解密:
這個範例示範瞭如何使用讀寫鎖來同步多執行緒的存取分享資源。透過使用 wcounter
鎖和 read_try
鎖,寫入執行緒可以獨佔分享資源,而讀取執行緒可以同時存取分享資源。
圖表翻譯:
flowchart TD A[寫入執行緒] --> B[鎖定 wcounter 鎖] B --> C[增加寫入計數器 wcount] C --> D[鎖定 read_try 鎖] D --> E[存取分享資源] E --> F[鎖定 wcounter 鎖] F --> G[減少寫入計數器 wcount] G --> H[釋放 read_try 鎖]
這個圖表示範了寫入執行緒的流程,從鎖定 wcounter
鎖開始,然後增加寫入計數器 wcount
,鎖定 read_try
鎖,存取分享資源,鎖定 wcounter
鎖,減少寫入計數器 wcount
,最後釋放 read_try
鎖。
從系統資源競爭的視角來看,本文深入探討了多執行緒程式設計中死鎖、活結和饑餓等常見問題,並以讀者-寫者問題為例,闡述了同步機制的重要性。分析顯示,死鎖的產生源於多個執行緒互相等待對方釋放資源,形成迴圈等待的僵局;活結則體現了執行緒間持續的資源競爭但都以失敗告終,浪費系統資源卻無法取得進展;而饑餓則突顯了資源分配不均導致某些執行緒長期無法取得所需資源。技術限制在於如何設計高效且公平的同步機制,在避免死鎖和活結的同時,還要兼顧系統整體效能和資源利用率。實務上,開發者應根據具體應用場景選擇合適的同步機制,例如讀寫鎖、訊號量或條件變數等,並仔細分析程式碼邏輯,避免潛在的死鎖和活結風險。玄貓認為,隨著多核心處理器和平行計算的普及,對高效同步機制的需求將日益增長,未來發展方向將聚焦於更精細的資源分配策略和更低開銷的同步原語,以充分釋放硬體效能。