併發:死鎖

リュウセイリョウ發表於2020-11-05

1、什麼是死鎖?

死鎖指一組互相競爭系統資源的程式/執行緒永久性地阻塞 (以下執行緒同程式)。當一組執行緒中的每個執行緒都在請求某些資源,而這組執行緒中被阻塞的其他執行緒已經佔用了這些資源,沒有其他任何執行緒會再釋放出這些資源,於是就發生了死鎖,死鎖是永久的。

2、發生死鎖的條件

死鎖有三個必要條件:

互斥。一次只有一個執行緒可以使用一個資源。

佔有且等待。當一個執行緒已佔用了某些資源,在請求等待其他資源的時候,不釋放已佔用的資源。

不可搶佔。其他執行緒不能強行搶佔執行緒已經佔用的資源。

這三個條件是發生死鎖的必要條件,而非充分條件。要產生死鎖還需要第四個條件:

迴圈等待。存在一個閉合的執行緒鏈,每個執行緒至少佔有執行緒鏈中下一個執行緒所需的資源。

這四個條件構成了死鎖的充分必要條件

3、處理死鎖的方法

3.1 死鎖預防

死鎖預防是試圖設計一種方式來破壞構成死鎖的一個條件。

1)互斥。這個條件是不能禁止的,如果需要對資源進行互斥訪問,那麼作業系統就必須支援互斥。

2)佔有且等待。要求執行緒一次性地請求所有需要的資源,並阻塞這個執行緒直到所有請求都能滿足。

使用賬戶轉賬的例子說明。示例程式碼1:

//資源分配器類,用於分配各賬戶進行轉賬操作時所需申請的鎖資源
class Allocator{
  //連結串列儲存資源物件
  private List<Object> als = new ArrayList<>();
  
  private Allocator(){
    
  }
  //單例模式,所有執行緒使用同一個資源分配器
  private static Allocator instance = new Allocator();
  
  private static Allocator getInstance(){
    return instance;
  }
  
  //申請資源,也就是本賬戶物件this和目標賬戶物件target這兩把鎖
  synchronized boolean apply(Object from, Object to){
    //當一個執行緒申請這兩個資源時,發現容器中已經存在其中一個物件,
    //說明其他物件已經佔用了至少一個物件,那本次申請就不成功,因為不能一次申請全部所需資源
    if(als.contains(from) || als.contains(to)){
      return false;
    }else{//如果容器中沒有這兩個物件,則說明可以一次性申請成功
      als.add(from);
      als.add(to);
    }
    return true;
  }
  
  //釋放資源
  synchronized void free(Object from, Object to){
    als.remove(from);
    als.remove(to);
  }
}

class Account{
  
  private Allocator actr = Allocator.getInstance();
  private Integer balance;
  
  //給目標賬戶target轉賬amt
  public void transfer(Account target, int amt){
    //一次性申請全部資源(轉出賬戶和轉入賬戶),申請不到則一直迴圈,申請時所有賬戶序列,因為Allocator單例
    while(!actr.apply(this,target));
    try{
      //用本賬戶this和目標賬戶target這兩把鎖來保護this.balance和target.balance
      synchronized(this){
        synchronized(target){
          if(balance >= amt){
            balance -= amt;
            target.balance += amt;
          }
        }
      }
    }finally{
      actr.free(this,target);
    }
  }
}

該方式的三個缺點

  • 一個執行緒為了要一次性獲取全部所需資源,可能會等待很久,本例中使用死迴圈while(!actr.apply(this,target))不斷地判斷條件是否滿足,如果一直不能獲取全部所需資源,將持續迴圈下去,成本很高;
  • 一個執行緒可能不會事先知道它執行過程中所需要的全部資源,雖然本例中可以知道;
  • 執行緒在初始時就申請了全部資源,然而已分配的部分資源,可能很長一段時間暫不會被該執行緒使用,但是依然被其佔有,不可以被其他執行緒使用。

