死鎖雜談

以前說鎖和被保護的資源是1:N的關係,這些被保護的資源有可能是彼此沒關聯的,也有可能彼此關聯。那麼有什麼不同呢?死鎖到底是怎麼產生的?死鎖該如何規避?

保護沒有關聯關係的多個資源

由於這些資源彼此沒關係,我們可以把他們全都塞進this這把鎖裏一了百了,我們也可以給不同的資源加不同的鎖分而治之。總的來說並行分而治之的性能肯定要比串行的同一把鎖好的多。分而治之的這種鎖也叫細粒度鎖

保護有關聯關係的多個資源

有關聯的資源也可以像沒關聯的資源那樣加鎖嗎?

下面我們舉個栗子:

1.有A、B、C三個銀行賬戶,賬戶餘額都爲200

2.A給B轉賬100,B給C轉賬100

如果我們不加鎖:


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

這樣的話就不存在互斥,不存在原子性操作,會有併發問題產生。

那麼我們就加鎖,首先將所有資源都塞到一個鎖裏面去試試:


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

兩個相關聯的資源都在this這把鎖的臨界區,但是我們發現this這把鎖只能保護自己的this.balance 無法保護別人的target.balance。我們分析一下:

我們的初衷是要完成上述要求2的效果,轉賬後A、B、C的餘額分別是100 200 300,我們期望是這樣。

但事實上假設線程1執行A轉賬B的操作,線程2執行B轉賬C的操作,恰好這兩個線程都在不同的CPU上執行,那麼這兩個操作就不是互斥的。也就會導致1,2兩個線程同時進入臨界區,他們讀到的B的餘額都爲200,這樣線程1操作的結果是B的餘額是300,線程2操作的結果會是B的賬戶餘額是100,就看這兩個哪個先寫入內存,100 200 300這三種結果都會有。

既然這樣不行我們就想辦法讓兩個賬戶的操作都是具有唯一性,最好的方法是:


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

類鎖的好處就是臨界區的操作絕對是唯一的。這樣就解決了問題。但是我們之前提到細粒度的鎖性能會更好一點,想象一下如果有上億個賬戶都這樣傻瓜式的順序執行,那該有多慢。事實賬戶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;
        }
      }
    }
  } 
}

這樣的話對象this的鎖保護了this.balance ,target對象的鎖保護了target.balance細粒度化了兩個操作實現了可並行操作。但是看似美好的事物背後都藏着陷阱,佔小便宜喫大虧,這是經常會發生的事情,編程也一樣。

就拿上面這個例子來看,假如A要給B轉賬,B恰好也要給A轉賬,沒毛病,可以並行。但是問題就出在,他們都把彼此的資源鎖了。A等B給它餘額信息,同時B也在等A給他餘額信息。然後就是互不讓步,就是瞎等。這樣就形成了一個無解的閉環,很壞的關係:死鎖

如何防止死鎖呢?

最簡單粗暴的解決方法就是重啓,但這樣治標不治本,遇到類似情況一樣白瞎。所以說最好的辦法是規避它,在設計之初就考慮到它的存在。

如何解決死鎖問題,我們站在巨人的肩頭,所以巨人們已有定論。有個叫 Coffman 的牛人早就總結過了死鎖發生的條件:

1.互斥,共享資源 X 和 Y 只能被一個線程佔用;

2.佔有且等待,線程 T1 已經取得共享資源 X,在等待共享資源 Y 的時候,不釋放共享資源 X;

3.不可搶佔,其他線程不能強行搶佔線程 T1 佔有的資源;

4.循環等待,線程 T1 等待線程 T2 佔有的資源,線程 T2 等待線程 T1 佔有的資源,就是循環等待。

如何解決,總結一句話就是破而後立,僵持中總有人要讓步,破壞形成死鎖的條件

之前說過,要保持原子性操作,就要互斥。所以條件1,我們是沒辦法破壞的。那麼剩下的解決辦法就有:

1. 破壞佔用且等待條件

一個線程一次性申請所有的資源。上面的例子,如果我們一次性申請到A B兩個賬戶的餘額信息,交給一個管理員T管理申請,如果A和B的賬戶餘額信息缺一個都表示申請失敗。那麼A轉賬B 和B轉賬A就互斥了,也就不存在死鎖了;

分佈式事務貌似就可以這樣實現

2. 破壞不可搶佔條件

從字面上看就知道,如果搶佔不到共享資源,讓它釋放線程佔用的資源。然而synchronized是做不到這一點的,它搶佔不到資源就會進入阻塞狀態,不會釋放線程佔用資源的。

當然它做不到,不等於別人做不到,豈不小看了天下英雄。至少java.util.concurrent 這個包下面提供的 Lock 是可以輕鬆解決這個問題的。

我們經常會遇到說synchronized和Lock的區別,這就是其中一個。

3. 破壞循環等待條件

要破壞循環等待條件,就是那個無解的閉環,線程1佔有A的資源,線程2佔有B的資源。不妨給A和B的賬戶資源排個序。再讓線程去申請資源。這樣如果資源A排在前頭,線程1 申請到了A的資源,加了鎖後線程2就無法進入資源A鎖的臨界區,也就不可能去佔用B的資源了。如下面代碼:


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

總結:

細顆粒的鎖存在死鎖的風險,要根據情況去規避。

破壞死鎖的幾個條件,酌情應用。

原子性的本質並非不可分割,而是要保持操作的中間狀態的不可見性。例如賬戶轉賬的例子:

A轉賬B ,A減少100的時候B還沒有變,這個就是中間狀態。還沒有操作完成的變量,要保持對外的不可見性。

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