Java中Fork/Join分支合併框架實踐

本文較亂,待花時間優化

1.分支/合併框架

分支/合併框架的目的是以遞歸方式將可以並行的任務拆分成更小的任務,然後將每個子任
務的結果合併起來生成整體結果。它是 ExecutorService 接口的一個實現,它把子任務分配給
線程池(稱爲 ForkJoinPool )中的工作線程。

2.1 使用 RecursiveTask

要把任務提交到這個池,必須創建 RecursiveTask 的一個子類,其中 R 是並行化任務(以
及所有子任務)產生的結果類型,或者如果任務不返回結果,則是 RecursiveAction 類型(當
然它可能會更新其他非局部機構)。要定義 RecursiveTask, 只需實現它唯一的抽象方法
compute :
protected abstract R compute();
這個方法同時定義了將任務拆分成子任務的邏輯,以及無法再拆分或不方便再拆分時,生成
單個子任務結果的邏輯。正由於此,這個方法的實現類似於下面的僞代碼:

if (任務足夠小或不可分) {
	順序計算該任務
} else {
    將任務分成兩個子任務
    遞歸調用本方法,拆分每個子任務,等待所有子任務完成
    合併每個子任務的結果
}

一般來說並沒有確切的標準決定一個任務是否應該再拆分,但有幾種試探方法可以幫助你做
出這一決定。
分支合併過程.jpg

爲一個數字範圍(這裏用一個
long[] 數組表示)求和。如前所述,你需要先爲 RecursiveTask 類做一個實現,就是下面代碼
清單中的 ForkJoinSumCalculator 。