3)不可搶佔。佔有某些資源的執行緒進一步申請資源時如果被拒絕,則應該釋放其已經佔有的資源,必要時可以再次申請這些資源和其他資源。其次,如果兩個執行緒具有不同的優先順序,那麼一個執行緒可以搶佔另一執行緒已經佔有的資源,被搶佔了執行緒釋放已佔有的資源。java中synchronized管程還不能解決這個問題,因為當它申請資源的時候,如果申請不到就直接阻塞了,不能釋放。而java併發包java.util.concurrent包下的Lock則可以解決這個問題。

缺點:在資源狀態比較容易儲存和恢復的情況下,該方法才是實用的。

4)迴圈等待。對資源進行線性排序,執行緒按序申請。比如,給每個資源按照1,2,3,…,n 標序號,申請時按照從小到大的順序申請。

使用賬戶轉賬的例子說明。示例程式碼2:

class Account{
  //每個賬戶都有一個id,這個id就是資源的序號
  private Integer id;
  private Integer balance;
  
  //轉賬
  public void transfer(Account target, int amt){
    //用兩個指標分別指向兩個資源,left指向序號較小的資源,right指向序號較大的資源,然後按序申請
    Account left = this;
    Account right = target;
    if(this.id > target.id){
      left = target;
      right = this;
    }
    
    //總是先申請序號較小的資源,再申請序號較大的資源
    synchronized(left){
      synchronized(right){
        if(balance >= amt){
            balance -= amt;
            target.balance += amt;
          }
      }
    }
  }
}

在處理賬戶轉賬問題上,破壞佔用且等待的條件使用了死迴圈while(!actr.apply(this,target)),而且apply方法對於所有賬戶來說是序列的,整個方法的成本較高。而本例中使用破壞迴圈等待的方式,成本較低。

缺點:破壞迴圈等待條件所需的成本,在一定程度上類似破壞佔有且等待條件。這兩者都對資源的獲取方式進行了限制,破壞佔有且等待條件限制了資源獲取的數量,破壞迴圈等待條件限制了資源獲取的順序。這些限制會使得執行緒可能在沒有必要的情況下無法獲取資源,執行速度變慢。

3.2 死鎖避免

從文字上看,死鎖避免很像死鎖預防。死鎖預防是通過破壞四個死鎖條件中的一個來完成,實際上比較低效。死鎖避免則相反,它允許三個必要條件發生,但通過判斷到來的資源請求是否可能導致死鎖,如果是,則拒絕,這樣的方式來確保永遠不會到達死鎖點。死鎖避免比死鎖預防允許更多的併發。

死鎖避免的兩種方法:

執行緒啟動拒絕。如果一個執行緒的請求會導致死鎖,則不啟動這個執行緒。

資源分配拒絕。如果一個執行緒增加的資源請求會導致死鎖,則拒絕分配它所請求的資源。

1)執行緒啟動拒絕

考慮一個系統中有n個執行緒和m種不同型別的資源。給出瞭如下向量和矩陣,分別是總資源向量、剩餘可用的總資源向量、執行緒i對資源j的需求矩陣、已分配資源j給執行緒i的矩陣。

在這裡插入圖片描述

當一個新執行緒的資源需求滿足如下公式時,也就是說新執行緒對每種資源的請求量加上所有當前執行緒對這種資源的最大請求量,小於這種資源的總量時,才會啟動這個執行緒。

在這裡插入圖片描述

但是這建立在所有執行緒都發出了它們的最大資源請求。

2)資源分配拒絕

該策略又稱為銀行家演算法 (banker algorithm)。銀行家演算法依然使用到了前面定義的四個向量或矩陣。

此外,還定義了兩種狀態:

  • 安全狀態,指至少有一個資源分配序列不會導致死鎖,即至少存在一種資源分配的順序可以使得所有執行緒都可以執行到結束。
  • 不安全狀態,指不存在任何一種資源分配順序讓所有執行緒都執行到結束。

