1. 程式人生 > >多執行緒死鎖的產生以及如何避免死鎖

多執行緒死鎖的產生以及如何避免死鎖

一、死鎖的定義

多執行緒以及多程序改善了系統資源的利用率並提高了系統 的處理能力。然而,併發執行也帶來了新的問題——死鎖。所謂死鎖是指多個執行緒因競爭資源而造成的一種僵局(互相等待),若無外力作用,這些程序都將無法向前推進。

下面我們通過一些例項來說明死鎖現象。

先看生活中的一個例項,2個人一起吃飯但是隻有一雙筷子,2人輪流吃(同時擁有2只筷子才能吃)。某一個時候,一個拿了左筷子,一人拿了右筷子,2個人都同時佔用一個資源,等待另一個資源,這個時候甲在等待乙吃完並釋放它佔有的筷子,同理,乙也在等待甲吃完並釋放它佔有的筷子,這樣就陷入了一個死迴圈,誰也無法繼續吃飯。。。
在計算機系統中也存在類似的情況。例如,某計算機系統中只有一臺印表機和一臺輸入 裝置,程序P1正佔用輸入裝置,同時又提出使用印表機的請求,但此時印表機正被程序P2 所佔用,而P2在未釋放印表機之前,又提出請求使用正被P1佔用著的輸入裝置。這樣兩個程序相互無休止地等待下去,均無法繼續執行,此時兩個程序陷入死鎖狀態。

二、死鎖產生的原因

1) 系統資源的競爭

通常系統中擁有的不可剝奪資源,其數量不足以滿足多個程序執行的需要,使得程序在 執行過程中,會因爭奪資源而陷入僵局,如磁帶機、印表機等。只有對不可剝奪資源的競爭 才可能產生死鎖,對可剝奪資源的競爭是不會引起死鎖的。

2) 程序推進順序非法

程序在執行過程中,請求和釋放資源的順序不當,也同樣會導致死鎖。例如,併發程序 P1、P2分別保持了資源R1、R2,而程序P1申請資源R2,程序P2申請資源R1時,兩者都 會因為所需資源被佔用而阻塞。

訊號量使用不當也會造成死鎖。程序間彼此相互等待對方發來的訊息,結果也會使得這 些程序間無法繼續向前推進。例如,程序A等待程序B發的訊息,程序B又在等待程序A 發的訊息,可以看出程序A和B不是因為競爭同一資源,而是在等待對方的資源導致死鎖。

3) 死鎖產生的必要條件

產生死鎖必須同時滿足以下四個條件,只要其中任一條件不成立,死鎖就不會發生。
  • 互斥條件:程序要求對所分配的資源(如印表機)進行排他性控制,即在一段時間內某 資源僅為一個程序所佔有。此時若有其他程序請求該資源,則請求程序只能等待。
  • 不剝奪條件:程序所獲得的資源在未使用完畢之前,不能被其他程序強行奪走,即只能 由獲得該資源的程序自己來釋放(只能是主動釋放)。
  • 請求和保持條件:程序已經保持了至少一個資源,但又提出了新的資源請求,而該資源 已被其他程序佔有,此時請求程序被阻塞,但對自己已獲得的資源保持不放。
  • 迴圈等待條件:存在一種程序資源的迴圈等待鏈,鏈中每一個程序已獲得的資源同時被 鏈中下一個程序所請求。即存在一個處於等待狀態的程序集合{Pl, P2, ..., pn},其中Pi等 待的資源被P(i+1)佔有(i=0, 1, ..., n-1),Pn等待的資源被P0佔有,如圖2-15所示。

直觀上看,迴圈等待條件似乎和死鎖的定義一樣,其實不然。按死鎖定義構成等待環所 要求的條件更嚴,它要求Pi等待的資源必須由P(i+1)來滿足,而迴圈等待條件則無此限制。 例如,系統中有兩臺輸出裝置,P0佔有一臺,PK佔有另一臺,且K不屬於集合{0, 1, ..., n}。

Pn等待一臺輸出裝置,它可以從P0獲得,也可能從PK獲得。因此,雖然Pn、P0和其他 一些程序形成了迴圈等待圈,但PK不在圈內,若PK釋放了輸出裝置,則可打破迴圈等待, 如圖2-16所示。因此迴圈等待只是死鎖的必要條件。

資源分配圖含圈而系統又不一定有死鎖的原因是同類資源數大於1。但若系統中每類資 源都只有一個資源,則資源分配圖含圈就變成了系統出現死鎖的充分必要條件。

