[併發理論基礎] 05 | 死鎖

[併發理論基礎] 05 | 一不小心就死鎖了,怎麼辦?


在上一篇文章中,我們用 Account.class 作爲互斥鎖,來解決銀行業務裏面的轉賬問題,雖然這個方案不存在併發問題,但是所有賬戶的轉賬操作都是串行的,例如賬戶 A 轉賬戶 B、賬戶 C 轉賬戶 D 這兩個轉賬操作現實世界裏是可以並行的,但是在這個方案裏卻被串行化了,這樣的話,性能太差。

這篇文章討論轉賬操作性能的提升

一、向現實世界要答案

現實世界裏,賬戶轉賬操作是支持併發的,而且絕對是真正的並行,銀行所有的窗口都可以做轉賬操作。只要我們能仿照現實世界做轉賬操作,串行的問題就解決了。

我們試想在古代,沒有信息化,賬戶的存在形式真的就是一個賬本,而且每個賬戶都有一個賬本,這些賬本都統一存放在文件架上。銀行櫃員在給我們做轉賬時,要去文件架上把轉出賬本和轉入賬本都拿到手,然後做轉賬。這個櫃員在拿賬本的時候可能遇到以下三種情況:

  1. 文件架上恰好有轉出賬本和轉入賬本,那就同時拿走;
  2. 如果文件架上只有轉出賬本和轉入賬本之一,那這個櫃員就先把文件架上有的賬本拿到手,同時等着其他櫃員把另外一個賬本送回來;
  3. 轉出賬本和轉入賬本都沒有,那這個櫃員就等着兩個賬本都被送回來。

上面這個過程在編程的世界裏怎麼實現呢?其實用兩把鎖就實現了,轉出賬本一把,轉入賬本另一把。在 transfer() 方法內部,我們首先嚐試鎖定轉出賬戶 this(先把轉出賬本拿到手),然後嘗試鎖定轉入賬戶 target(再把轉入賬本拿到手),只有當兩者都成功時,才執行轉賬操作。這個邏輯可以圖形化爲下圖這個樣子。

在這裏插入圖片描述

詳細的代碼實現,如下所示。經過這樣的優化後,賬戶 A 轉賬戶 B 和賬戶 C 轉賬戶 D 這兩個轉賬操作就可以並行了。


class Account {
  private int balance;
  // 轉賬
  void transfer(Account target, int amt){
    // 鎖定轉出賬戶
    synchronized(this) {              
      // 鎖定轉入賬戶
      synchronized(target) {           
        if (this.balance > amt) {
          this.balance -= amt;
          target.balance += amt;
        }
      }
    }
  } 
}

二、沒有免費的午餐

上面的實現看上去很完美,並且也算是將鎖用得出神入化了。相對於用 Account.class 作爲互斥鎖,鎖定的範圍太大,而我們鎖定兩個賬戶範圍就小多了,這樣的鎖,上一章我們介紹過,叫細粒度鎖。使用細粒度鎖可以提高並行度,是性能優化的一個重要手段。

但是,使用細粒度鎖是有代價的,這個代價就是可能會導致死鎖。

在詳細介紹死鎖之前,我們先看看現實世界裏的一種特殊場景。如果有客戶找櫃員張三做個轉賬業務:賬戶 A 轉賬戶 B 100 元,此時另一個客戶找櫃員李四也做個轉賬業務:賬戶 B 轉賬戶 A 100 元,於是張三和李四同時都去文件架上拿賬本,這時候有可能湊巧張三拿到了賬本 A,李四拿到了賬本 B。張三拿到賬本 A 後就等着賬本 B(賬本 B 已經被李四拿走),而李四拿到賬本 B 後就等着賬本 A(賬本 A 已經被張三拿走),他們要等多久呢?他們會永遠等待下去…因爲張三不會把賬本 A 送回去,李四也不會把賬本 B 送回去。我們姑且稱爲死等吧。

在這裏插入圖片描述
現實世界裏的死等,就是編程領域的死鎖了。死鎖的一個比較專業的定義是:一組互相競爭資源的線程因互相等待,導致“永久”阻塞的現象。

