計算機程式的思維邏輯 (67) - 執行緒的基本協作機制 (上)

swiftma發表於2017-02-20

本系列文章經補充和完善,已修訂整理成書《Java程式設計的邏輯》(馬俊昌著),由機械工業出版社華章分社出版,於2018年1月上市熱銷,讀者好評如潮!各大網店和書店有售,歡迎購買:京東自營連結

計算機程式的思維邏輯 (67) - 執行緒的基本協作機制 (上)

上節介紹了多執行緒之間競爭訪問同一個資源的問題及解決方案synchronized,我們提到,多執行緒之間除了競爭,還經常需要相互協作,本節就來介紹Java中多執行緒協作的基本機制wait/notify。

都有哪些場景需要協作?wait/notify是什麼?如何使用?實現原理是什麼?協作的核心是什麼?如何實現各種典型的協作場景?由於內容較多,我們分為上下兩節來介紹。

我們先來看看都有哪些協作的場景。

協作的場景

多執行緒之間需要協作的場景有很多,比如說:

  • 生產者/消費者協作模式:這是一種常見的協作模式,生產者執行緒和消費者執行緒通過共享佇列進行協作,生產者將資料或任務放到佇列上,而消費者從佇列上取資料或任務,如果佇列長度有限,在佇列滿的時候,生產者需要等待,而在佇列為空的時候,消費者需要等待。
  • 同時開始:類似運動員比賽,在聽到比賽開始槍響後同時開始,在一些程式,尤其是模擬模擬程式中,要求多個執行緒能同時開始。
  • 等待結束:主從協作模式也是一種常見的協作模式,主執行緒將任務分解為若干個子任務,為每個子任務建立一個執行緒,主執行緒在繼續執行其他任務之前需要等待每個子任務執行完畢。
  • 非同步結果:在主從協作模式中,主執行緒手工建立子執行緒的寫法往往比較麻煩,一種常見的模式是將子執行緒的管理封裝為非同步呼叫,非同步呼叫馬上返回,但返回的不是最終的結果,而是一個一般稱為Promise或Future的物件,通過它可以在隨後獲得最終的結果。
  • 集合點:類似於學校或公司組團旅遊,在旅遊過程中有若干集合點,比如出發集合點,每個人從不同地方來到集合點,所有人到齊後進行下一項活動,在一些程式,比如並行迭代計算中,每個執行緒負責一部分計算,然後在集合點等待其他執行緒完成,所有執行緒到齊後,交換資料和計算結果,再進行下一次迭代。

我們會探討如何實現這些協作場景,在此之前,我們先來了解協作的基本方法wait/notify。

wait/notify

我們知道,Java的根父類是Object,Java在Object類而非Thread類中,定義了一些執行緒協作的基本方法,使得每個物件都可以呼叫這些方法,這些方法有兩類,一類是wait,另一類是notify。

主要有兩個wait方法:

public final void wait() throws InterruptedException
public final native void wait(long timeout) throws InterruptedException;
複製程式碼

一個帶時間引數,單位是毫秒,表示最多等待這麼長時間,引數為0表示無限期等待。一個不帶時間引數,表示無限期等待,實際就是呼叫wait(0)。在等待期間都可以被中斷,如果被中斷,會丟擲InterruptedException,關於中斷及中斷處理,我們在下節介紹,本節暫時忽略該異常。

wait實際上做了什麼呢?它在等待什麼?上節我們說過,每個物件都有一把鎖和等待佇列,一個執行緒在進入synchronized程式碼塊時,會嘗試獲取鎖,獲取不到的話會把當前執行緒加入等待佇列中,其實,除了用於鎖的等待佇列,每個物件還有另一個等待佇列,表示條件佇列,該佇列用於執行緒間的協作。呼叫wait就會把當前執行緒放到條件佇列上並阻塞,表示當前執行緒執行不下去了,它需要等待一個條件,這個條件它自己改變不了,需要其他執行緒改變。當其他執行緒改變了條件後,應該呼叫Object的notify方法:

public final native void notify();
public final native void notifyAll();
複製程式碼

