ForkJoin使用和原理剖析

ForkJoin使用和原理剖析

相信大家此前或多或少有瞭解到ForkJoin,ForkJoin框架其實就是一個線程池ExecutorService的實現,通過工作竊取(work-stealing)算法,獲取其他線程中未完成的任務來執行。可以充分利用機器的多處理器優勢,利用空閒的線程去並行快速完成一個可拆分爲小任務的大任務,類似於分治算法。ForkJoin的目標,就是使用所有可用的處理能力來提高程序的響應和性能。本文將介紹ForkJoin框架,依次介紹基礎特性、案例使用、源碼剖析和實現亮點。

基礎特性


ForkJoin框架的核心是ForkJoinPool類,基於AbstractExecutorService擴展。ForkJoinPool中維護了一個隊列數組WorkQueue[],每個WorkQueue維護一個ForkJoinTask數組和當前工作線程。ForkJoinPool實現了工作竊取(work-stealing)算法並執行ForkJoinTask。ForkJoinTask是能夠在ForkJoinPool中執行的任務抽象類,父類是Future,具體實現類有很多,這裏主要關注RecursiveAction和RecursiveTask。RecursiveAction是沒有返回結果的任務,RecursiveTask是需要返回結果的任務。只需要實現其compute()方法,在compute()中做最小任務控制,任務分解(fork)和結果合併(join)。ForkJoinPool中執行的默認線程是ForkJoinWorkerThread,由默認工廠產生,可以自己重寫要實現的工作線程。同時會將ForkJoinPool引用放在每個工作線程中,供工作竊取時使用。

變量說明

ForkJoinPool類

ADD_WORKER : 100000000000000000000000000000000000000000000000 -> 1000 0000 0000 0000,用來配合ctl在控制線程數量時使用

ctl : 控制ForkJoinPool創建線程數量,(ctl & ADD_WORKER) != 0L 時創建線程,也就是當ctl的第16位不爲0時,可以繼續創建線程

defaultForkJoinWorkerThreadFactory : 默認線程工廠,默認實現是DefaultForkJoinWorkerThreadFactory

runState : 全局鎖控制,全局運行狀態

workQueues : 工作隊列數組WorkQueue[]

config : 記錄並行數量和ForkJoinPool的模式(異步或同步)

ForkJoinTask類

status : 任務的狀態,對其他工作線程和pool可見,運行正常則status爲負數,異常情況爲正數

WorkQueue類

qlock : 併發控制,put任務時的鎖控制

array : 任務數組ForkJoinTask<?>[]

pool : ForkJoinPool,所有線程和WorkQueue共享,用於工作竊取、任務狀態和工作狀態同步

base : array數組中取任務的下標

top : array數組中放置任務的下標

owner : 所屬線程,ForkJoin框架中,只有一個WorkQueue是沒有owner的,其他的均有具體線程owner

ForkJoinWorkerThread類

pool : ForkJoinPool,所有線程和WorkQueue共享,用於工作竊取、任務狀態和工作狀態同步

workQueue : 當前線程的任務隊列,與WorkQueue的owner呼應

案例使用

這裏使用網紅ForkJoin案例,1-100數字求和,提升求和效率。

    public class CountRecursiveTask extends RecursiveTask<Integer> {
        private int Th = 15;

        private int start;
        private int end;

        public CountRecursiveTask(int start, int end) {
            this.start = start;
            this.end = end;
        }

        @Override
        protected Integer compute() {
            if (this.end - this.start < Th) {
                return count();
            } else {
                //fork 2 tasks:Th = 10
                int middle = (end + start) / 2;
                CountRecursiveTask left = new CountRecursiveTask(start, middle);
                System.out.println("start:" + start + ";middle:" + middle + ";end:" + end);
                left.fork();
                CountRecursiveTask right = new CountRecursiveTask(middle + 1, end);
                right.fork();
                return left.join() + right.join();
            }
        }

        private int count() {
            int sum = 0;
            for (int i = start; i <= end; i++) {
                sum += i;
            }
            return sum;
        }
    }

    @Test
    public void testForkJoin() {
        ForkJoinPool forkJoinPool = new ForkJoinPool(Runtime.getRuntime().availableProcessors());

        Integer sum = forkJoinPool.invoke(new CountRecursiveTask(1, 100));
        System.out.println(sum);
    }
    

輸出結果爲:

start:1;middle:50;end:100
start:1;middle:25;end:50
start:1;middle:13;end:25
start:26;middle:38;end:50
start:51;middle:75;end:100
start:51;middle:63;end:75
start:76;middle:88;end:100
5050

