條件佇列大法好:使用wait、notify和notifyAll的正確姿勢

monkeysayhi發表於2017-12-18

前面介紹wait和notify的基本語義,參考條件佇列大法好:wait和notify的基本語義。這篇講講使用wait、notify、notifyAll的正確姿勢。

一定要先看語義,保證自己掌握了基本語義,再來學習如何使用。

基本原理

狀態依賴的類

狀態依賴的類:在狀態依賴的類中,存在著某些操作,它們擁有基於狀態的前提條件。也就是說,只有該狀態滿足某種前提條件時,操作才會繼續執行

例如,要想從空佇列中取得元素,必須等待佇列的狀態變為“非空”;在這個前提條件得到滿足之前,獲取元素的操作將保持阻塞。

如果是頭一次瞭解狀態依賴類的概念,很容易將狀態依賴類與併發容器混淆。實際上,二者是不對等的概念:

  • 併發容器的關鍵詞是“容器”,其_提供了不同的併發特徵(包括效能、安全、活躍性等方面),使用者大多數時候可以直接使用這些容器_。
  • 狀態依賴的類的關鍵詞是“依賴”,其_提供的是狀態同步的基本邏輯,往往用於維護併發程式的狀態,例如構建併發容器等_,也可以直接由使用者使用。

可阻塞的狀態依賴操作

狀態依賴類的核心是狀態依賴操作,最常用的是可阻塞的狀態依賴操作。

其本質如下:

acquire lock(on object state) // 測試前需要獲取鎖,以保證測試時條件不變
while (precondition does not hold) { // pre-check防止訊號丟失;re-check防止過早喚醒
  release lock // 如果條件尚未滿足,就釋放鎖,允許其他執行緒修改條件
  wait until precondition might hold, interrupted or timeout expires
  acquire lock // 再次測試前需要獲取鎖,以保證測試時條件不變
}
do sth // 如果條件已滿足,就執行動作
release lock // 最後再釋放鎖
複製程式碼

註釋內容可暫時不關注,後面逐項解釋。

對應修改狀態的操作:

acquire lock(on object state)
do sth, to make precondition might be hold
release lock
複製程式碼

條件佇列的核心行為就是一個可阻塞的狀態依賴操作。

在條件佇列中,precondition(前置條件)是一個單元的條件謂詞,也即條件佇列等待的條件(signal/notify)。大部分使用條件佇列的場景,本質上是在基於單元條件謂詞構造多元條件謂詞的狀態依賴類

正確姿勢

version1:baseline

如果將具體場景中的多元條件謂詞稱為“條件謂詞”,那麼,構造出來的仍然是一個可阻塞的狀態依賴操作。

可以認為,條件謂詞和條件佇列針對的都是同一個“條件”,只不過條件謂詞刻畫該“條件”的內容,條件佇列用於維護狀態依賴,即4行的“wait until”。

理解了這一點後,基於條件佇列的同步將變的非常簡單。大體上是使用Java提供的API實現可阻塞的狀態依賴操作。

key point

基本點:

  • 在等待執行緒中獲取條件謂詞的狀態,如果不滿足就等待,滿足就繼續操作
  • 在通知執行緒中修改條件謂詞的狀態,之後發出通知

加鎖:

  • 獲取、修改條件謂詞的狀態是互斥的,需要加鎖保護
  • 滿足條件謂詞的值後,需要保證操作期間,條件謂詞的狀態不變,因此,等待執行緒的加鎖範圍應擴充套件為從檢查條件之前開始,然後進入等待,最後到操作之後結束
  • 同一時間,只能執行一種操作,對應條件謂詞的一次狀態轉換,因此,通知執行緒的加鎖範圍應擴充套件為從操作之前開始,到發出通知之後結束

API相關:

  • 在通知執行緒等待時,通知執行緒需要釋放自己持有的鎖,待條件謂詞滿足時重新競爭鎖。因此,我們在“通知-等待”模型中使用的鎖必須與條件佇列關聯——在Java中,這一語義都由wait()方法完成,因此,不需要使用者顯示的釋放鎖和獲取鎖