上面轉賬的代碼是怎麼發生死鎖的呢?我們假設線程 T1 執行賬戶 A 轉賬戶 B 的操作,賬戶 A.transfer(賬戶 B);同時線程 T2 執行賬戶 B 轉賬戶 A 的操作,賬戶 B.transfer(賬戶 A)。當 T1 和 T2 同時執行完①處的代碼時,T1 獲得了賬戶 A 的鎖(對於 T1,this 是賬戶 A),而 T2 獲得了賬戶 B 的鎖(對於 T2,this 是賬戶 B)。之後 T1 和 T2 在執行②處的代碼時,T1 試圖獲取賬戶 B 的鎖時,發現賬戶 B 已經被鎖定(被 T2 鎖定),所以 T1 開始等待;T2 則試圖獲取賬戶 A 的鎖時,發現賬戶 A 已經被鎖定(被 T1 鎖定),所以 T2 也開始等待。於是 T1 和 T2 會無期限地等待下去,也就是我們所說的死鎖了。


class Account {
  private int balance;
  // 轉賬
  void transfer(Account target, int amt){
    // 鎖定轉出賬戶
    synchronized(this){// 鎖定轉入賬戶
      synchronized(target){if (this.balance > amt) {
          this.balance -= amt;
          target.balance += amt;
        }
      }
    }
  } 
}

關於這種現象,我們還可以藉助資源分配圖來可視化鎖的佔用情況(資源分配圖是個有向圖,它可以描述資源和線程的狀態)。其中,資源用方形節點表示,線程用圓形節點表示;資源中的點指向線程的邊表示線程已經獲得該資源,線程指向資源的邊則表示線程請求資源,但尚未得到。轉賬發生死鎖時的資源分配圖就如下圖所示,一個“各據山頭死等”的尷尬局面。

在這裏插入圖片描述

三、如何預防死鎖

併發程序一旦死鎖,一般沒有特別好的方法,很多時候我們只能重啓應用。因此,解決死鎖問題最好的辦法還是規避死鎖。

那如何避免死鎖呢?要避免死鎖就需要分析死鎖發生的條件,有個叫 Coffman 的牛人早就總結過了,只有以下這四個條件都發生時纔會出現死鎖:

  1. 互斥,共享資源 X 和 Y 只能被一個線程佔用;
  2. 佔有且等待,線程 T1 已經取得共享資源 X,在等待共享資源 Y 的時候,不釋放共享資源 X;
  3. 不可搶佔,其他線程不能強行搶佔線程 T1 佔有的資源;
  4. 循環等待,線程 T1 等待線程 T2 佔有的資源,線程 T2 等待線程 T1 佔有的資源,就是循環等待。

反過來分析,也就是說只要我們破壞其中一個,就可以成功避免死鎖的發生。

其中,互斥這個條件我們沒有辦法破壞,因爲我們用鎖爲的就是互斥。不過其他三個條件都是有辦法破壞掉的,到底如何做呢?

  1. 對於“佔用且等待”這個條件,我們可以一次性申請所有的資源,這樣就不存在等待了。
  2. 對於“不可搶佔”這個條件,佔用部分資源的線程進一步申請其他資源時,如果申請不到,可以主動釋放它佔有的資源,這樣不可搶佔這個條件就破壞掉了。
  3. 對於“循環等待”這個條件,可以靠按序申請資源來預防。所謂按序申請,是指資源是有線性順序的,申請的時候可以先申請資源序號小的,再申請資源序號大的,這樣線性化後自然就不存在循環了。

    們已經從理論上解決了如何預防死鎖,那具體如何體現在代碼上呢?下面我們就來嘗試用代碼實踐一下這些理論。
1. 破壞佔用且等待條件

從理論上講,要破壞這個條件,可以一次性申請所有資源。在現實世界裏,就拿前面我們提到的轉賬操作來講,它需要的資源有兩個,一個是轉出賬戶,另一個是轉入賬戶,當這兩個賬戶同時被申請時,我們該怎麼解決這個問題呢?

可以增加一個賬本管理員,然後只允許賬本管理員從文件架上拿賬本,也就是說櫃員不能直接在文件架上拿賬本,必須通過賬本管理員才能拿到想要的賬本。例如,張三同時申請賬本 A 和 B,賬本管理員如果發現文件架上只有賬本 A,這個時候賬本管理員是不會把賬本 A 拿下來給張三的,只有賬本 A 和 B 都在的時候纔會給張三。這樣就保證了“一次性申請所有資源”。