產生死鎖的一個例子

/** 
* 一個簡單的死鎖類 
* 當DeadLock類的物件flag==1時(td1),先鎖定o1,睡眠500毫秒 
* 而td1在睡眠的時候另一個flag==0的物件(td2)執行緒啟動,先鎖定o2,睡眠500毫秒 
* td1睡眠結束後需要鎖定o2才能繼續執行,而此時o2已被td2鎖定; 
* td2睡眠結束後需要鎖定o1才能繼續執行,而此時o1已被td1鎖定; 
* td1、td2相互等待,都需要得到對方鎖定的資源才能繼續執行,從而死鎖。 
*/  
public class DeadLock implements Runnable {  
    public int flag = 1;  
    //靜態物件是類的所有物件共享的  
    private static Object o1 = new Object(), o2 = new Object();  
    @Override  
    public void run() {  
        System.out.println("flag=" + flag);  
        if (flag == 1) {  
            synchronized (o1) {  
                try {  
                    Thread.sleep(500);  
                } catch (Exception e) {  
                    e.printStackTrace();  
                }  
                synchronized (o2) {  
                    System.out.println("1");  
                }  
            }  
        }  
        if (flag == 0) {  
            synchronized (o2) {  
                try {  
                    Thread.sleep(500);  
                } catch (Exception e) {  
                    e.printStackTrace();  
                }  
                synchronized (o1) {  
                    System.out.println("0");  
                }  
            }  
        }  
    }  
  
    public static void main(String[] args) {  
          
        DeadLock td1 = new DeadLock();  
        DeadLock td2 = new DeadLock();  
        td1.flag = 1;  
        td2.flag = 0;  
        //td1,td2都處於可執行狀態,但JVM執行緒排程先執行哪個執行緒是不確定的。  
        //td2的run()可能在td1的run()之前執行  
        new Thread(td1).start();  
        new Thread(td2).start();  
  
    }  
}  


三、如何避免死鎖

在有些情況下死鎖是可以避免的。三種用於避免死鎖的技術:

  1. 加鎖順序(執行緒按照一定的順序加鎖)
  2. 加鎖時限(執行緒嘗試獲取鎖的時候加上一定的時限,超過時限則放棄對該鎖的請求,並釋放自己佔有的鎖)
  3. 死鎖檢測

加鎖順序

當多個執行緒需要相同的一些鎖,但是按照不同的順序加鎖,死鎖就很容易發生。

如果能確保所有的執行緒都是按照相同的順序獲得鎖,那麼死鎖就不會發生。看下面這個例子:

Thread 1:
  lock A 
  lock B

Thread 2:
   wait for A
   lock C (when A locked)

Thread 3:
   wait for A
   wait for B
   wait for C

如果一個執行緒(比如執行緒3)需要一些鎖,那麼它必須按照確定的順序獲取鎖。它只有獲得了從順序上排在前面的鎖之後,才能獲取後面的鎖。

例如,執行緒2和執行緒3只有在獲取了鎖A之後才能嘗試獲取鎖C(譯者注:獲取鎖A是獲取鎖C的必要條件)。因為執行緒1已經擁有了鎖A,所以執行緒2和3需要一直等到鎖A被釋放。然後在它們嘗試對B或C加鎖之前,必須成功地對A加了鎖。

按照順序加鎖是一種有效的死鎖預防機制。但是,這種方式需要你事先知道所有可能會用到的鎖(譯者注:並對這些鎖做適當的排序),但總有些時候是無法預知的。

加鎖時限

另外一個可以避免死鎖的方法是在嘗試獲取鎖的時候加一個超時時間,這也就意味著在嘗試獲取鎖的過程中若超過了這個時限該執行緒則放棄對該鎖請求。若一個執行緒沒有在給定的時限內成功獲得所有需要的鎖,則會進行回退並釋放所有已經獲得的鎖,然後等待一段隨機的時間再重試。這段隨機的等待時間讓其它執行緒有機會嘗試獲取相同的這些鎖,並且讓該應用在沒有獲得鎖的時候可以繼續執行(譯者注:加鎖超時後可以先繼續執行乾點其它事情,再回頭來重複之前加鎖的邏輯)。

以下是一個例子,展示了兩個執行緒以不同的順序嘗試獲取相同的兩個鎖,在發生超時後回退並重試的場景:

Thread 1 locks A
Thread 2 locks B

Thread 1 attempts to lock B but is blocked
Thread 2 attempts to lock A but is blocked

