java8(五)並行流之分支/合併框架 一、RecursiveTask 二、Fork/join的最佳用法 三、工作竊取 四、Spliterator

並行流背後使用的基礎架構是Java 7中引入的分支/合併框架。我們會在本文仔細研究分支/合併框架。

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

一、RecursiveTask

要把任務提交到這個池,必須創建 RecursiveTask<R> 的一個子類,其中 R 是並行化任務(以及所有子任務)產生的結果類型,或者如果任務不返回結果,則是 RecursiveAction 類型。

要定義 RecursiveTask, 只需實現它唯一的抽象方法compute :

protected abstract R compute();

在我們實現這個方法時,需要同時定義將任務拆分成子任務的邏輯,以及無法再拆分或不方便再拆分時,生成
單個子任務結果的邏輯。

這個方法的實現類似於下面的僞代碼:

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

遞歸任務拆分過程如下所示:

分支/合併框架實例:爲一個數字範圍Long[]求和

import java.util.concurrent.ForkJoinPool;
import java.util.concurrent.ForkJoinTask;
import java.util.concurrent.RecursiveTask;
import java.util.stream.LongStream;

import static com.cloud.bssp.java8.stream.TestStreamParallel.measureSumPerf;

/**
 * @description: 使用ForkJoinPool
 * @author:weirx
 * @date:2021/10/25 14:10
 * @version:3.0
 */
public class TestRecursiveTask extends RecursiveTask<Long> {

    /**
     * 要求和的數組
     */
    private final long[] numbers;

    /**
     * 子任務求和的數組的開始位置
     */
    private int start;

    /**
     * 子任務求和的數組的結束位置
     */
    private int end;

    /**
     * 私有構造,用於以遞歸方式爲主任務創建子任務
     *
     * @param numbers
     * @param start
     * @param end
     */
    private TestRecursiveTask(long[] numbers, int start, int end) {
        this.numbers = numbers;
        this.start = start;
        this.end = end;
    }

    /**
     * 公共函數用於構建主任務
     *
     * @param numbers
     */
    public TestRecursiveTask(long[] numbers) {
        this.numbers = numbers;
    }

    /**
     * 任務拆分的數組最大值
     */
    public static final long THRESHOLD = 10000L;

    @Override
    protected Long compute() {
        int length = end - start;
        if (length <= THRESHOLD) {
            // 如果大小小於等於閾值,則順序計算
            return computeSequentially();
        } else {
            //創建一個子任務,爲數組的前一半求和
            TestRecursiveTask left = new TestRecursiveTask(numbers, start, start + length / 2);
            //利用另一個ForkJoinPool線程異步執行新創建的子任務
            left.fork();
            //創建一個子任務,爲數組的後一半求和
            TestRecursiveTask right = new TestRecursiveTask(numbers, start + length / 2, end);
            // 同步執行第二個子任務
            Long compute = right.compute();
            //讀取第一個子任務的結果,沒有完成則等待
            Long join = left.join();
            //結果合併
            return compute + join;
        }
    }

    /**
     * 當子任務不可拆分時計算結果的簡單算法
     *
     * @return
     */
    private Long computeSequentially() {
        long sum = 0;
        for (int i = start; i < end; i++) {
            sum += numbers[i];
        }
        return sum;
    }

    /**
     * 並行對前n個自然數求和
     *
     * @param n
     * @return
     */
    public static long forkJoinSum(long n) {
        long[] numbers = LongStream.rangeClosed(1, n).toArray();
        ForkJoinTask<Long> task = new TestRecursiveTask(numbers);
        return new ForkJoinPool().invoke(task);
    }

    public static void main(String[] args) {
        System.out.println("ForkJoin sum done in: " + measureSumPerf(
                TestRecursiveTask::forkJoinSum, 10000000) + " msecs");
    }

}

輸出結果:

ForkJoin sum done in: 64 msecs

這個性能看起來比用並行流的版本要差,但這只是因爲必須先要把整個數字流都放進一個long[] ,之後才能在任務中使用它。

二、Fork/join的最佳用法

雖然分支/合併框架還算簡單易用,不幸的是它也很容易被誤用。以下是幾個有效使用它的最佳做法:

1)對一個任務調用 join 方法會阻塞調用方,直到該任務做出結果。因此,有必要在兩個子任務的計算都開始之後再調用它。否則,你得到的版本會比原始的順序算法更慢更復雜,因爲每個子任務都必須等待另一個子任務完成才能啓動。

2)不應該在 RecursiveTask 內部使用 ForkJoinPool 的 invoke 方法。相反,你應該始終直接調用 compute 或 fork 方法,只有順序代碼才應該用 invoke 來啓動並行計算。

3) 對子任務調用 fork 方法可以把它排進 ForkJoinPool 。同時對左邊和右邊的子任務調用fork()似乎很自然,但這樣做的效率要比直接對其中一個調用 compute 低。調用compute你可以爲其中一個子任務重用同一線程,從而避免在線程池中多分配一個任務造成的開銷。

4)調試分支/合併框架的並行計算代碼可能有點棘手。特別是你平常都在你喜歡的IDE裏面看棧跟蹤(stack trace)來找問題,但放在分支/合併計算上就不行了,因爲調用 compute的線程並不是概念上的調用方,後者是調用 fork 的那個。

