Java併發基礎-Fork、Join方式的平行計算研究分析

icarusliu81發表於2018-03-21

本文目錄:

1 Fork/Join概述

Fork/Join是JDK中提供的類似Map/Reduce的平行計算的實現;它主要處理那些可以遞迴的分解成更小作業的作業。
與Map/Reduce類似,它也分成兩個過程:
- Fork:大的作業被分成很多小的作業分別提交到執行緒池中執行; 
- Join:小作業完成後通過Join操作合併每個作業的執行結果;

示例 假設有10000個資料要計算其合計值,那麼我們可以將這10000個數分成100個作業,每個作業執行100個資料的合計值;這100個作業被提交到執行緒池中並行執行;這就是Fork的過程。
每個小作業執行會有一個結果,Join就是負責將這100個作業的執行結果合併起來。

Fork/Join採用工作竊取演算法業充分利用每個執行緒;每個執行緒可能被分配多個任務;當分配給某個執行緒的任務全部執行完成時,它將會竊取原本分配給其它執行緒的任務並執行,這樣能儘量提高計算效率。

說明 關於工作竊取,按上面所述的10000個數的計算操作,也可以這樣理解:A執行緒被分配了100個資料的計算任務,這100個數的任務又可能被細分成更小粒度的10個數的計算任務;這些子任務都是分配給A來執行的;假設分配給另外的B執行緒的任務已經執行完成,而A中還剩下有子任務未執行完成,那B會從A的佇列中取得未執行的子任務來執行。

Fork/Join計算框架的核心是ForkJoinPool這個類,它繼承自AbstractExecutorService類,實現了工作竊取演算法。而ForkJoinTask的任務可以被提交到ForkJoinPool中執行。

2 示例

先以一簡單的例子來說明Fork/Join如何使用。
以第一節中所述的例子來演示,計算10000個數的和。

public static void main(String[] args) {
    //生成10000個隨機數
    List<Integer> list = Stream.generate(() -> (int)(Math.random() * 10)).limit(10000)
    .collect(Collectors.toList());

    //建立ForkJoinTask物件
    ForkJoinTask<Integer> forkJoinTask = new MyForkJoinTask(list);

    //提交到ForkJoinPool中
    ForkJoinPool forkJoinPool = ForkJoinPool.commonPool();
    Integer result = forkJoinPool.invoke(forkJoinTask);

    //比較計算結果與Stream的計算結果是否一致
    System.out.println(result + ", " + list.stream().reduce((x, y) -> x + y).get());
}

/**
 * 平行計算傳入的List中所有元素的和
 */
public static class MyForkJoinTask extends ForkJoinTask<Integer>{
    private int result = 0;
    private List<Integer> list = null;

    private static final int MIN_TASK_SIZE = 5;

    public MyForkJoinTask(List<Integer> list) {
        this.list = list;
    }

    @Override
    public Integer getRawResult() {
        return result;
    }

    @Override
    protected void setRawResult(Integer value) {
        this.result = value;
    }

    protected boolean exec() {
        int result = 0;
        if (MIN_TASK_SIZE >= list.size()) {
            //如果需要計算的資料量少於一定值時,直接計算結果
            result = list.stream().reduce(0, (x, y) -> x + y);
            setRawResult(result);
        } else {
            //如果需要計算的資料量大於某個值,則將其分解成兩個子任務並提交執行,即Fork過程
            int divideSize = list.size() / 2;
            MyForkJoinTask task1 = new MyForkJoinTask(list.subList(0, divideSize));
            MyForkJoinTask task2 = new MyForkJoinTask(list.subList(divideSize, list.size()));

            //提交兩個子任務
            invokeAll(task1, task2);
            try {
                //合併兩個子任務計算的結果,即Join過程
                result = task1.get() + task2.get();
            } catch (InterruptedException e) {
                //出異常時,需要重新丟擲包含原始異常資訊的異常
                this.completeExceptionally(e);
            } catch (ExecutionException e) {
                this.completeExceptionally(e);
            }

            setRawResult(result);
        }

        return true;
    }
}

