Java編程拾遺『線程協作』

之前用了兩篇文章介紹了多個線程競爭資源的解決方案synchronized,但其實多個線程之間除了競爭資源之外,還有相互協作。比如:

  • 生產者/消費者模式:這是一種常見的協作模式,生產者線程和消費者線程通過共享隊列進行協作,生產者將數據或任務放到隊列上,而消費者從隊列上取數據或任務,如果隊列長度有限,在隊列滿的時候,生產者需要等待,而在隊列爲空的時候,消費者需要等待。
  • 同時開始:類似運動員比賽,在聽到比賽開始槍響後同時開始,在一些程序,尤其是模擬仿真程序中,要求多個線程能同時開始。
  • 等待結束:主從協作模式也是一種常見的協作模式,主線程將任務分解爲若干個子任務,爲每個子任務創建一個線程,主線程在繼續執行其他任務之前需要等待每個子任務執行完畢。
  • 集合點:類似於學校或公司組團旅遊,在旅遊過程中有若干集合點,比如出發集合點,每個人從不同地方來到集合點,所有人到齊後進行下一項活動,在一些程序,比如並行迭代計算中,每個線程負責一部分計算,然後在集合點等待其他線程完成,所有線程到齊後,交換數據和計算結果,再進行下一次迭代。
  • 控制併發數目:類似於去銀行櫃檯取款,總共只有4個窗口,但是有10個顧客,那麼同時只有4個顧客能獲得服務,當某個顧客結束後,再通過一個信號通知下一個顧客。再多線程中,其實也就是控同時訪問資源的線程個數。
  • 異步結果:在主從協作模式中,主線程手工創建子線程的寫法往往比較麻煩,一種常見的模式是將子線程的管理封裝爲異步調用,異步調用馬上返回,但返回的不是最終的結果,而是一個一般稱爲Promise或Future的對象,通過它可以在隨後獲得最終的結果。

本篇文章就來介紹一下上述幾種協作方式。

1. 生產者/消費者模式

在生產者/消費者模式中,生產者消費者協作的共享變量是隊列,生產者往隊列中寫數據,如果隊列滿了就wait。而消費者從隊列中取數據,如果隊列爲空也wait。將隊列作爲單獨的類進行設計,代碼如下:

public class MyBlockingQueue<T>  {
    private Queue<T> queue = null;
    private int limit;

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

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

    public synchronized T take() throws InterruptedException {
        while (queue.isEmpty()) {
            wait();
        }
        T e = queue.poll();
        notifyAll();
        return e;
    }
}

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

生產者代碼如下所示:

public class Producer extends Thread {
    private 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) {
        }
    }
}

消費者代碼如下所示:

public class Consumer extends Thread {
    private 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<>(1);
    new Producer(queue).start();
    new Consumer(queue).start();
}

運行結果:

produce task 0
handle task 0
produce task 1
handle task 1
produce task 2
handle task 2
handle task 3
produce task 3
produce task 4
handle task 4
produce task 5
handle task 5
……

生產者和消費者交替出現,複合預期。我們實現的MyBlockingQueue主要用於演示,Java提供了專門的阻塞隊列實現,包括:

  • 基於數組的實現類ArrayBlockingQueue
  • 基於鏈表的實現類LinkedBlockingQueue和LinkedBlockingDeque
  • 基於堆的實現類PriorityBlockingQueue

2. 同時開始

類似於運動員比賽,在聽到比賽開始槍響後同時開始。下面,我們模擬下這個過程。有一個主線程和N個子線程,每個子線程模擬一個運動員,主線程模擬裁判,主線程下達開始命令後,各個子線程開始執行,代碼如下所示:

public class FireFlag {
    private volatile boolean fired = false;

    public synchronized void waitForFire() throws InterruptedException {
        while (!fired) {
            wait();
        }
    }

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

子線程中調用waitForFire()等待發令槍響,而主線程調用fire()發射比賽開始信號。

public class Racer extends Thread {
    private FireFlag fireFlag;

    public Racer(FireFlag fireFlag) {
        this.fireFlag = fireFlag;
    }

