如何處理執行緒死鎖

程式設計我的一切發表於2021-01-26

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

那下面我們就嘗試著把效能提升一下。

1. 從現實世界尋找答案

現實世界裡,賬戶轉賬操作是支援併發的,而且絕對是真正的併發。我們先試想一下古代,賬戶的存在形式就是一個賬本,而且每個賬戶都有一個賬本,統一放在檔案架上,在做轉賬時,去檔案架上把轉出和轉入賬本都拿到手,然後轉賬,那麼,這個櫃員在拿賬本的時候可能遇到以下三種情況:

  1. 檔案架上恰好有轉出和轉入賬本,同時拿走
  2. 檔案架上只有轉出或轉入賬本,那櫃員就先拿走有的賬本,同時等著其他櫃員把另一個賬本送回
  3. 轉出和轉入賬本都沒有,櫃員等著兩個賬本都被送回來。

上面的過程在程式設計的世界裡怎麼實現呢?其實用兩把鎖就實現了,轉出賬本一把,轉入賬本一把。在 transfer() 方法內部,我們首先嚐試鎖定轉出賬戶 this,然後在嘗試鎖定轉入賬戶 target,只有當兩者都成功了,才執行轉賬操作,過程如下圖所示:

程式碼實現如下:

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

2. 沒有免費的午餐

上面的實現看上去很完美,相對於使用 Account.class 作為互斥鎖,鎖定的範圍太大,而我們鎖定兩個賬戶範圍小多了,這樣的鎖叫細粒度鎖。使用細粒度鎖可以提高並行度,是效能優化的一個重要手段。但是,使用細粒度鎖是有代價的,這個代價就是可能會導致死鎖。

那麼什麼是死鎖呢?死鎖的定義是:一組相互競爭資源的執行緒因相互等待,導致永久阻塞的現象。

上面的程式碼我們可以舉個現實中的例子。如果有客戶找櫃員張三做個轉賬:賬戶 A 轉到 賬戶 B 100元,此時另一個客戶找櫃員李四也做個轉賬業務:賬戶B 轉賬戶 A 100元,於是張三和李四同時都去檔案架上拿賬本,此時湊齊張三拿到了賬戶A,李四拿到了賬戶B ,張三拿著賬戶A 後就等著賬戶B ,而李四拿到賬戶B後就等著賬戶A,它們會永遠的等下去,因為張三不會把賬戶A送回去,李四也不會把賬戶B 送回去,因此就死等下去。

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

上面轉賬的程式碼是怎麼發生死鎖的呢?我們假設執行緒 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 會無期限地等待下去,也就是我們所說的死鎖了。

 

3. 如何預防死鎖

併發程式一旦死鎖,一般沒有好的方法,很多時候我們只能重啟應用。因此,解決死鎖問題做好的辦法就是規避死鎖。

要避免死鎖就要分析出死鎖發生的條件,前人總結出,發生以下四個條件是才會出現死鎖:

  1. 互斥,共享資源 X 和 Y 只能被一個執行緒佔用;
  2. 佔有且等待,執行緒 T1 已經取得共享資源 X,在等待共享資源 Y 的時候,不釋放共享資源 X;
  3. 不可搶佔,其它執行緒不能強行搶佔執行緒 T1 佔有的資源;
  4. 迴圈等待,執行緒 T1 等待執行緒 T2 佔有的資源,執行緒 T2 等待執行緒 T1 佔有的資源。

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

  1. 破壞互斥:互斥這個條件我們沒有辦法破壞,因為我們用鎖就是為了互斥;
  2. 破壞佔有且等待:我們可以一次性申請所有的資源,這樣就不存在等待了;
  3. 破壞不可搶佔:佔有部分這樣的執行緒進一步申請其他資源時,如果申請不到,可以主動釋放佔有的資源;
  4. 破壞迴圈等待:可以靠按序申請資源來預防。所謂按序申請,是指資源是有線性順序的,申請的時候可以先申請資源序號小的,在申請資源序號大的,資源線性化後自然就不存在迴圈了。

我們已經從理論上解決了如何預防死鎖,那具體如何體現在程式碼上,下面我們就來嘗試用程式碼實踐以下這些理論。

3.1 破壞佔有且等待條件

從理論上講,要破壞這個條件,可以一次性申請所有資源。在現實世界裡,對於轉賬操作來說,它需要的資源有兩個,一個是轉出賬戶,一個是轉入賬戶,怎樣同時申請這兩個賬戶呢?方法是,可以增加一個賬本管理員,然後只允許賬本管理員從檔案架上拿賬本,例如張三同時申請賬本A 和 B,只有當賬本 A 和 B 都在的時候才會給張三,只有就保證了一次性申請所有隻有。

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

3.2 破壞不可搶佔條件

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

不過 synchronized 解決不了,在Java SDK 中 java.util.concurrent 這個包下提供的 Lock 是可以輕鬆解決這個問題的。

3.3 破壞迴圈等待條件

破壞迴圈等待這個條件,需要對資源進行排序,然後按序申請資源。這個實現非常簡單,我們假設每個賬戶都有不同的屬性ID,按個ID 可以作為排序字斷,申請的時候,我們可以按照從小到大的順序來申請。

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

4. 總結

這一篇文章主要講了用細粒度鎖來鎖定多個資源時,要注意死鎖的問題。這個就需要你能把它強化為一個思維定勢,遇到這種場景,馬上想到可能存在死鎖問題。當你知道風險之後,才有機會談如何預防和避免,因此,識別出風險很重要。

用細粒度鎖來鎖定多個資源時,可以提高並行度,提升系統效能,但要注意死鎖問題。預防死鎖主要是破壞三個條件中的一個,有了這個思路,現實就簡單了。但仍需注意的是,有時候預防死鎖成本也是很高的。例如上面的例子,我們破壞佔有且等待條件的成本就比破壞迴圈等待條件的成本高,破壞佔有且等待體檢,我們也是鎖了所有的使用者,而且還是用了死迴圈 while(!actr.apply(this, target)); 方法,不過好在apply() 方法基本不耗時。在轉賬這個例子中,破壞迴圈等待條件就是成本最低的一個方案。

所以我們在選擇具體方案時,還需要評估以下操作成本,從中選擇一個成本最低的方案。

 

5. 思考

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

其實,synchronized(Account.class) 鎖了Account類相關的所有操作。相當於文中說的包場了,只要與Account有關聯,通通需要等待當前執行緒操作完成。while死迴圈的方式只鎖定了當前操作的兩個相關的物件。兩種影響到的範圍不同。

相關文章