在這裡插入圖片描述

圖(a)是資源分配的初始狀態,當前可用資源R1、R2、R3分別是0個、1個、1個。

現在假設P2要請求資源0、0、1,判斷分配這個請求是否可以讓我們找到一種不會發生死鎖的資源分配序列,即安全狀態。

P2的需求是0、1、1,可以滿足,將0、1、1分配給P2。P2執行結束後,釋放其所有資源,使得可用資源變成6、2、3。然後再判斷可以滿足哪個執行緒的剩餘需求,P1、P3、P4都能滿足,如果分配給P1,那麼P1執行完成釋放其資源後,可用資源變成7、2、3,然後再按照這種方式,分配給P3和P4,直到所有執行緒都執行結束。

因此,P2的請求是安全的,可以分配。

這個策略之所以稱為“銀行家演算法”,實際上與傳統銀行貸款方式類似。當有客戶需要向銀行貸款時,銀行家需要衡量這個客戶是否有還款能力,如果有,才會借給TA。類比到本策略,當一個執行緒需要請求資源時,系統衡量這個請求是否安全,如果安全,才會分配。

銀行家演算法虛擬碼:

//判斷當前狀態是否安全
boolean safe(state s){
  //儲存當前可用的各類資源的數量
  int currentAvail[m];
  //rest儲存所有還未執行結束的程式
  process rest[<number of procecesses>];
  currentAvail = available;
  rest = {all processes};
  boolean possible = true;
  while(possible){
    <在rest中找到一個程式k,可以滿足其需求>
      if(found){
        //如果找到程式k就假設讓它執行結束,更新可用資源
        currentAvail = currentAvail + alloc[k][*];
        //從rest集合中排除程式k
        rest = rest - {Pk};
      }
    	else{
        possible = false;
      }
  }
  //如果最後所有程式都可以執行結束,則是安全的
  return {rest == null};
}

3)死鎖避免的缺點

與死鎖預防相比,死鎖避免受到的限制較小。但其自身也存在限制:

  • 所有執行緒都必須事先宣告所需的最大資源。
  • 所涉及的執行緒之間必須相互獨立,它們的執行順序不能有任何的同步要求。
  • 分配的資源數必須是固定的。
  • 在佔有資源時,執行緒不能退出。

3.3 死鎖檢測

死鎖檢測指當執行緒請求資源時,只要能滿足,都會分配給它,作業系統週期性地執行一個演算法來檢測是否出現迴圈等待。

死鎖檢測的次數取決於發生死鎖的可能性。在每次請求資源時檢測,這樣可以儘早發現死鎖,並且演算法比較簡單。但是這種頻繁的檢測比較耗費CPU資源。

死鎖檢測常見的演算法使用了Allocation矩陣、Available向量、執行緒請求資源的矩陣Q。演算法過程中,標記未死鎖的執行緒,演算法結束時,那些沒有被標記的執行緒就是死鎖的。演算法流程如下:

step1:對於已分配資源為0的執行緒,全部進行標記,因為這些執行緒不佔有任何資源,不可能發生死鎖;

step2:初始化一個currentAvail向量,用於儲存當前可用資源的數量,初始化等於Available向量;

step3:查詢一個執行緒i,該執行緒請求的資源數可以被currentAvail向量表示的資源數所滿足;

step4:如果找到這樣的執行緒i,則標記該執行緒,並假設該執行緒執行完後,會釋放已佔有的資源,對可用資源的數量進行更新;

step5:如果找不到,就結束演算法。最後沒有被標記的執行緒,則發生了死鎖。

檢測到死鎖後,最常用的方法是取消所有的死鎖執行緒。

4、小結

在實際情況中,不一定只採用一種策略來解決死鎖問題,可以根據不同的情況使用不同的策略。

相關文章