public class ForkJoinSumCalculator
extends java.util.concurrent.RecursiveTask<Long> {
private final long[] numbers;
private final int start;
private final int end;
public static final long THRESHOLD = 10_000;
public ForkJoinSumCalculator(long[] numbers) {
this(numbers, 0, numbers.length);
}
private ForkJoinSumCalculator(long[] numbers, int start, int end) {
this.numbers = numbers;
this.start = start;
this.end = end;
}
@Override
protected Long compute() {
int length = end - start;
if (length <= THRESHOLD) {
return computeSequentially();
}
ForkJoinSumCalculator leftTask =
new ForkJoinSumCalculator(numbers, start, start + length/2);
leftTask.fork();
ForkJoinSumCalculator rightTask =
new ForkJoinSumCalculator(numbers, start + length/2, end);
Long rightResult = rightTask.compute();
Long leftResult = leftTask.join();
return leftResult + rightResult;
}
private long computeSequentially() {
long sum = 0;
for (int i = start; i < end; i++) {{
sum += numbers[i];
}
return sum;
}
}

現在編寫一個方法來並行對前n個自然數求和就很簡單了。你只需把想要的數字數組傳給
ForkJoinSumCalculator 的構造函數:

public static long forkJoinSum(long n) {
long[] numbers = LongStream.rangeClosed(1, n).toArray();
ForkJoinTask<Long> task = new ForkJoinSumCalculator(numbers);
return new ForkJoinPool().invoke(task);
}

這裏用了一個 LongStream 來生成包含前n個自然數的數組,然後創建一個 ForkJoinTask
( RecursiveTask 的父類),並把數組傳遞給ForkJoinSumCalculator 的公共
構造函數。最後,你創建了一個新的 ForkJoinPool ,並把任務傳給它的調用方法 。在
ForkJoinPool 中執行時,最後一個方法返回的值就是 ForkJoinSumCalculator 類定義的任務
結果。

請注意在實際應用時,使用多個 ForkJoinPool 是沒有什麼意義的。正是出於這個原因,一
般來說把它實例化一次,然後把實例保存在靜態字段中,使之成爲單例,這樣就可以在軟件中任
何部分方便地重用了。

2.2運行 ForkJoinSumCalculator

當把 ForkJoinSumCalculator 任務傳給 ForkJoinPool 時,這個任務就由池中的一個線程
執行,這個線程會調用任務的 compute 方法。該方法會檢查任務是否小到足以順序執行,如果不
夠小則會把要求和的數組分成兩半,分給兩個新的 ForkJoinSumCalculator ,而它們也由
ForkJoinPool 安排執行。因此,這一過程可以遞歸重複,把原任務分爲更小的任務,直到滿足
不方便或不可能再進一步拆分的條件(本例中是求和的項目數小於等於10 000)。這時會順序計
算每個任務的結果,然後由分支過程創建的(隱含的)任務二叉樹遍歷回到它的根。接下來會合
並每個子任務的部分結果,從而得到總任務的結果。
分支合併算法.jpg

3.1 使用分支/合併框架的最佳做法

雖然分支/合併框架還算簡單易用,不幸的是它也很容易被誤用。以下是幾個有效使用它的
最佳做法。
 對一個任務調用 join 方法會阻塞調用方,直到該任務做出結果。因此,有必要在兩個子
任務的計算都開始之後再調用它。否則,你得到的版本會比原始的順序算法更慢更復雜,
因爲每個子任務都必須等待另一個子任務完成才能啓動。
 不應該在 RecursiveTask 內部使用 ForkJoinPool 的 invoke 方法。相反,你應該始終直
接調用 compute 或 fork 方法,只有順序代碼才應該用 invoke 來啓動並行計算。
 對子任務調用 fork 方法可以把它排進 ForkJoinPool 。同時對左邊和右邊的子任務調用
它似乎很自然,但這樣做的效率要比直接對其中一個調用 compute 低。這樣做你可以爲
其中一個子任務重用同一線程,從而避免在線程池中多分配一個任務造成的開銷。
 調試使用分支/合併框架的並行計算可能有點棘手。特別是你平常都在你喜歡的IDE裏面
看棧跟蹤(stack trace)來找問題,但放在分支合併計算上就不行了,因爲調用 compute
的線程並不是概念上的調用方,後者是調用 fork 的那個。
 和並行流一樣,你不應理所當然地認爲在多核處理器上使用分支/合併框架就比順序計
算快。我們已經說過,一個任務可以分解成多個獨立的子任務,才能讓性能在並行化時
有所提升。所有這些子任務的運行時間都應該比分出新任務所花的時間長;一個慣用方
法是把輸入/輸出放在一個子任務裏,計算放在另一個裏,這樣計算就可以和輸入/輸出
同時進行。此外,在比較同一算法的順序和並行版本的性能時還有別的因素要考慮。就
像任何其他Java代碼一樣,分支/合併框架需要“預熱”或者說要執行幾遍纔會被JIT編
譯器優化。這就是爲什麼在測量性能之前跑幾遍程序很重要,我們的測試框架就是這麼
做的。同時還要知道,編譯器內置的優化可能會爲順序版本帶來一些優勢(例如執行死
碼分析——刪去從未被使用的計算)。
對於分支/合併拆分策略還有最後一點補充:你必須選擇一個標準,來決定任務是要進一步
拆分還是已小到可以順序求值。

3.2 工作竊取

在 ForkJoinSumCalculator 的例子中,我們決定在要求和的數組中最多包含10 000個項目
時就不再創建子任務了。這個選擇是很隨意的,但大多數情況下也很難找到一個好的啓發式方法
來確定它,只能試幾個不同的值來嘗試優化它。在我們的測試案例中,我們先用了一個有1000
萬項目的數組,意味着 ForkJoinSumCalculator 至少會分出1000個子任務來。這似乎有點浪費
資源,因爲我們用來運行它的機器上只有四個內核。在這個特定例子中可能確實是這樣,因爲所
有的任務都受CPU約束,預計所花的時間也差不多。
但分出大量的小任務一般來說都是一個好的選擇。這是因爲,理想情況下,劃分並行任務時,
應該讓每個任務都用完全相同的時間完成,讓所有的CPU內核都同樣繁忙。不幸的是,實際中,每
個子任務所花的時間可能天差地別,要麼是因爲劃分策略效率低,要麼是有不可預知的原因,比如
磁盤訪問慢,或是需要和外部服務協調執行。
分支/合併框架工程用一種稱爲工作竊取(work stealing)的技術來解決這個問題。在實際應
用中,這意味着這些任務差不多被平均分配到 ForkJoinPool 中的所有線程上。每個線程都爲分
配給它的任務保存一個雙向鏈式隊列,每完成一個任務,就會從隊列頭上取出下一個任務開始執
行。基於前面所述的原因,某個線程可能早早完成了分配給它的所有任務,也就是它的隊列已經
空了,而其他的線程還很忙。這時,這個線程並沒有閒下來,而是隨機選了一個別的線程,從隊
列的尾巴上“偷走”一個任務。這個過程一直繼續下去,直到所有的任務都執行完畢,所有的隊
列都清空。這就是爲什麼要劃成許多小任務而不是少數幾個大任務,這有助於更好地在工作線程
之間平衡負載。
一般來說,這種工作竊取算法用於在池中的工作線程之間重新分配和平衡任務。當工作線程隊列中有一個任務被分成兩個子任務時,一個子任務就被閒置的工作線
程“偷走”了。如前所述,這個過程可以不斷遞歸,直到規定子任務應順序執行的條件爲真。

分支合併框架使用的工作竊取算法.jpg

現在你應該清楚流如何使用分支/合併框架來並行處理它的項目了,不過還有一點沒有講。
本節中我們分析了一個例子,你明確地指定了將數字數組拆分成多個任務的邏輯。但是,使用本
章前面講的並行流時就用不着這麼做了,這就意味着,肯定有一種自動機制來爲你拆分流。這種
新的自動機制稱爲 Spliterator ,

public class ForkJoinPoolTest {

    private static int[] data = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};

    public static void main(String[] args) {
        System.out.println("result=> " + calc());
        AccumulatorRecursiveTask task = new AccumulatorRecursiveTask(0, data.length, data);
        ForkJoinPool forkJoinPool = new ForkJoinPool();
        Integer result = forkJoinPool.invoke(task);
        System.out.println("AccumulatorRecursiveTask >>" + result);

        AccumulatorRecursiveAction action = new AccumulatorRecursiveAction(0, data.length, data);
        forkJoinPool.invoke(action);
        System.out.println("AccumulatorRecursiveAction >>" + AccumulatorRecursiveAction.AccumulatorHelper.getResult());
    }


    private static int calc() {
        int result = 0;
        for (int i = 0; i < data.length; i++) {
            result += data[i];
        }
        return result;
    }

}
import java.util.concurrent.RecursiveTask;