Fork/Join過程實際就是一個遞迴的過程。上述程式碼中,最核心的部分就在exec方法中,當需要計算的資料量大於一個閾值時,Fork/Join過程就會生效:

  • Fork:將需要計算的資料分成兩部分,每一部分生成一個子任務(在這裡是新的MyForkJoinTask任務),分別計算佇列中的一半資料,然後使用invokeAll將新生成的子任務提交執行; 子任務執行起來後又會呼叫子任務的exec過程,此時會再次檢測是否要生成新的子任務。
  • Join: 使用get方法等待每個子任務完成後獲取其計算結果,然後將兩部分計算結果合併起來。

這樣原本的計算被一層層的分成了很多的子任務,直到最後每個子任務佇列中計算的資料量小於某個閾值時才不會繼續細分。這就是Fork/Join的過程。

說明 在本例中,進行Fork時,將需要計算的資料分成了兩部分,然後新起兩個子任務分別進行計算;實際上也可以在此處生成多個子任務,這樣能夠減少遞迴的層次,提高運算效率;相應的程式碼複雜度上會有所上升。

3 詳解

先來看下Fork/Join中所涉及的類: 
這裡寫圖片描述

3.1 ForkJoinPool

從類圖中可以看出,ForkJoinPool是AbstractExecutorService的一個子類;它與其它的ExecutorService不一樣的地方在於它的工作竊取機制:每一個池中的執行緒,都會嘗試從池中獲取提交到池中執行的任務或者是獲取其它任務產生的子任務來執行。

ForkJoinPool中提供了一個預設的實現commonPool,可以滿足多數場景下的使用。可以通過commonPool()靜態方法直接獲取到。

除此之外,ForkJoinPool還可以通過建構函式直接建立。

它包含有一些關鍵的引數,在優化其執行效能的時候可能會使用到:
- parallelism:可同時執行的執行緒個數,預設情況下數目與可以使用的CPU核心數是一樣的; 
- corePoolSize:池中保持的執行緒數;一般情況下與parallelism一樣,但在某些場景下如執行執行緒可能被阻塞時可以將其設定成比parallelism大;如果設定成比parallelism小時,將使用預設值也就是parallelism的值。
- maximumPoolSize:池中最多保持的執行緒數;
- minimumRunnable:正在執行而不被阻塞的最小執行緒數目,如果達到這個數目時,將會針對池中的任務建立新的執行緒來執行。

ForkJoinPool包含的一些有可能會使用到的方法:

方法 說明
invoke(ForkJoinTask): T 執行提交的任務,等待其計算完成並返回計算結果,執行失敗時,將會丟擲執行時異常。
execute(ForkJoinTask) 提交任務到池中,提交成功後不等待其執行結果直接返回,一般提交無返回結果的任務
submit(ForkJoinTask): ForkJoinTask 提交一個任務執行,返回的是該提交的任務本身
shutdown 等待池子中的任務完全執行完成後再退出;在此過程中不再接收新的任務
shutdownNow 立即停止池子,嘗試停止或取消所有池子中的任務,並且不再接收新的任務

一般情況下,如果任務有返回結果使用invoke提交任務並等待其處理完成返回結果;如果無返回結果就可以直接使用execute方法提交待執行的任務。

3.2 ForkJoinTask

當一個ForkJoinTask被提交到ForkJoinPool中後,它即開始執行。此時它會啟動其它的子任務。具體示例見第二節。
ForkJoinTask是一個抽象類,有以下抽象方法:

  • setRawResult: 設定計算結果;
  • getRawResult: 獲取計算結果,即使計算過程中出現某些異常,也可以返回一個由setRawResult指定的結果;當計算結果未完成時,將返回null;
  • exec: 執行實際的計算過程;主要的計算邏輯需要實現在這個函式中;一般情況下直接指定返回True就可以了;