    @Override
    public void run() {
        try {
            this.fireFlag.waitForFire();
            System.out.println("start run "
                    + Thread.currentThread().getName());
        } catch (InterruptedException ignored) {
        }
    }
}

子線程run方法中一開始就調用waitForFire,等待開始信號。如果開始信號沒有到達,就會阻塞當前線程。

public class Test {
    public static void main(String[] args) throws Exception {
        int num = 10;
        FireFlag fireFlag = new FireFlag();
        Thread[] racers = new Thread[num];
        for (int i = 0; i < num; i++) {
            racers[i] = new Racer(fireFlag);
            racers[i].start();
        }
        Thread.sleep(1000);
        fireFlag.fire();
    }
}

運行結果:

start run Thread-9
start run Thread-6
start run Thread-4
start run Thread-5
start run Thread-1
start run Thread-0
start run Thread-8
start run Thread-7
start run Thread-2
start run Thread-3

觀察運行結果可以發現,上述結果是在主線程休眠1秒後,調用了fire方法後,各個子線程纔開始打印的。

3. 等待結束

等待其它線程結束,我們可以直接使用Thread對象的join方法。oin實際上就是調用了wait,其主要代碼是:

while (isAlive()) {
    wait(0);
}

只要線程是活着的,isAlive()返回true,join就一直等待。誰來通知它呢?當線程運行結束的時候,Java系統調用notifyAll來通知。

但是使用join有個最大的問題是,需要主線程逐一等待每個子線程。這裏我們通過線程協作來實現主線程等待各個子線程執行結束後繼續執行主線程邏輯。主線程與各個子線程協作的共享變量是一個數,這個數表示未完成的線程個數,初始值爲子線程個數,主線程等待該值變爲0,而每個子線程結束後都將該值減1,當減爲0時調用notifyAll,我們用MyLatch來表示這個協作對象,示例代碼如下:

public class MyLatch {
    private int count;

    public MyLatch(int count) {
        this.count = count;
    }

    public synchronized void await() throws InterruptedException {
        while (count > 0) {
            wait();
        }
    }

    public synchronized void countDown() {
        count--;
        if (count <= 0) {
            notifyAll();
        }
    }
}

MyLatch構造方法的參數count應初始化爲子線程的個數,主線程應該調用await(),而子線程在執行完後應該調用countDown()。工作子線程的示例代碼如下:

public class Worker extends Thread {
    private MyLatch latch;

    public Worker(MyLatch latch) {
        this.latch = latch;
    }

    @Override
    public void run() {
        try {
            // 模擬線程運行
            Thread.sleep((int) (Math.random() * 1000));

            this.latch.countDown();
        } catch (InterruptedException ignored) {
        }
    }
}

主線程的示例代碼如下:

public class Test {
    public static void main(String[] args) throws Exception{
        int workerNum = 100;
        MyLatch latch = new MyLatch(workerNum);
        Worker[] workers = new Worker[workerNum];
        for (int i = 0; i < workerNum; i++) {
            workers[i] = new Worker(latch);
            workers[i].start();
        }
        latch.await();

        System.out.println("collect worker results");
    }
}

MyLatch是一個用於同步協作的工具類,主要用於演示基本原理,在Java中有一個專門的同步類CountDownLatch,功能和用法跟MyLatch一致。MyLatch的功能是比較通用的,它也可以應用於上面”同時開始”的場景,初始值設爲1,Racer類調用await(),主線程調用countDown()即可,如下所示:

public class RacerWithLatchDemo {
    static class Racer extends Thread {
        private MyLatch latch;

        public Racer(MyLatch latch) {
            this.latch = latch;
        }

        @Override
        public void run() {
            try {
                this.latch.await();
                System.out.println("start run "
                        + Thread.currentThread().getName());
            } catch (InterruptedException ignored) {
            }
        }
    }

    public static void main(String[] args) throws Exception {
        int num = 10;
        MyLatch latch = new MyLatch(1);
        Thread[] racers = new Thread[num];
        for (int i = 0; i < num; i++) {
            racers[i] = new Racer(latch);
            racers[i].start();
        }
        Thread.sleep(2000);
        latch.countDown();
    }
}

4. 集合點

各個線程先是分頭行動,然後各自到達一個集合點,在集合點需要集齊所有線程,交換數據,然後再進行下一步動作。怎麼表示這種協作呢?協作的共享變量依然是一個數,這個數表示未到集合點的線程個數,初始值爲子線程個數,每個線程到達集合點後將該值減一,如果不爲0,表示還有別的線程未到,進行等待,如果變爲0,表示自己是最後一個到的,調用notifyAll喚醒所有線程。我們用AssemblePoint類來表示這個協作對象,示例代碼如下:

public class AssemblePoint {
    private int n;

    public AssemblePoint(int n) {
        this.n = n;
    }