源碼剖析

源碼剖析是本文的精華,在知道怎麼使用ForkJoin之後,需要深入的瞭解其實現,去掌握Doug Lea大師併發和性能提升的思想。

創建ForkJoinPool

ForkJoinPool forkJoinPool = new ForkJoinPool(Runtime.getRuntime().availableProcessors());先看ForkJoinPool的創建過程,這個比較簡單,創建了一個ForkJoinPool對象,帶有默認ForkJoinWorkerThreadFactory,並行數跟機器核數一樣,同步模式。

提交任務

forkJoinPool.invoke(new CountRecursiveTask(1, 100));會先執行到ForkJoinPool#externalPush中,此時forkJoinPool.workQueues並沒有完成初始化工作,所以執行到ForkJoinPool#externalSubmit。

externalSubmit

1、創建ForkJoinPool的WorkQueue[]變量workQueues,長度爲大於等於2倍並行數量的且是2的n次冪的數。這裏對傳入的並行數量使用了位運算,來計算出workQueues的長度。

2、創建一個WorkQueue變量q,q.base=q.top=4096,q的owner爲null,無工作線程,放入workQueues數組中

3、創建q.array對象,長度8192,將ForkJoinTask也就是代碼案例中的CountRecursiveTask放入q.array,pool爲傳入的ForkJoinPool,並將q.top加1,完成後q.base=4096,q.top=4097。然後執行ForkJoinPool#signalWork方法。(base下標表示用來取數據的,top下標表示用來放數據的,當base小於top時,說明有數據可以取)

externalSubmit主要完成3個小步驟工作,每個步驟都使用了鎖的機制來處理併發事件,既有對runState使用ForkJoinPool的全局鎖,也有對WorkQueue使用局部鎖。

signalWork

signalWork方法的簽名是:void signalWork(WorkQueue[] ws, WorkQueue q)。ws爲ForkJoinPool中的workQueues,q爲externalSubmit方法中新建的用於存放ForkJoinTask的WorkQueue.

signalWork中會根據ctl的值判斷是否需要創建創建工作線程,當前暫無,因此走到tryAddWorker(),並在createWorker()來創建,使用默認工廠方法ForkJoinWorkerThread#ForkJoinWorkerThread(ForkJoinPool)來創建一個ForkJoinWorkerThread,ForkJoinPool爲前面創建的pool。並創建一個WorkQueue其owner爲新創建的工作線程,其array爲空,被命名爲ForkJoinPool-1-worker-1,且將其存放在pool.workQueues數組中。創建完線程之後,工作線程start()開始工作。這樣就創建了兩個WorkQueue存放在pool.workQueues,其中一個WorkQueue保存了第一個大的ForkJoinTask,owner爲null,其base=4096,top=4097;第二個WorkQueue的owner爲新建的工作線程,array爲空,暫時無數據,base=4096,top=4096。關係如下圖:

ForkJoinWorkerThread#run

執行ForkJoinWorkerThread線程ForkJoinPool-1-worker-1,執行點來到ForkJoinWorkerThread#run,注意這裏是在ForkJoinWorkerThread中,此時的workQueue.array還是空的,pool爲文中唯一的一個,是各個線程會共享的。