Thread 1's lock attempt on B times out
Thread 1 backs up and releases A as well
Thread 1 waits randomly (e.g. 257 millis) before retrying.

Thread 2's lock attempt on A times out
Thread 2 backs up and releases B as well
Thread 2 waits randomly (e.g. 43 millis) before retrying.

在上面的例子中,執行緒2比執行緒1早200毫秒進行重試加鎖,因此它可以先成功地獲取到兩個鎖。這時,執行緒1嘗試獲取鎖A並且處於等待狀態。當執行緒2結束時,執行緒1也可以順利的獲得這兩個鎖(除非執行緒2或者其它執行緒線上程1成功獲得兩個鎖之前又獲得其中的一些鎖)。

需要注意的是,由於存在鎖的超時,所以我們不能認為這種場景就一定是出現了死鎖。也可能是因為獲得了鎖的執行緒(導致其它執行緒超時)需要很長的時間去完成它的任務。

此外,如果有非常多的執行緒同一時間去競爭同一批資源,就算有超時和回退機制,還是可能會導致這些執行緒重複地嘗試但卻始終得不到鎖。如果只有兩個執行緒,並且重試的超時時間設定為0到500毫秒之間,這種現象可能不會發生,但是如果是10個或20個執行緒情況就不同了。因為這些執行緒等待相等的重試時間的概率就高的多(或者非常接近以至於會出現問題)。
(譯者注:超時和重試機制是為了避免在同一時間出現的競爭,但是當執行緒很多時,其中兩個或多個執行緒的超時時間一樣或者接近的可能性就會很大,因此就算出現競爭而導致超時後,由於超時時間一樣,它們又會同時開始重試,導致新一輪的競爭,帶來了新的問題。)

這種機制存在一個問題,在Java中不能對synchronized同步塊設定超時時間。你需要建立一個自定義鎖,或使用Java5中java.util.concurrent包下的工具。寫一個自定義鎖類不復雜,但超出了本文的內容。後續的Java併發系列會涵蓋自定義鎖的內容。

死鎖檢測

死鎖檢測是一個更好的死鎖預防機制,它主要是針對那些不可能實現按序加鎖並且鎖超時也不可行的場景。

每當一個執行緒獲得了鎖,會線上程和鎖相關的資料結構中(map、graph等等)將其記下。除此之外,每當有執行緒請求鎖,也需要記錄在這個資料結構中。

當一個執行緒請求鎖失敗時,這個執行緒可以遍歷鎖的關係圖看看是否有死鎖發生。例如,執行緒A請求鎖7,但是鎖7這個時候被執行緒B持有,這時執行緒A就可以檢查一下執行緒B是否已經請求了執行緒A當前所持有的鎖。如果執行緒B確實有這樣的請求,那麼就是發生了死鎖(執行緒A擁有鎖1,請求鎖7;執行緒B擁有鎖7,請求鎖1)。

當然,死鎖一般要比兩個執行緒互相持有對方的鎖這種情況要複雜的多。執行緒A等待執行緒B,執行緒B等待執行緒C,執行緒C等待執行緒D,執行緒D又在等待執行緒A。執行緒A為了檢測死鎖,它需要遞進地檢測所有被B請求的鎖。從執行緒B所請求的鎖開始,執行緒A找到了執行緒C,然後又找到了執行緒D,發現執行緒D請求的鎖被執行緒A自己持有著。這是它就知道發生了死鎖。

下面是一幅關於四個執行緒(A,B,C和D)之間鎖佔有和請求的關係圖。像這樣的資料結構就可以被用來檢測死鎖。

那麼當檢測出死鎖時,這些執行緒該做些什麼呢?

一個可行的做法是釋放所有鎖,回退,並且等待一段隨機的時間後重試。這個和簡單的加鎖超時類似,不一樣的是隻有死鎖已經發生了才回退,而不會是因為加鎖的請求超時了。雖然有回退和等待,但是如果有大量的執行緒競爭同一批鎖,它們還是會重複地死鎖(編者注:原因同超時類似,不能從根本上減輕競爭)。

一個更好的方案是給這些執行緒設定優先順序,讓一個(或幾個)執行緒回退,剩下的執行緒就像沒發生死鎖一樣繼續保持著它們需要的鎖。如果賦予這些執行緒的優先順序是固定不變的,同一批執行緒總是會擁有更高的優先順序。為避免這個問題,可以在死鎖發生的時候設定隨機的優先順序。