    public synchronized void await() throws InterruptedException {
        if (n > 0) {
            n--;
            if (n == 0) {
                notifyAll();
            } else {
                while (n != 0) {
                    wait();
                }
            }
        }
    }
}

遊客線程,先獨立運行,然後使用該協作對象等待其他線程到達集合點,進行下一步動作,示例代碼如下:

public class Tourist extends Thread {
    private int touristId;

    private AssemblePoint ap;

    public Tourist(int touristId, AssemblePoint ap) {
        this.touristId = touristId;
        this.ap = ap;
    }

    @Override
    public void run() {
        try {
            System.out.println("tourist:" + touristId + " start travel");
            // 模擬先各自獨立運行
            Thread.sleep((int) (Math.random() * 1000));

            // 集合
            System.out.println( "tourist:" + touristId + " arrived, wait other tourist");
            ap.await();
            // ... 集合後執行其他操作
            System.out.println("all tourist arrived");
        } catch (InterruptedException e) {
        }
    }
}

調用示例:

public class Test {
    public static void main(String[] args) {
        int num = 5;
        Tourist[] threads = new Tourist[num];
        AssemblePoint ap = new AssemblePoint(num);
        for (int i = 0; i < num; i++) {
            threads[i] = new Tourist(i, ap);
            threads[i].start();
        }
    }
}

運行結果:

tourist:0 start travel
tourist:1 start travel
tourist:2 start travel
tourist:3 start travel
tourist:4 start travel
tourist:2 arrived, wait other tourist
tourist:0 arrived, wait other tourist
tourist:3 arrived, wait other tourist
tourist:4 arrived, wait other tourist
tourist:1 arrived, wait other tourist
all tourist arrived
all tourist arrived
all tourist arrived
all tourist arrived
all tourist arrived

這裏實現的是AssemblePoint主要用於演示基本原理,Java中有一個專門的同步工具類CyclicBarrier可以替代它,功能和使用與AssemblePoint一致。

這裏比較一下,會發現CyclicBarrier和CountDownLatch有些類似,都是實現線程等待。但是它們之間的側重點不同,CountDownLatch一般用於某個線程A等待若干個其他線程執行完任務之後,它才執行。而CyclicBarrier一般用於一組線程互相等待至某個狀態,然後這一組線程再同時執行。

5. 控制併發數目

生活中當出現多人共享有限資源時(比如),就要控制好使用的先後順序,不然就會出現混亂。還用哪個櫃檯取款的例子,只有4個窗口,但是有10個顧客,最多同時只能由4個顧客獲得服務,一個顧客獲得服務之前先要確認一下是否由可用窗口,沒有的話就只能繼續等待。當一個獲得服務的顧客結束之後,要歸還窗口,並通知其它等待的顧客,我們使用MySemaphore類來表示這個協作對象,如下:

public class MySemaphore {
    private int permits;

    public MySemaphore(int permits) {
        this.permits = permits;
    }

    //請求一個資源
    public synchronized void acquire() throws InterruptedException {
        acquire(1);
    }
    
    //請求acquire個資源
    public synchronized void acquire(int acquire) throws InterruptedException {
        while (permits - acquire < 0) {
            wait();
        }
        permits -= acquire;
    }

    //釋放一個資源
    public synchronized void release() {
        release(1);
    }

    //釋放acquire個資源
    public synchronized void release(int acquire) {
        permits += acquire;
        notifyAll();
    }
}

顧客線程,獲得服務前要先通過acquire方法獲取資源,服務結束通過release釋放資源,如下:

public class Customer extends Thread {
    private int customerId;

    private MySemaphore mySemaphore;

    public Customer(int customerId, MySemaphore mySemaphore) {
        this.customerId = customerId;
        this.mySemaphore = mySemaphore;
    }