在繼承ForkJoinTask類時,需要實現這三個方法;注意這三個方法是提供給繼承的時候使用的,實際上會在ForkJoinTask抽象類中的某些函式中使用到這三個方法,它們並不是直接提供給外部呼叫的

在繼承時,如第二節中的示例,一般過程是這樣的:
- 在子類中新建一個變數儲存計算結果
- 實現setRawResult與getRawResult,來設定結果變數的值與獲取結果變數的值
- 實現exec方法,即實際的計算過程:一般是看當前任務是否足夠小不需要再建立子任務了,如果不需要就直接計算,需要的話就根據一定的規則建立子任務,然後再等待所有子任務執行完成後再收集所有子任務的計算結果,最終呼叫setRawResult來設定計算結果到結果變數中。這樣後續就可以通過ForkJoinTask提供的方法獲取計算結果了。 其流程大致如下圖:
這裡寫圖片描述
ForkJoinTask還有一些經常會使用到的靜態方法,清單如下表: 

方法 說明
invokeAll(ForkJoinTask, ForkJoinTask) 執行提交的兩個任務,並等待兩個任務都執行完成;如果某個任務執行出現異常,那麼會將異常資訊包含在一個執行時異常裡面並丟擲該異常;
invokeAll(ForkJoinTask… 與上一方法類似,用於執行多個任務
invokeAll(Collection): Collection 執行多個任務,並返回每個結果組成的集合

這個類的實現令人較為費解,主要是setRawResult與getRawResult兩個方法的實現上,如果不去了解ForkJoinTask內部是如何呼叫這兩個方法的,理解上就會有些困難。可以不用太關注這個類,因為JDK中提供了它的使用起來更加簡單的子類:RecursiveAction與RecursiveTask。

3.3 RecursiveAction與RecursiveTask

RecursiveAction在ForkJoinTask的基礎上進行了進一步的封裝,它們有一個名稱一樣的抽象方法:compute,不同的是RecursiveAction中該方法的返回值是void型別的,而RecursiveTask的返回型別是T型別。
也就是說,RecursiveAction主要使用在無返回結果的計算中;而RecursiveTask用於有返回結果的計算。

因為它們都只有一個抽象方法compute,因此在使用上不像ForkJoinTask那麼麻煩需要實現多個方法。
實現該方法與繼承ForkJoinTask的exec方法基本是類似的,如使用RecursiveTask來實現第二節中的示例,MyForkJoinTask的實現如下: 

 public static class MyForkJoinTask extends RecursiveTask<Integer> {
    private List<Integer> list = null;

    private static final int MIN_TASK_SIZE = 5;

    public MyForkJoinTask(List<Integer> list) {
        this.list = list;
    }

    @Override
    protected Integer compute() {
        int result = 0;
        if (MIN_TASK_SIZE >= list.size()) {
            //如果需要計算的資料量少於一定值時,直接計算結果
            result = list.stream().reduce(0, (x, y) -> x + y);
            setRawResult(result);
        } else {
            //如果需要計算的資料量大於某個值,則將其分解成兩個子任務並提交執行,即Fork過程
            int divideSize = list.size() / 2;
            MyForkJoinTask task1 = new MyForkJoinTask(list.subList(0, divideSize));
            MyForkJoinTask task2 = new MyForkJoinTask(list.subList(divideSize, list.size()));

            //提交兩個子任務
            invokeAll(task1, task2);
            try {
                //合併兩個子任務計算的結果,即Join過程
                result = task1.get() + task2.get();
            } catch (InterruptedException e) {
                //出異常時,需要重新丟擲包含原始異常資訊的異常 
                this.completeExceptionally(e);
            } catch (ExecutionException e) {
                this.completeExceptionally(e);
            }
        }

        return result;
    }
}

這樣的實現就比直接使用ForkJoinTask要方便並且容易理解的多。因此一般情況下都可以直接使用這兩個類。

相關文章