偽碼

使用共享物件shared中的內建鎖與內建條件佇列。

// 等待執行緒
synchronized (shared) {
  if (precondition does not hold) {
    shared.wait();
  }
  do sth;
}
複製程式碼
// 通知執行緒
synchronized (shared) {
  do sth, to make precondition might be hold;
  shared.notify();
}
複製程式碼

version2:過早喚醒

Java提供的條件佇列(無論是內建條件佇列還是顯示條件佇列)本身不支援多元條件謂詞,因此儘管我們試圖基於條件佇列內建的單元條件謂詞構造多元條件謂詞的狀態依賴類,但實際上二者在語義上無法繫結在一起——這導致了很多問題。

仍舊以內建條件佇列為例。它提供了內建單元條件謂詞上的“等待”和“通知”的語義,當內建單元條件謂詞滿足時,等待執行緒被喚醒,但該執行緒無法得知是否是多元條件謂詞是否也已經滿足。不考慮惡意程式碼,被喚醒通常有以下原因:

  • 自己的多元條件謂詞得到滿足(這是我們最期望的情況)
  • 超時(如果你不希望一直等下去的話)
  • 被中斷
  • 與你共用一個條件佇列的多元條件謂詞得到滿足(我們不建議這樣做,但內建條件佇列經常會遇到這樣的情況)
  • 如果你恰好使用了一個執行緒物件s作為條件佇列,那麼執行緒死亡的時候,會自動喚醒等待s的執行緒

所以,當執行緒從wait()方法返回時,必須再次檢查多元條件謂詞是否滿足。改起來很簡單:

// 等待執行緒
synchronized (shared) {
  while (precondition does not hold) {
    shared.wait();
  }
  do sth;
}
複製程式碼

另一方面,就算這次被喚醒是因為多元條件謂詞得到滿足,仍然需要再次檢查。別忘了,wait()方法完成了“釋放鎖->等待通知->收到通知->競爭鎖->重新獲取鎖”一系列事件,雖然“收到通知”時多元條件謂詞已經得到滿足,但從“收到通知”到“重新獲取鎖”之間,可能有其他執行緒已經獲取了這個鎖,並修改了多元條件謂詞的狀態,使得多元條件謂詞再次變得不滿足。

以上幾種情況即為“過早喚醒”。

version3:訊號丟失

還有一個很難注意到的問題:re-check時,使用while-do還是do-while?

本質上是一個”先檢查還是先wait“的問題,發生在等待執行緒和通知執行緒啟動的過程中。假設使用do-while:如果通知執行緒先發出通知,等待執行緒再進入等待,那麼等待執行緒將永遠不會醒來,也就是“訊號丟失”。這是因為,條件佇列的通知沒有“粘附性”:如果條件佇列收到通知時,沒有執行緒等待,通知就被丟棄了。

要解決訊號丟失問題,必須“先檢查再wait”,使用while-do即可。

version4:訊號劫持

明確了過早喚醒和訊號丟失的問題,再來講訊號劫持就容易多了。

訊號劫持發生在使用notify()時,notifyAll()不會出現該問題。

假設等待執行緒T1、T2的條件謂詞不同,但共用一個條件佇列s。此時,T2的條件謂詞得到滿足,s收到通知,隨機從等待在s上的T1、T2中選擇了T1。T1的條件謂詞還未滿足,經過re-check後再次進入了阻塞狀態;而條件謂詞已經滿足的T2卻沒有被喚醒。由於T1的過早喚醒,使得T2的訊號丟失了,我們就說在T2上發生了訊號劫持。

將通知執行緒程式碼中的notify()替換為notifyAll()可以解決訊號劫持的問題

// 通知執行緒
synchronized (shared) {
  do sth, to make precondition might be hold;
  shared.notifyAll();
}
複製程式碼

