條件佇列大法好:wait和notify的基本語義

monkeysayhi發表於2019-01-30

條件佇列是我們常用的輕量級同步機制,也被稱為“wait+notify”機制。但很多剛剛接觸併發的朋友可能會對wait和notify的語義和配合過程感到迷惑。

今天從join()方法的實現切入,重點講解wait()方法的語義,簡略提及notify()與notifyAll()的語義,最後總結二者的配合過程。

本篇的知識點很淺,但牢固掌握很重要。後面會再寫一篇文章,介紹wait+nofity的用法,和使用時的一些問題。

基本概念

執行緒、Thread與Object

在理解“wait+notify”機制時,注意區分執行緒、Thread與Object的概念,明確三者在wait、 notify、鎖競爭等事件中充當的角色:

  • 執行緒指作業系統中的執行緒
  • Thread指Java中的執行緒類
  • Object指Java中的物件

Thread繼承自Object,也是一個物件(多型),並從Object類中繼承得到了wait()、notify()(還有notifyAll())方法;同時,Thread也被JVM用於對映作業系統中的執行緒。

wait()

迷惑的join()方法

通過join()方法確認你是否理解了wait+notify機制:

Thread f = new Thread(new Runnable() {
  @Overide
  public run() {
    Thread s = new Thread(new Runnable() {
      @Overide
      public run() {
        for (int i : 1000000) {
          sout(i);
        }
      }
    });
    s.start();
    sout("************* son thread started *************");
    s.join();
    sout("************* son thread died *************");
  }
});
f.start();
複製程式碼

join()方法的語義很簡單,可以不嚴謹的表述為“讓父執行緒等待子執行緒退出”。現在我們來觀察Thread#join()的實現,讓你對這個語義產生迷惑:

public final synchronized void join(long millis)
throws InterruptedException {
    long base = System.currentTimeMillis();
    long now = 0;

    if (millis < 0) {
        throw new IllegalArgumentException("timeout value is negative");
    }

    if (millis == 0) {
        while (isAlive()) {
            wait(0);
        }
    } else {
        while (isAlive()) {
            long delay = millis - now;
            if (delay <= 0) {
                break;
            }
            wait(delay);
            now = System.currentTimeMillis() - base;
        }
    }
}
複製程式碼

重點看15-22行。邏輯很簡單,一個限時阻塞的經典寫法。不過,你可能會產生和我一樣的迷惑:

為什麼呼叫子執行緒的wait()方法,進入等待狀態的卻是父執行緒呢?

分析

讓我們用前面提到的執行緒、Thread和Object三個概念來解釋這段程式碼。事件序列如下:

  1. 主執行緒t0執行1-17行,在Java中建立了Thread例項f,處於NEW狀態;同時,f也是一個Object例項
  • 主執行緒t0執行18行後,作業系統中建立了執行緒t1,Thread例項f轉入RUNNABLE狀態(Java中,Thread沒有RUN狀態,因為執行緒是否正在執行由JVM之外的排程策略決定)
  • 假設執行緒t1正在執行,則執行緒t1執行4-11行,在Java中建立了Thread例項s,處於NEW狀態;同時,s也是一個Object例項
  • 執行緒t1執行12行後,作業系統中建立了執行緒t2,Thread例項s轉入RUNNABLE狀態
  • 假設執行緒t1、t2均正在執行,則執行緒t1執行12行之後、14行之前,可能執行緒t1與執行緒t2同時在向標準輸出列印內容(t1執行13行,t2執行7-9行)
  • 執行緒t1執行14行的過程中,作業系統中的執行緒t1轉入阻塞或等待狀態(取決於作業系統的實現),Thread例項f轉入TIMED_WAITING狀態Thread例項s不受影響,仍處於RUNNABLE狀態
  • 執行緒t2死亡後,被作業系統標記為死亡,Thread例項s轉入為TERMINATED狀態
  • 執行緒t1中,Thread例項f發現Thread例項s不再存活,隨即轉入RUNNABLE狀態,作業系統中的執行緒t1轉入執行狀態
  • 執行緒t1從14行s.join()返回,執行15行,列印
  • 最後,執行緒t1死亡,Thread例項也轉入了TERMINATED狀態

當然,在事件6(執行緒t1執行14行的過程中),Thread例項f在TIMED_WAITING狀態與RUNNABLE狀態之間來回轉換,也因此,才能發現Thread例項s不再存活。但可忽略RUNNABLE狀態,不影響理解。

上一節提出的問題忽略了執行緒、Thread與Object的區別。現在,耐心分析過事件序列之後,讓我們使用這三個概念,重新表述該問題:

為什麼在父執行緒t1中呼叫s.join(),進而呼叫s.wait(),進入等待狀態的卻是Thread例項f對應的父執行緒t1,而不是子執行緒t2呢?

該表述同時也是回答。因為wait()影響的是呼叫wait()的執行緒,而不是wait()所屬的Object例項。具體說,wait()的語義是“將呼叫s.wait()的執行緒t1放入Object例項s的等待集合”。這與s是否同時是Thread例項並無關係——如果s恰好是一個Thread例項,那麼其所對應的執行緒t2可以照常執行,毫無影響。

雖然執行緒的狀態與Thread例項的狀態不能一一對應,但用Thread例項的狀態代替執行緒的狀態,可以簡化條件佇列的模型,又不影響核心的正確性。在事件6(執行緒t1執行14行的過程中)中,各角色的關係如圖:

image.png

更容易理解的用法