notify做的事情就是從條件佇列中選一個執行緒,將其從佇列中移除並喚醒,notifyAll和notify的區別是,它會移除條件佇列中所有的執行緒並全部喚醒。

我們來看個簡單的例子,一個執行緒啟動後,在執行一項操作前,它需要等待主執行緒給它指令,收到指令後才執行,程式碼如下:

public class WaitThread extends Thread {
    private volatile boolean fire = false;

    @Override
    public void run() {
        try {
            synchronized (this) {
                while (!fire) {
                    wait();
                }
            }
            System.out.println("fired");
        } catch (InterruptedException e) {
        }
    }

    public synchronized void fire() {
        this.fire = true;
        notify();
    }

    public static void main(String[] args) throws InterruptedException {
        WaitThread waitThread = new WaitThread();
        waitThread.start();
        Thread.sleep(1000);
        System.out.println("fire");
        waitThread.fire();
    }
}
複製程式碼

示例程式碼中有兩個執行緒,一個是主執行緒,一個是WaitThread,協作的條件變數是fire,WaitThread等待該變數變為true,在不為true的時候呼叫wait,主執行緒設定該變數並呼叫notify。

兩個執行緒都要訪問協作的變數fire,容易出現競態條件,所以相關程式碼都需要被synchronized保護。實際上,wait/notify方法只能在synchronized程式碼塊內被呼叫,如果呼叫wait/notify方法時,當前執行緒沒有持有物件鎖,會丟擲異常java.lang.IllegalMonitorStateException。

你可能會有疑問,如果wait必須被synchronzied保護,那一個執行緒在wait時,另一個執行緒怎麼可能呼叫同樣被synchronzied保護的notify方法呢?它不需要等待鎖嗎?我們需要進一步理解wait的內部過程,雖然是在synchronzied方法內,但呼叫wait時,執行緒會釋放物件鎖,wait的具體過程是:

  1. 把當前執行緒放入條件等待佇列,釋放物件鎖,阻塞等待,執行緒狀態變為WAITING或TIMED_WAITING
  2. 等待時間到或被其他執行緒呼叫notify/notifyAll從條件佇列中移除,這時,要重新競爭物件鎖
  • 如果能夠獲得鎖,執行緒狀態變為RUNNABLE,並從wait呼叫中返回
    複製程式碼
  • 否則,該執行緒加入物件鎖等待佇列,執行緒狀態變為BLOCKED,只有在獲得鎖後才會從wait呼叫中返回

執行緒從wait呼叫中返回後,不代表其等待的條件就一定成立了,它需要重新檢查其等待的條件,一般的呼叫模式是:

synchronized (obj) {
    while (條件不成立)
        obj.wait();
    ... // 執行條件滿足後的操作
}
複製程式碼

比如,上例中的程式碼是:

synchronized (this) {
    while (!fire) {
        wait();
    }
}
複製程式碼

呼叫notify會把在條件佇列中等待的執行緒喚醒並從佇列中移除,但它不會釋放物件鎖,也就是說,只有在包含notify的synchronzied程式碼塊執行完後,等待的執行緒才會從wait呼叫中返回。

簡單總結一下,wait/notify方法看上去很簡單,但往往難以理解wait等的到底是什麼,而notify通知的又是什麼,我們需要知道,它們與一個共享的條件變數有關,這個條件變數是程式自己維護的,當條件不成立時,執行緒呼叫wait進入條件等待佇列,另一個執行緒修改了條件變數後呼叫notify,呼叫wait的執行緒喚醒後需要重新檢查條件變數。從多執行緒的角度看,它們圍繞共享變數進行協作,從呼叫wait的執行緒角度看,它阻塞等待一個條件的成立。我們在設計多執行緒協作時,需要想清楚協作的共享變數和條件是什麼,這是協作的核心。接下來,我們通過一些場景來進一步理解wait/notify的應用,本節只介紹生產者/消費者模式,下節介紹更多模式。

生產者/消費者模式

在生產者/消費者模式中,協作的共享變數是佇列,生產者往佇列上放資料,如果滿了就wait,而消費者從佇列上取資料,如果佇列為空也wait。我們將佇列作為單獨的類進行設計,程式碼如下:

