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

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

必定要先看語義,保證本身掌握了基本語義,再來學習如何使用。git

基本原理

狀態依賴的類

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

例如,要想從空隊列中取得元素,必須等待隊列的狀態變爲「非空」;在這個前提條件獲得知足以前,獲取元素的操做將保持阻塞。面試

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

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

可阻塞的狀態依賴操做

狀態依賴類的核心是狀態依賴操做,最經常使用的是可阻塞的狀態依賴操做。服務器

其本質以下:併發

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 // 最後再釋放鎖
複製代碼

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

對應修改狀態的操做:dom

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

條件隊列的核心行爲就是一個可阻塞的狀態依賴操做。ide

在條件隊列中,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()不會出現該問題。

假設等待線程T一、T2的條件謂詞不一樣,但共用一個條件隊列s。此時,T2的條件謂詞獲得知足,s收到通知,隨機從等待在s上的T一、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 國際許可協議發佈,歡迎轉載,演繹或用於商業目的,可是必須保留本文的署名及連接。

相關文章
相關標籤/搜索