如何避免死鎖?我們有套路可循

發表於2019-10-30

寫在前面

上一篇文章共享資源那麼多,如何用一把鎖保護多個資源? 文章我們談到了銀行轉賬經典案例,其中有兩個問題:

  1. 單純的用 synchronized 方法起不到保護作用(不能保護 target)
  2. 用 Account.class 鎖方案,鎖的粒度又過大,導致涉及到賬戶的所有操作(取款,轉賬,修改密碼等)都會變成序列操作

如何解決這兩個問題呢?我們們先換好衣服穿越回到過去尋找一下錢莊,一起透過現象看本質,dengdeng deng.......

來到錢莊,告訴櫃員你要給鐵蛋兒轉 100 銅錢,這時櫃員轉身在牆上尋找你和鐵蛋兒的賬本,此時櫃員可能面臨三種情況:

  1. 理想狀態: 你和鐵蛋兒的賬本都是空閒狀態,一起拿回來,在你的賬本上減 100 銅錢,在鐵蛋兒賬本上加 100 銅錢,櫃員轉身將賬本掛回到牆上,完成你的業務
  2. 尷尬狀態: 你的賬本在,鐵蛋兒的賬本被其他櫃員拿出去給別人轉賬,你要等待其他櫃員把鐵蛋兒的賬本歸還
  3. 抓狂狀態: 你的賬本不在,鐵蛋兒的賬本也不在,你只能等待兩個賬本都歸還

放慢櫃員的取賬本操作,他一定是先拿到你的賬本,然後再去拿鐵蛋兒的賬本,兩個賬本都拿到(理想狀態)之後才能完成轉賬,用程式模型來描述一下這個拿取賬本的過程:

我們繼續用程式程式碼描述一下上面這個模型:

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;
        }
      }
    }
  } 
}

這個解決方案看起來很完美,解決了文章開頭說的兩個問題,但真是這樣嗎?


我們剛剛說過的理想狀態是錢莊只有一個櫃員(既單執行緒)。隨著錢莊規模變大,牆上早已掛了非常多個賬本,錢莊為了應對繁忙的業務,開通了多個視窗,此時有多個櫃員(多執行緒)處理錢莊業務。

櫃員 1 正在辦理給鐵蛋兒轉賬的業務,但只拿到了你的賬本;櫃員 2 正在辦理鐵蛋兒給你轉賬的業務,但只拿到了鐵蛋兒的賬本,此時雙方出現了尷尬狀態,兩位櫃員都在等待對方歸還賬本為當前客戶辦理轉賬業務。

現實中櫃員會溝通,喊出一嗓子 老鐵,鐵蛋兒的賬本先給我用一下,用完還給你,但程式卻沒這麼智慧,synchronized 內建鎖非常執著,它會告訴你「死等」的道理,最終出現死鎖

Java 有了 synchronized 內建鎖,還發明瞭顯示鎖 Lock,是不是就為了治一治 synchronized 「死等」的執著呢??

解決方案

如何解決上面的問題呢?正所謂知己知彼方能百戰不殆,我們要先了解什麼情況會發生死鎖,才能知道如何避免死鎖,很幸運我們可以站在巨人的肩膀上看待問題

Coffman 總結出了四個條件說明可以發生死鎖的情形:

Coffman 條件

互斥條件:指程式對所分配到的資源進行排它性使用,即在一段時間內某資源只由一個程式佔用。如果此時還有其它程式請求資源,則請求者只能等待,直至佔有資源的程式用畢釋放。

請求和保持條件:指程式已經保持至少一個資源,但又提出了新的資源請求,而該資源已被其它程式佔有,此時請求程式阻塞,但又對自己已獲得的其它資源保持不放。

不可剝奪條件:指程式已獲得的資源,在未使用完之前,不能被剝奪,只能在使用完時由自己釋放。

環路等待條件:指在發生死鎖時,必然存在一個程式——資源的環形鏈,即程式集合{P1,P2,···,Pn}中的 P1 正在等待一個 P2 佔用的資源;P2 正在等待 P3 佔用的資源,……,Pn 正在等待已被 P0 佔用的資源。

這幾個條件很好理解,其中「互斥條件」是併發程式設計的根基,這個條件沒辦法改變。但其他三個條件都有改變的可能,也就是說破壞另外三個條件就不會出現上面說到的死鎖問題

破壞請求和保持條件

每個櫃員都可以取放賬本,很容易出現互相等待的情況。要想破壞請求和保持條件,就要一次性拿到所有資源。

作為程式猿你一定聽過這句話:

任何軟體工程遇到的問題都可以通過增加一箇中間層來解決