在這裏插入圖片描述
對應到編程領域,“同時申請”這個操作是一個臨界區,我們也需要一個角色(Java 裏面的類)來管理這個臨界區,我們就把這個角色定爲 Allocator。它有兩個重要功能,分別是:同時申請資源 apply() 和同時釋放資源 free()。賬戶 Account 類裏面持有一個 Allocator 的單例(必須是單例,只能由一個人來分配資源)。當賬戶 Account 在執行轉賬操作的時候,首先向 Allocator 同時申請轉出賬戶和轉入賬戶這兩個資源,成功後再鎖定這兩個資源;當轉賬操作執行完,釋放鎖之後,我們需通知 Allocator 同時釋放轉出賬戶和轉入賬戶這兩個資源。具體的代碼實現如下。


class Allocator {
  private List<Object> als =new ArrayList<>();
  // 一次性申請所有資源
  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 {
  // actr應該爲單例
  private Allocator actr;
  private int balance;
  // 轉賬
  void transfer(Account target, int amt){
    // 一次性申請轉出賬戶和轉入賬戶,直到成功
    while(!actr.apply(this, target))try{
      // 鎖定轉出賬戶
      synchronized(this){              
        // 鎖定轉入賬戶
        synchronized(target){           
          if (this.balance > amt){
            this.balance -= amt;
            target.balance += amt;
          }
        }
      }
    } finally {
      actr.free(this, target)
    }
  } 
}

注意: while循環最好設置個timeout,避免一直阻塞下去。加超時在實際項目中非常重要!

2. 破壞不可搶佔條件

破壞不可搶佔條件看上去很簡單,核心是要能夠主動釋放它佔有的資源,這一點 synchronized 是做不到的。原因是 synchronized 申請資源的時候,如果申請不到,線程直接進入阻塞狀態了,而線程進入阻塞狀態,啥都幹不了,也釋放不了線程已經佔有的資源。

Java 在語言層次沒有解決這個問題,不過在 SDK 層面還是解決了的,java.util.concurrent 這個包下面提供的 Lock 是可以輕鬆解決這個問題的。這個話題在後面章節會詳細講。

3. 破壞循環等待條件

破壞這個條件,需要對資源進行排序,然後按序申請資源。這個實現非常簡單,我們假設每個賬戶都有不同的屬性 id,這個 id 可以作爲排序字段,申請的時候,我們可以按照從小到大的順序來申請。比如下面代碼中,①~⑥處的代碼對轉出賬戶(this)和轉入賬戶(target)排序,然後按照序號從小到大的順序鎖定賬戶。這樣就不存在“循環”等待了。


class Account {
  private int id;
  private int balance;
  // 轉賬
  void transfer(Account target, int amt){
    Account left = this        ①
    Account right = target;if (this.id > target.id) { ③
      left = target;           ④
      right = this;}// 鎖定序號小的賬戶
    synchronized(left){
      // 鎖定序號大的賬戶
      synchronized(right){ 
        if (this.balance > amt){
          this.balance -= amt;
          target.balance += amt;
        }
      }
    }
  } 
}

注意:實際開發中都是用數據庫事務+樂觀鎖的方式解決的。這個就是個例子,爲了說明死鎖是怎麼回事,以及死鎖問題怎麼解決。

四、課後題

  1. 上面提到:破壞佔用且等待條件,我們也是鎖了所有的賬戶,而且還是用了死循環 while(!actr.apply(this, target));這個方法,那它比 synchronized(Account.class) 有沒有性能優勢呢?

    答:synchronized(Account.class) 鎖了Account類相關的所有操作。只要與Account有關聯,通通需要等待當前線程操作完成。while死循環的方式只鎖定了當前操作的兩個相關的對象。兩種影響到的範圍不同。

  2. 如何判斷多線程的阻塞導致的問題呢?

    可以用top命令查看Java線程的cpu利用率,用jstack來dump線程。開發環境可以用 java visualvm查看線程執行情況

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章