我們之所以會在join()方法的實現上產生困惑,是因為它以一種難以理解的姿勢使用wait+notify機制。

wait+notify機制本質上是一種基於條件佇列的同步。JVM為每個物件都內建了監視器,與java.util.concurrent包中的條件佇列Condition對應。

條件佇列本身很容易理解,但join()方法使用wait()的姿勢讓人迷惑。它將Thread例項s作為條件佇列,共享於父執行緒t1、子執行緒t2中——Thread例項s既能夠被建立它的Thread例項f訪問,也能夠被它自己(this)訪問。可讀性很差,不建議學習。

那麼,如何使用wait()才更容易理解呢?可參考Java實現生產者-消費者模型中的“實現二:wait && notify”,使用明確可讀的條件佇列。簡化如下:

public class WaitNotifyModel implements Model {
  private final Object BUFFER_LOCK = new Object();
...
  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;
        System.out.println("consume: " + task.no);
        BUFFER_LOCK.notifyAll();
      }
    }
  }

  private class ProducerImpl extends AbstractProducer implements Producer, Runnable {
    @Override
    public void produce() throws InterruptedException {
      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();
      }
    }
  }
...
}
複製程式碼

BUFFER_LOCK即是內建的條件佇列。所有生產者執行緒和消費者執行緒都共享BUFFER_LOCK,通過BUFFER_LOCK的wait+notify機制實現同步。

  • notify()和notifyAll()接下來講。
  • 之所以命名為BUFFER_LOCK,是因為同時還要在將BUFFER_LOCK作為內建鎖來使用。命名為BUFFER_LOCKBUFFER_COND都是可接受的。

notify()與notifyAll()

可以認為notify與wait是對偶的。s.wait()將當前執行緒c放入Object例項s的等待集合中,s.notify()隨機將一個執行緒t從s的等待集合中取出來(也可能不是隨機的,這取決於作業系統的實現。但很明顯JVM的使用者不應該依賴其是否隨機)。如果s的等待集合中有多個執行緒,那麼t可能是剛才放入的執行緒c,也可能是其他執行緒。

雖然我們通常說“wait+notify”機制,但是使用更多的是notifyAll()而不是notify()。因為notify()只能喚醒一個執行緒,並且通常是隨機的——而被喚醒執行緒所等待的條件不一定已經被滿足(因為多個條件可以使用同一個條件佇列),從而會再次進入等待狀態;真正滿足了條件的執行緒卻因為沒被選中而繼續等待。這類似於“訊號丟失”,可以稱為訊號劫持

notifyAll()則一次喚醒全部等待在該條件佇列上的執行緒。雖然notifyAll()解決了“訊號劫持”的問題,但一次性喚醒全部執行緒去競爭鎖,也大大加劇了無效競爭

關於notify()與notifyAll()的自問自答

如何同時解決訊號劫持與無效競爭?

不過,只要保證notify()每次都能叫醒正確的人,就能在解決訊號劫持的前提下,避免無效競爭。方法很簡單,禁止不同型別的執行緒共用條件佇列。具體來說:

  • 一個條件佇列只用來維護一個條件
  • 每個執行緒被喚醒後執行的操作相同

使用join()方法的過程中,沒有任何執行緒呼叫notify(),如何喚醒執行緒t1?

為了方便理解,前面事件8(執行緒t1中,Thread例項f發現Thread例項s不再存活)採用了不正確的描述。在事件8之前,執行緒t1已經處於阻塞狀態,從而Thread例項f無法發現s是否不再存活。那麼,使用join()方法的過程中,沒有任何執行緒呼叫notify(),如何喚醒執行緒t1?

線上程t1死亡的時候,JVM會幫忙呼叫s.notify()(或非正常死亡時丟擲InterruptedException),以喚醒執行緒t1;t1中做判斷,發現s不再存活,便能夠正常只是後面的邏輯。

這是必要的。假設JVM不會幫忙(呼叫s.notify()或丟擲InterruptedException),在最壞的情況下,如果執行緒t1被使用者從作業系統中強制殺死,那麼在條件佇列s上等待的主執行緒t0將永遠阻塞,而不知道此時發生的異常情況。

同時,這種幫助在JVM規範下沒有副作用。因為JVM要求使用者從wait()方法返回後檢查條件是否得到滿足。如果使用者編寫了錯誤的同步邏輯,使得執行緒t2正常執行結束後,條件仍不能得到滿足,那麼雖然JVM的“幫助”使得執行緒t1提前喚醒,但wait()返回後的檢查使執行緒t1再次進入阻塞狀態,符合使用者編寫的同步邏輯(儘管是錯誤的)。另一方面,如果沒有執行緒等待條件佇列,那麼notify也不會做任何事。

wait+notify的配合過程

仍然用Thread例項的狀態代替執行緒的狀態。

1. 呼叫wait()前

呼叫wait()前,執行緒t1對應的Thread例項f、t2對應的s都處於RUNNABLE狀態:

image.png

2. 呼叫wait()後,呼叫notify()前

線上程t1中呼叫s.wait()後,其他執行緒呼叫s.notify()前,t1對應的f轉入WAITING狀態,進入物件s的等待佇列(即,條件佇列);s不受影響,仍處於RUNNABLE狀態:

image.png

3. 呼叫notify()後

假設在主執行緒t0中主動呼叫s.notify(),那麼在此之後,執行緒t1對應的Thread例項f轉入RUNNABLE狀態;s仍然不受影響:

image.png

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

相關文章