我們不允許櫃員都可以取放賬本,賬本要由單獨的賬本管理員來管理

也就是說賬本管理員拿取賬本是臨界區,如果只拿到其中之一的賬本,那麼不會給櫃員,而是等待櫃員下一次詢問是否兩個賬本都在

//賬本管理員
public class AccountBookManager {
    synchronized boolean getAllRequiredAccountBook( Object from, Object to){
        if(拿到所有賬本){
            return true;
        } else{
            return false;
        }
    }
    // 歸還資源
    synchronized void releaseObtainedAccountBook(Object from, Object to){
        歸還獲取到的賬本
    }
}


public class Account {
    //單例的賬本管理員
    private AccountBookManager accountBookManager;

    public void transfer(Account target, int amt){
        // 一次性申請轉出賬戶和轉入賬戶,直到成功
        while(!accountBookManager.getAllRequiredAccountBook(this, target)){
            return;
        }

        try{
            // 鎖定轉出賬戶
            synchronized(this){
                // 鎖定轉入賬戶
                synchronized(target){
                    if (this.balance > amt){
                        this.balance -= amt;
                        target.balance += amt;
                    }
                }
            }
        } finally {
            accountBookManager.releaseObtainedAccountBook(this, target);
        }
    }
}

破壞不可剝奪條件

上面已經給了你小小的提示,為了解決內建鎖的執著,Java 顯示鎖支援通知(notify/notifyall)和等待(wait),也就是說該功能可以實現喊一嗓子 老鐵,鐵蛋兒的賬本先給我用一下,用完還給你 的功能,這個後續將到 Java SDK 相關內容時會做說明

破壞環路等待條件

破壞環路等待條件也很簡單,我們只需要將資源序號大小排序獲取就會解決這個問題,將環路拆除

繼續用程式碼來說明:

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

當 smaller 被佔用時,其他執行緒就會被阻塞,也就不會存在死鎖了.

附加說明

在實際業務中,關於 Account 都會是資料庫物件,我們可以通過事務或資料庫的樂觀鎖來解決的。另外分散式系統中,賬本管理員這個角色的處理也可能會用 redis 分散式鎖來解決.

在處理破壞請求和保持條件時,我們使用的是 while 迴圈方式來不斷請求鎖的時候,在實際業務中,我們會有 timeout 的設定,防止無休止的浪費 CPU 使用率

另外大家可以嘗試使用阿里開源工具 Arthas 來檢視 CPU 使用率,執行緒等相關問題,github 上有明確的說明

總結

計算機的計算能力遠遠超過人類,但是他的智慧還需要有帶提高,當看待併發問題時,我們往往認為人類的最基本溝通計算機也可以做到,其實不然,還是那句話,編寫併發程式,要站在計算機的角度來看待問題

粗粒度鎖我們不提倡,所以會使用細粒度鎖,但使用細粒度鎖的時候,我們要嚴格按照 Coffman 的四大條件來逐條判斷,這樣再應用我們這幾個解決方案來解決就好了

靈魂追問

  1. 破壞請求和保持條件時,處理能力的瓶頸在賬本管理員那裡,那你覺得這種處理方式會提高併發量嗎?
  2. 破壞請求保持條件的方法和破壞環路等待的方法,你覺得那種方式更好
  3. 破壞請求和保持條件時,如果程式碼換成下面的樣子會發生什麼?
public void transfer(Account target, int amt){
    // 一次性申請轉出賬戶和轉入賬戶,直到成功
    while(accountBookManager.getAllRequiredAccountBook(this, target)){}
        try{
            // 鎖定轉出賬戶
            synchronized(this){
                // 鎖定轉入賬戶
                synchronized(target){
                    if (this.balance > amt){
                        this.balance -= amt;
                        target.balance += amt;
                    }
                }
            }
        } finally {
            accountBookManager.releaseObtainedAccountBook(this, target);
        }
    }
}

提高效率工具


  1. 這次走進併發的世界,請不要錯過
  2. 學併發程式設計,透徹理解這三個核心是關鍵
  3. 併發Bug之源有三,請睜大眼睛看清它們
  4. 可見性有序性,Happens-before來搞定
  5. 解決原子性問題?你首先需要的是巨集觀理解
  6. 面試併發volatile關鍵字時,我們應該具備哪些談資?

歡迎關注我的公眾號 「日拱一兵」,趣味原創解析Java技術棧問題,將複雜問題簡單化,將抽象問題圖形化落地
如果對我的專題內容感興趣,或搶先看更多內容,歡迎訪問我的部落格 dayarch.top

相關文章