    @Override
    public void run() {
        try {
            mySemaphore.acquire();
            System.out.println("顧客" + this.customerId + "佔用一個窗口...");
            Thread.sleep(2000);
            System.out.println("顧客" + this.customerId + "釋放窗口");
            mySemaphore.release();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

調用示例:

public class Test {
    public static void main(String[] args) {
        int N = 10;            //顧客數
        MySemaphore semaphore = new MySemaphore(4); //窗口數目
        for (int i = 0; i < N; i++)
            new Customer(i, semaphore).start();
    }
}

運行結果:

顧客0佔用一個窗口...
顧客1佔用一個窗口...
顧客2佔用一個窗口...
顧客3佔用一個窗口...
顧客0釋放窗口
顧客9佔用一個窗口...
顧客1釋放窗口
顧客4佔用一個窗口...
顧客2釋放窗口
顧客8佔用一個窗口...
顧客3釋放窗口
顧客5佔用一個窗口...
顧客9釋放窗口
顧客7佔用一個窗口...
顧客4釋放窗口
顧客6佔用一個窗口...
顧客5釋放窗口
顧客8釋放窗口
顧客7釋放窗口
顧客6釋放窗口

可以看到最多只能有4個顧客獲得服務。Java中有一個專門的同步工具類Semaphore可以替代它,功能和使用與MySemaphore一致。

上面講的MyLatch、AssemblePoint和MySemaphore的實現都是使用的額wait/nofity阻塞實現的。對應Java API中CountDownLatch、CyclicBarrier和Semaphore都是基於AQS(AbstractQueuedSynchronizer)實現的。具體代碼實現會在接下來的文章裏講解。

6. 異步結果

在主從模式中,手工創建線程往往比較麻煩,一種常見的模式是異步調用,異步調用返回一個一般稱爲Promise或Future的對象,通過它可以獲得最終的結果。在Java中,表示子任務的接口是Callable,聲明爲:

public interface Callable<V> {
    V call() throws Exception;
}

爲表示異步調用的結果,我們定義一個接口MyFuture,如下所示:

public interface MyFuture <V> {
    V get() throws Exception ;
}

這個接口的get方法返回真正的結果,如果結果還沒有計算完成,get會阻塞直到計算完成,如果調用過程發生異常,則get方法拋出調用過程中的異常。

爲方便主線程調用子任務,我們定義一個類MyExecutor,其中定義一個public方法execute,表示執行子任務並返回異步結果,聲明如下:

public <V> MyFuture<V> execute(final Callable<V> task)

利用該方法,對於主線程,它就不需要創建並管理子線程了,並且可以方便地獲取異步調用的結果,比如,在主線程中,可以類似這樣啓動異步調用並獲取結果:

public class Test {
    public static void main(String[] args) {
        MyExecutor executor = new MyExecutor();
        // 子任務
        Callable<Integer> subTask = () -> {
            // ... 執行異步任務
            int millis = (int) (Math.random() * 1000);
            Thread.sleep(millis);
            return millis;
        };

        // 異步調用,返回一個MyFuture對象
        MyFuture<Integer> future = executor.execute(subTask);

        try {
            // 獲取異步調用的結果
            Integer result = future.get();
            System.out.println(result);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

MyExecutor的execute方法是怎麼實現的呢?它封裝了創建子線程,同步獲取結果的過程,它會創建一個執行子線程,該子線程的代碼如下所示:

public class ExecuteThread<V> extends Thread{
    private V result = null;
    private Exception exception = null;
    private boolean done = false;
    private Callable<V> task;
    private final Object lock;

    public ExecuteThread(Callable<V> task, Object lock) {
        this.task = task;
        this.lock = lock;
    }

    @Override
    public void run() {
        try {
            result = task.call();
        } catch (Exception e) {
            exception = e;
        } finally {
            synchronized (lock) {
                done = true;
                lock.notifyAll();
            }
        }
    }

    public V getResult() {
        return result;
    }

    public boolean isDone() {
        return done;
    }

    public Exception getException() {
        return exception;
    }
}

這個子線程執行實際的子任務,記錄執行結果到result變量、異常到exception變量,執行結束後設置共享狀態變量done爲true並調用notifyAll以喚醒可能在等待結果的主線程。

MyExecutor類實現:

public class MyExecutor {
    public <V> MyFuture<V> execute(final Callable<V> task) {
        final Object lock = new Object();
        final ExecuteThread<V> thread = new ExecuteThread<>(task, lock);
        thread.start();

        return () -> {
            synchronized (lock) {
                while (!thread.isDone()) {
                    try {
                        lock.wait();
                    } catch (InterruptedException ignored) {
                    }
                }
                if (thread.getException() != null) {
                    throw thread.getException();
                }
                return thread.getResult();
            }
        };
    }
}

execute啓動一個線程,並返回MyFuture對象,MyFuture的get方法會阻塞等待直到線程運行結束。

以上的MyExecutore和MyFuture主要用於演示基本原理,實際上,Java中已經包含了一套完善的框架Executors,相關的部分接口和類有:

  • 表示異步結果的接口Future和實現類FutureTask
  • 用於執行異步任務的接口Executor、以及有更多功能的子接口ExecutorService
  • 用於創建Executor和ExecutorService的工廠方法類Executors

參考鏈接:

1. 《Java編程的邏輯》

2. Java API

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