static class MyBlockingQueue<E> {
    private Queue<E> queue = null;
    private int limit;

    public MyBlockingQueue(int limit) {
        this.limit = limit;
        queue = new ArrayDeque<>(limit);
    }

    public synchronized void put(E e) throws InterruptedException {
        while (queue.size() == limit) {
            wait();
        }
        queue.add(e);
        notifyAll();
    }

    public synchronized E take() throws InterruptedException {
        while (queue.isEmpty()) {
            wait();
        }
        E e = queue.poll();
        notifyAll();
        return e;
    }
}
複製程式碼

MyBlockingQueue是一個長度有限的佇列,長度通過構造方法的引數進行傳遞,有兩個方法put和take。put是給生產者使用的,往佇列上放資料,滿了就wait,放完之後呼叫notifyAll,通知可能的消費者。take是給消費者使用的,從佇列中取資料,如果為空就wait,取完之後呼叫notifyAll,通知可能的生產者。

我們看到,put和take都呼叫了wait,但它們的目的是不同的,或者說,它們等待的條件是不一樣的,put等待的是佇列不為滿,而take等待的是佇列不為空,但它們都會加入相同的條件等待佇列。由於條件不同但又使用相同的等待佇列,所以要呼叫notifyAll而不能呼叫notify,因為notify只能喚醒一個執行緒,如果喚醒的是同類執行緒就起不到協調的作用。

只能有一個條件等待佇列,這是Java wait/notify機制的侷限性,這使得對於等待條件的分析變得複雜,後續章節我們會介紹顯式的鎖和條件,它可以解決該問題。

一個簡單的生產者程式碼如下所示:

static class Producer extends Thread {
    MyBlockingQueue<String> queue;

    public Producer(MyBlockingQueue<String> queue) {
        this.queue = queue;
    }

    @Override
    public void run() {
        int num = 0;
        try {
            while (true) {
                String task = String.valueOf(num);
                queue.put(task);
                System.out.println("produce task " + task);
                num++;
                Thread.sleep((int) (Math.random() * 100));
            }
        } catch (InterruptedException e) {
        }
    }
}
複製程式碼

Producer向共享佇列中插入模擬的任務資料。一個簡單的示例消費者程式碼如下所示:

static class Consumer extends Thread {
    MyBlockingQueue<String> queue;

    public Consumer(MyBlockingQueue<String> queue) {
        this.queue = queue;
    }

    @Override
    public void run() {
        try {
            while (true) {
                String task = queue.take();
                System.out.println("handle task " + task);
                Thread.sleep((int)(Math.random()*100));
            }
        } catch (InterruptedException e) {
        }
    }
}
複製程式碼

主程式的示例程式碼如下所示:

public static void main(String[] args) {
    MyBlockingQueue<String> queue = new MyBlockingQueue<>(10);
    new Producer(queue).start();
    new Consumer(queue).start();
}
複製程式碼

執行該程式,會看到生產者和消費者執行緒的輸出交替出現。

我們實現的MyBlockingQueue主要用於演示,Java提供了專門的阻塞佇列實現,包括:

  • 介面BlockingQueue和BlockingDeque
  • 基於陣列的實現類ArrayBlockingQueue
  • 基於連結串列的實現類LinkedBlockingQueue和LinkedBlockingDeque
  • 基於堆的實現類PriorityBlockingQueue

我們會在後續章節介紹這些類,在實際系統中,應該考慮使用這些類。

小結

本節介紹了Java中執行緒間協作的基本機制wait/notify,協作關鍵要想清楚協作的共享變數和條件是什麼,為進一步理解,本節針對生產者/消費者模式演示了wait/notify的用法。

下一節,我們來繼續探討其他協作模式。

(與其他章節一樣,本節所有程式碼位於 github.com/swiftma/pro…)


未完待續,檢視最新文章,敬請關注微信公眾號“老馬說程式設計”(掃描下方二維碼),深入淺出,老馬和你一起探索Java程式設計及計算機技術的本質。用心原創,保留所有版權。

計算機程式的思維邏輯 (67) - 執行緒的基本協作機制 (上)

相關文章