不過,notifyAll()的副作用非常大:一次性喚醒等待在條件佇列上的所有執行緒,除了最終競爭到鎖的執行緒,其他執行緒都相當於無效競爭。事實上,使用notify()也可以,只需要保證每次都能叫醒正確的等待執行緒。方法很簡單:

  • 一個條件佇列只與一個多元條件謂詞繫結,即“單進單出”。

如果使用內建條件佇列,由於一個內建鎖只關聯了一個內建條件佇列,單進單出的條件將很難滿足(如佇列非空與佇列非滿)。顯式鎖(如ReentrantLock)提供了Lock#newCondition()方法,能在一個顯式鎖上建立多個顯示條件佇列,能保證滿足該條件。

總之,訊號劫持問題需要在設計狀態依賴類的時候解決。如果可以避免訊號劫持,還是要使用notify():

// 通知執行緒
synchronized (shared) {
  do sth, to make precondition might be hold;
  shared.notify();
}
複製程式碼

final version

大體框架記住後,使用條件佇列的正確姿勢可以精簡為以下幾個要點:

  • 全程加鎖
  • while-do 等待
  • 要想使用notify,必須保證單進單出

最後給一個之前手擼的生產者消費者模型,明確使用wait、notify、notifyAll的正確姿勢,詳細參考Java實現生產者-消費者模型

該例中,生產者與消費者互為等待執行緒與通知執行緒;兩個條件謂詞非空buffer.size() > 0與非滿buffer.size() < cap共用同一個條件佇列BUFFER_LOCK,需要使用notifyAll避免訊號劫持。簡化如下:

public class WaitNotifyModel implements Model {
  private final Object BUFFER_LOCK = new Object();
  private final Queue<Task> buffer = new LinkedList<>();
...
  private class ConsumerImpl extends AbstractConsumer implements Consumer, Runnable {
    @Override
    public void consume() throws InterruptedException {
      synchronized (BUFFER_LOCK) {
        while (buffer.size() == 0) {
          BUFFER_LOCK.wait();
        }
        Task task = buffer.poll();
        assert task != null;
        // 固定時間範圍的消費,模擬相對穩定的伺服器處理過程
        Thread.sleep(500 + (long) (Math.random() * 500));
        System.out.println("consume: " + task.no);
        BUFFER_LOCK.notifyAll();
      }
    }
  }

  private class ProducerImpl extends AbstractProducer implements Producer, Runnable {
    @Override
    public void produce() throws InterruptedException {
      // 不定期生產,模擬隨機的使用者請求
      Thread.sleep((long) (Math.random() * 1000));
      synchronized (BUFFER_LOCK) {
        while (buffer.size() == cap) {
          BUFFER_LOCK.wait();
        }
        Task task = new Task(increTaskNo.getAndIncrement());
        buffer.offer(task);
        System.out.println("produce: " + task.no);
        BUFFER_LOCK.notifyAll();
      }
    }
  }
...
}
複製程式碼

建議感興趣的讀者繼續閱讀原始碼|併發一枝花之BlockingQueue,從LinkedBlockingQueue的實現中,學習如何保證“一個條件佇列只與一個多元條件謂詞繫結”以避免訊號劫持,還能瞭解到"單次通知"、"條件通知" 等常見優化手段。

總結

條件佇列的使用是併發面試中的一個好考點。猴子第一次遇到時一臉懵逼,嘰裡咕嚕也沒有答上來,現在寫文章時才發現自己根本沒有理解。如果本文有哪裡說錯了,希望您能通過簡書或郵箱聯絡我,提前致謝。

挖坑系列——以後講一下wait、notify、notifyAll的實現機制。


本文連結:條件佇列大法好:使用wait、notify和notifyAll的正確姿勢
作者:猴子007
出處:monkeysayhi.github.io
本文基於 知識共享署名-相同方式共享 4.0 國際許可協議釋出,歡迎轉載,演繹或用於商業目的,但是必須保留本文的署名及連結。

相關文章