5)和並行流一樣,你不應理所當然地認爲在多核處理器上使用分支/合併框架就比順序計算快。一個任務可以分解成多個獨立的子任務,才能讓性能在並行化時有所提升。所有這些子任務的運行時間都應該比分出新任務所花的時長。

三、工作竊取

工作竊取爲何被提出?

如前面的例子,我們指定數組的大小是10000L,即允許任務被拆分爲每個數組大小爲10000,共1000個任務。

在理想的情況下,每個任務完成的時間應該是相同的,這樣在多核cpu的前提下,我們能保證每個核處理的時間都是相同的。

實際情況中,每個子任務花費的時間可以說是天差地別,磁盤,網絡,或等等很多的因素導致。

Fork/Join框架爲了解決這個提出,提出了工作竊取(work stealing)的概念。

在實際應用中,這意味着這些任務差不多被平均分配到 ForkJoinPool 中的所有線程上。每個線程都爲分配給它的任務保存一個雙向鏈式隊列,每完成一個任務,就會從隊列頭上取出下一個任務開始執行。

基於前面所述的原因,某個線程可能早早完成了分配給它的所有任務,也就是它的隊列已經空了,而其他的線程還很忙。這時,這個線程並沒有閒下來,而是隨機選了一個別的線程,從隊列的尾巴上“偷走”一個任務。這個過程一直繼續下去,直到所有的任務都執行完畢,所有的隊列都清空。這就是爲什麼要劃成許多小任務而不是少數幾個大任務,這有助於更好地在工作線程之間平衡負載。

一般來說,這種工作竊取算法用於在池中的工作線程之間重新分配和平衡任務。如下圖展示了這個過程。當工作線程隊列中有一個任務被分成兩個子任務時,一個子任務就被閒置的工作線程“偷走”了。如前所述,這個過程可以不斷遞歸,直到規定子任務應順序執行的條件爲真。

四、Spliterator

那麼Stream是如何實現並行的呢?我們並不需要手動去實現Fork/join,這就意味着,肯定有一種自動機制來爲你拆分流。這種新的自動機制稱爲 Spliterator。

Spliterator 是Java 8中加入的另一個新接口;這個名字代表“可分迭代器”(splitableiterator)。和 Iterator 一樣, Spliterator 也用於遍歷數據源中的元素,但它是爲了並行執行而設計的。

public interface Spliterator<T> {

  /**
    *  tryAdvance 方法的行爲類似於普通的Iterator ,因爲它會按順序一個一個使用 Spliterator 中的元素,
    * 並且如果有其他元素要遍歷就返回 true
    */
  boolean tryAdvance(Consumer<? super T> action);

   /**
     * 專爲 Spliterator 接口設計的,因爲它可以把一些元素劃出去分
     * 給第二個 Spliterator (由該方法返回),讓它們兩個並行處理。
     */
  Spliterator<T> trySplit();

  /**
    * estimateSize 方法估計還剩下多少元素要遍歷
    */
  long estimateSize();

  int characteristics();
}

4.1 拆分過程

將 Stream 拆分成多個部分的算法是一個遞歸過程,這個框架不斷對 Spliterator 調用 trySplit直到它返回 null ,表明它處理的數據結構不能再分割,流程如下描述。

1)第一步是對第一個Spliterator 調用 trySplit ,生成第二個 Spliterator 。

2)第二步對這兩個 Spliterator 調用trysplit ,這樣總共就有了四個 Spliterator 。

3)第三步,對當前所有的Spliterator 調用trysplit ,當所有的trysplit 都返回null,則表示拆分結束。

4.2 Spliterator特性

Spliterator的拆分過程也收到其本身的特性所影響,特性是通過characteristics()方法來聲明的。

Spliterator 接口聲明的最後一個抽象方法是 characteristics ,它將返回一個 int ,代表 Spliterator 本身特性集的編碼。

有如下特性:

    /**
     * 元素有既定的順序(例如 List ),因此 Spliterator 在遍歷和劃分時也會遵循這一順序
     */
    public static final int ORDERED    = 0x00000010;

    /**
     * 對於任意一對遍歷過的元素 x 和 y , x.equals(y) 返回 false
     */
    public static final int DISTINCT   = 0x00000001;

    /**
     * 遍歷的元素按照一個預定義的順序排序
     */
    public static final int SORTED     = 0x00000004;

    /**
     * 該 Spliterator 由一個已知大小的源建立(例如 Set ),因此 estimatedSize() 返回的是準確值
     */
    public static final int SIZED      = 0x00000040;

    /**
     * 保證遍歷的元素不會爲 null
     */
    public static final int NONNULL    = 0x00000100;

    /**
     * Spliterator 的數據源不能修改。這意味着在遍歷時不能添加、刪除或修改任何元素
     */
    public static final int IMMUTABLE  = 0x00000400;

    /**
     * 該 Spliterator 的數據源可以被其他線程同時修改而無需同步
     */
    public static final int CONCURRENT = 0x00001000;

    /**
     * 該 Spliterator 和所有從它拆分出來的 Spliterator 都是 SIZED
     */
    public static final int SUBSIZED = 0x00004000;
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章