本文部分摘自《Java 併發程式設計的藝術》
Fork/Join 框架概述
Fork/Join 框架是 Java7 提供的一個用於並行執行任務的框架,是把一個大任務分割成若干個小任務,最終彙總每個小任務結果後得到大任務結果的框架,其執行流程如圖所示:
工作竊取演算法
工作竊取演算法是指某個執行緒從其他佇列裡竊取任務來執行,為什麼要這樣做呢?假如我們需要做一個比較大的任務,可以把這個任務分割為若干個互不依賴的子任務,為了減少執行緒間的競爭,把這些子任務分別放到不同的佇列裡,併為每個佇列建立一個單獨的執行緒來執行佇列裡的任務,執行緒和佇列一一對應。然而,如果某一執行緒先把自己佇列的任務幹完了,而其他執行緒對應的佇列裡還有任務等待處理,幹完活的執行緒與其等著,不如去幫其他執行緒幹活,這就是工作竊取演算法的動機。
為了減少竊取任務執行緒和被竊取任務執行緒之間的競爭,通常會使用雙端佇列,被竊取任務執行緒永遠從佇列的頭部拿任務執行,而竊取任務的執行緒永遠從雙端佇列的尾部拿任務執行
使用 Fork/Join 框架
首先思考一下,如果讓我們來設計一個 Fork/Join 框架,該如何設計呢?
-
分割任務
首先我們需要一個有 fork 類來把大任務分割成子任務,有可能子任務還是很大,所以需要不停地分割,直到分割出來的子任務足夠小
-
執行任務併合並結果
分割的子任務分別放在雙端佇列裡,然後幾個啟動執行緒分別從雙端佇列裡獲取任務執行。子任務執行完的結果都統一放在一個佇列裡,啟動一個執行緒從佇列裡拿資料,然後進行合併
Fork/Join 使用兩個類來完成以上兩件事情:
-
ForkJoinTask
我們使用 ForkJoin 框架,必須首先建立 ForkJoin 任務,它提供在任務中執行 fork() 和 join() 操作的機制。通常情況下,我們不需要直接繼承 ForkJoinTask 類,只需要繼承它的子類即可,Fork/Join 框架提供了以下兩個子類:
- RecursiveAction:用於沒有返回結果的任務
- RecursiveTask:用於有返回結果待任務
-
ForkJoinPool
ForkJoinTask 需要通過 ForkJoinPool 來執行
我們通過一個簡單的需求來使用 Fork/Join 框架,需求是:計算 1+2+3+4 的結果
使用 Fork/Join 框架把這個任務 fork 成兩個子任務,子任務一負責計算 1+2,子任務而負責計算 3+4,然後再 join 兩個子任務的結果,因為是有結果的任務,所以必須繼承 RecursiveTask,程式碼實現如下:
public class CountTask extends RecursiveTask<Integer> {
// 閾值
private static final int THRESHOLD = 2;
private final int start;
private final int end;
public CountTask(int start, int end) {
this.start = start;
this.end = end;
}
@Override
protected Integer compute() {
int sum = 0;
boolean canCompute = (end - start) <= THRESHOLD;
// 如果任務足夠小就計算任務
if (canCompute) {
for (int i = start; i <= end; i++) {
sum += i;
}
} else {
// 如果任務大於閾值,就分裂成兩個子任務計算
int middle = (start + end) / 2;
CountTask leftTask = new CountTask(start, middle);
CountTask rightTask = new CountTask(middle + 1, end);
// 執行子任務
leftTask.fork();
rightTask.fork();
// 等待子任務執行完,並得到其結果
int leftResult = leftTask.join();
int rightResult = rightTask.join();
// 合併子任務
sum = leftResult + rightResult;
}
return sum;
}
public static void main(String[] args) {
ForkJoinPool forkJoinPool = new ForkJoinPool();
// 生成一個計算任務,負責計算 1+2+3+4
CountTask task = new CountTask(1, 4);
// 執行一個任務
Future<Integer> result = forkJoinPool.submit(task);
try {
System.out.println(result.get());
} catch (InterruptedException | ExecutionException e) {
e.printStackTrace();
}
}
}
上面的例子中是通過 new ForkJoinPool(),然而這並不是其作者 Doug Lea 推薦的方式。ForkJoinPool 類有一個靜態方法commonPool(),它所獲得的 ForkJoinPool 例項是由整個應用程式共享的,可以幫助應用程式中多個需要進行歸併計算的任務共享計算資源
ForkJoinPool forkJoinPool = ForkJoinPool.commonPool();
ForkJoinTask 在執行的時候可能會丟擲異常,但我們沒辦法在主執行緒直接捕獲執行緒,所以 ForkJoinTask 提供了 isCompletedAbnormally() 方法來檢查任務是否已經丟擲異常或已經被取消,並可以通過 ForkJoinTask 的 getException 方法獲取異常
if(task.isCompletedAbnormally()) {
System.out.println(task.getException());
}
Fork/Join 框架的實現原理
ForkJoinPool 中用來處理任務的工作執行緒採用的是 ForkJoinWorkerThread,它繼承了 Thread 類,擁有兩個非常關鍵的變數
final ForkJoinPool pool;
final ForkJoinPool.WorkQueue workQueue;
pool 是這個工作執行緒所屬的 ForkJoinPool 例項,workQueue 是一個雙端佇列,可以發現,它是 ForkJoinPool 的一個內部類,其結構如下(省略部分程式碼)
static final class WorkQueue {
...
ForkJoinTask<?>[] array;
final ForkJoinPool pool;
final ForkJoinWorkerThread owner;
...
}
WorkQueue 裡維護一個 ForkJoinTask 陣列,用來存放待執行的任務(ForkJoinTask)。所以 Fork/Join 框架的基本思想就是:ForkJoinPool 的每個工作執行緒都維護著一個工作佇列(WorkQueue),裡面存放的物件是任務,每個工作執行緒處理自己的工作佇列裡的任務
fork() 方法做的工作只有一件事,既是把任務推入當前工作執行緒的工作佇列裡
public final ForkJoinTask<V> fork() {
Thread t;
if ((t = Thread.currentThread()) instanceof ForkJoinWorkerThread)
((ForkJoinWorkerThread)t).workQueue.push(this);
else
ForkJoinPool.common.externalPush(this);
return this;
}
join() 方法的工作則複雜一些,首先會判斷執行緒是否為 ForkJoinThread 執行緒,如果不是,阻塞當前執行緒,等待任務完成,如果是,則不阻塞。接著檢視任務的完成狀態,如果已經完成,直接返回結果,否則從佇列中取出任務執行