run方法中首先是一個判斷 if (workQueue.array == null) { // only run once,這也驗證了我們前面的分析,當前線程的workQueue.array是空的。每個新建的線程,擁有的workQueue.array是沒有任務的。那麼它要執行的任務從哪裏來?

runWorker()方法中會執行一個死循環,去scan掃描是否有任務可以執行。全文的講到的工作竊取work-stealing算法,就在java.util.concurrent.ForkJoinPool#scan。當有了上圖的模型概念時,這個方法的實現看過就會覺得其實非常簡單。這裏拿源碼直接講解

	WorkQueue q; ForkJoinTask<?>[] a; ForkJoinTask<?> t;
	int b, n; long c;
	//如果pool.workQueues即ws的k下標元素不爲空
	if ((q = ws[k]) != null) {
		//如果base<top且array不爲空,則說明有元素。爲什麼還需要array不爲空才說明有元素?
		//從下面可以知道由於獲取元素後纔會設置base=base+1,所以可能出現上一個線程拿到元素了但是沒有及時更新base
	    if ((n = (b = q.base) - q.top) < 0 &&
	        (a = q.array) != null) {      // non-empty
	        long i = (((a.length - 1) & b) << ASHIFT) + ABASE;
	        //這裏使用getObjectVolatile去獲取當前WorkQueue的元素
	        //volatile是保證線程可見性的,也就是上一個線程可能已經拿掉了,可能已經將這個任務置爲空了。
	        if ((t = ((ForkJoinTask<?>)
	                  U.getObjectVolatile(a, i))) != null &&
	            q.base == b) {
	            if (ss >= 0) {
	            		//拿到任務之後,將array中的任務用CAS的方式置爲null,並將base加1
	                if (U.compareAndSwapObject(a, i, t, null)) {
	                    q.base = b + 1;
	                    if (n < -1)       // signal others
	                        signalWork(ws, q);
	                    return t;
	                }
	            }
	            else if (oldSum == 0 &&   // try to activate
	                     w.scanState < 0)
	                tryRelease(c = ctl, ws[m & (int)c], AC_UNIT);
	        }
	        if (ss < 0)                   // refresh
	            ss = w.scanState;
	        r ^= r << 1; r ^= r >>> 3; r ^= r << 10;
	        origin = k = r & m;           // move and rescan
	        oldSum = checkSum = 0;
	        continue;
	    }
	    checkSum += b;
	}

疑問1:爲什麼在在pool.workQueues中拿到某個下標的WorkQueue對象沒有使用getObjectVolatile這種方式獲取?

這也是Doug Lea厲害之處,這裏用了更細粒度鎖,讓併發只在一個WorkQueue中,而不是整個workQueues中。

從pool.workQueues中獲得任務後,就會在ForkJoinPool.WorkQueue#runTask去執行.runTask方法中會依次執行到重寫的RecursiveTask#compute方法中。

CountRecursiveTask#compute

重寫compute方法一般需要遵循這個規則來寫

if(任務足夠小){
  直接執行任務;
  如果有結果,return結果;
}else{
  拆分爲2個子任務;
  分別執行子任務的fork方法;
  執行子任務的join方法;
  如果有結果,return合併結果;
}

文中的案例就是按照這個規則來寫的,下面看看fork和join方法做了哪些事情。

ForkJoinTask#fork

    public final ForkJoinTask<V> fork() {
        Thread t;
        //如果是工作線程,則往自己線程中的workQuerue中添加子任務;否則走首次添加邏輯
        if ((t = Thread.currentThread()) instanceof ForkJoinWorkerThread)
            ((ForkJoinWorkerThread)t).workQueue.push(this);
        else
            ForkJoinPool.common.externalPush(this);
        return this;
    }

ForkJoinPool.WorkQueue#push方法會將當前子任務存放到array中,並調用ForkJoinPool#signalWork添加線程或等待其他線程去竊取任務執行。過程又回到前面講到的signalWork流程。

ForkJoinTask#externalAwaitDone

主線程在把任務放置在第一個WorkQueue的array之後,啓動工作線程就退出了。如果使用的是異步的方式,則使用Future的方式來獲取結果,即提交的ForkJoinTask,通過isDone(),get()方法判斷和得到結果。異步的方式跟同步方式在防止任務的過程是一樣的,只是主線程可以任意時刻再通過ForkJoinTask去跟蹤結果。本案例用的是同步的寫法,因此主線程最後在ForkJoinTask#externalAwaitDone等待任務完成。這裏主線程會執行Object#wait(long),使用的是Object類中的wait,在當前ForkJoinTask等待,直到被notify。而notify這個動作會在ForkJoinTask#setCompletion中進行,這裏使用的是notifyAll,因爲需要通知的有主線程和工作線程,他們都共同享用這個對象,需要被喚起。

關於wait/notify,可參考我的另一篇文章Java wait()和await() notify()和signal() notifyAll()和signalAll()瞭解和區別

ForkJoinTask#join

來看left.join() + right.join(),在將left和right的Task放置在當前工作線程的workQueue之後,執行join()方法,join()方法最終會在ForkJoinPool.WorkQueue#tryRemoveAndExec中將剛放入的left取出,將對應workQueue中array的left任務置爲空,然後執行left任務。然後執行到left的compute方法。對於right任務也是一樣,繼續子任務的fork和join工作,如此循環往復。

	public final V join() {
        int s;
        if ((s = doJoin() & DONE_MASK) != NORMAL)
            reportException(s);
        return getRawResult();
    }

當工作線程執行結束後,會執行getRawResult,拿到結果。

所以在整個執行中,假設只有一個並行數,即ForkJoinPool最多隻有一個線程時,left.join由於在前,會把所有的左邊的任務分配並執行完成後,纔會分配右邊的任務並執行,那麼這樣的效率很會低了。所以ForkJoin框架並適合這種用法,並行數必須大於等於2,才能發揮其對任務的併發處理優勢。

如上圖是按照我們的案例代碼分解出來的任務,ForkJoinPool中的線程會對每個任務進行分配或計算,並最終得到結果。這個任務圖是一個樹狀圖,但是ForkJoinPool執行過程中,並不會嚴格按照樹的level來去執行任務的先後順序,例如compute(1-13)可能會先於compute(26-50)或compute(51-100)或compute(76-100)執行。但是對於樹每個節點的父任務,則必須是在子任務之前執行的,否則怎麼可能有子任務,如compute(26-50)必須在compute(26-38)之前執行完成。

任務從上到下進行分配,直到達到可計算的最小任務,而每個父節點,通過left.join()+right.join()方法,負責對兩個子任務/節點結果進行彙總,並最終得到compute(1-100)的結果。類似分治算法,卻比分治算法更加高級一點,因爲可以有多個任務同時被執行。

實現亮點

Work-Steal算法

相比其他線程池實現,這個是ForkJoin框架中最大的亮點。當空閒線程在自己的WorkQueue沒有任務可做的時候,會去遍歷其他的WorkQueue,並進行任務竊取和執行,提高程序響應和性能。

取2的n次冪作爲長度的實現

	//代碼位於java.util.concurrent.ForkJoinPool#externalSubmit
    if ((rs & STARTED) == 0) {
        U.compareAndSwapObject(this, STEALCOUNTER, null,
                               new AtomicLong());
        // create workQueues array with size a power of two
        int p = config & SMASK; // ensure at least 2 slots
        int n = (p > 1) ? p - 1 : 1;
        n |= n >>> 1; n |= n >>> 2;  n |= n >>> 4;
        n |= n >>> 8; n |= n >>> 16; n = (n + 1) << 1;
        workQueues = new WorkQueue[n];
        ns = STARTED;
    }

這裏的p其實就是設置的並行線程數,在爲ForkJoinPool創建WorkQueue[]數組時,會對傳入的p進行一系列位運算,最終得到一個大於等於2p的2的n次冪的數組長度

內存屏障

	//代碼位於java.util.concurrent.ForkJoinPool#externalSubmit
    if ((a != null && a.length > s + 1 - q.base) ||
        (a = q.growArray()) != null) {
        int j = (((a.length - 1) & s) << ASHIFT) + ABASE;
        U.putOrderedObject(a, j, task);
        U.putOrderedInt(q, QTOP, s + 1);
        submitted = true;
    }

這裏在對單個WorkQueue的array進行push任務操作時,先後使用了putOrderedObject和putOrderedInt,確保程序執行的先後順序,同時這種直接操作內存地址的方式也會更加高效。

高併發:細粒度WorkQueue的鎖

	//代碼位於java.util.concurrent.ForkJoinPool#externalSubmit
    if (q.qlock == 0 && U.compareAndSwapInt(q, QLOCK, 0, 1)) {
        ForkJoinTask<?>[] a = q.array;
        int s = q.top;
        boolean submitted = false; // initial submission or resizing
        try {                      // locked version of push
            if ((a != null && a.length > s + 1 - q.base) ||
                (a = q.growArray()) != null) {
                int j = (((a.length - 1) & s) << ASHIFT) + ABASE;
                U.putOrderedObject(a, j, task);
                U.putOrderedInt(q, QTOP, s + 1);
                submitted = true;
            }
        } finally {
            U.compareAndSwapInt(q, QLOCK, 1, 0);
        }
        if (submitted) {
            signalWork(ws, q);
            return;
        }
    }

這裏對單個WorkQueue的array進行push任務操作時,使用了qlock的CAS細粒度鎖,讓併發只落在一個WOrkQueue中,而不是整個pool中,極大提高了程序的併發性能,類似於ConcurrentHashMap。

總結

本文旨在簡單介紹一下ForkJoin框架的一些基本實現,讓大家瞭解一下在並行計算和併發控制上Doug Lea大師的一些思路和邏輯,同時也算是拋磚引玉。

同時我學習之後也存在一些疑問

(1)WorkQueue的長度爲什麼是8192,爲什麼從中間的位置開始放?

(2)ForkJoin框架在哪些情況下不適用,哪些情況下可能造成相反的效果?

歡迎大家一起討論你的疑惑和文中可能還沒有講到的內容。

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