public class AccumulatorRecursiveTask extends RecursiveTask<Integer> {

    private final int start;

    private final int end;

    private final int[] data;

    private final int LIMIT = 3;

    public AccumulatorRecursiveTask(int start, int end, int[] data) {
        this.start = start;
        this.end = end;
        this.data = data;
    }


    @Override
    protected Integer compute() {
        if ((end - start) <= LIMIT) {
            int result = 0;
            for (int i = start; i < end; i++) {
                result += data[i];
            }
            return result;
        }

        int mid = (start + end) / 2;
        AccumulatorRecursiveTask left = new AccumulatorRecursiveTask(start, mid, data);
        AccumulatorRecursiveTask right = new AccumulatorRecursiveTask(mid, end, data);
        left.fork();

        Integer rightResult = right.compute();
        Integer leftResult = left.join();

        return rightResult + leftResult;
    }
}

import java.util.concurrent.RecursiveAction;
import java.util.concurrent.atomic.AtomicInteger;

public class AccumulatorRecursiveAction extends RecursiveAction {
    private final int start;

    private final int end;

    private final int[] data;

    private final int LIMIT = 3;

    public AccumulatorRecursiveAction(int start, int end, int[] data) {
        this.start = start;
        this.end = end;
        this.data = data;
    }

    @Override
    protected void compute() {

        if ((end - start) <= LIMIT) {
            for (int i = start; i < end; i++) {
                AccumulatorHelper.accumulate(data[i]);
            }
        } else {
            int mid = (start + end) / 2;
            AccumulatorRecursiveAction left = new AccumulatorRecursiveAction(start, mid, data);
            AccumulatorRecursiveAction right = new AccumulatorRecursiveAction(mid, end, data);
            left.fork();
            right.fork();
            left.join();
            right.join();
        }
    }

    static class AccumulatorHelper {

        private static final AtomicInteger result = new AtomicInteger(0);

        static void accumulate(int value) {
            result.getAndAdd(value);
        }

        public static int getResult() {
            return result.get();
        }

        static void rest() {
            result.set(0);
        }
    }
}

本文參考:
Java–8--新特性–串並行流與ForkJoin框架
線程基礎:多任務處理(12)——Fork/Join框架(基本使用)
Fork/Join框架基本使用
《Java8 in Action》電子版

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