work steal and overpartition

工作竊取和超額切分,第一次聽到這個概念是從ZGC的Stripe Mark技術,它爲了提高垃圾回收的效率,把多個region劃分到不同的條紋,然後再把GC線程隔離到自己的條紋內進行垃圾回收。如果有線程處理完了自己的任務,則會加入到其他的條紋上幫助其他的線程來處理任務。這就是和工作竊取是同一種思想。

工作竊取,一種分治的思想,最初是一種多指令流多數據流(MIMD)計算任務的線程調度方法,需要工作的處理器需要從其他的處理器上竊取工作任務。一般上層應用層也會用來做多線程任務的調度,來保證線程的活躍來提高任務處理的速度。

爲了保證線程的活躍,需要把任務做超額的切分,即要有足夠的任務來保證線程的繁忙。

因爲MIMD計算任務的調度模型與一般上層應用所描述的線程任務調度會有一定的區別,這裏我們只討論應用層一般用來做線程任務調度的工作竊取思想,但是基本思想還是大同小異。

具體來說,每個線程都會維護一個雙端隊列數據結構。雙端隊列有兩個節點:頭結點、尾節點。任務可以從尾節點插入,從頭結點移除。當初始時,每個線程都有自己的一個雙端隊列來維護自己需要處理的任務,每次從頭結點彈出一個任務來執行,當自身雙端隊列爲空時,即自身任務執行完,則會從其他的雙端隊列中隨機選擇一個進行任務竊取,如果被竊取的雙端隊列不爲空,則會從該雙端隊列的尾節點彈出任務進行處理;如果爲空,則會再隨機選擇一個雙端隊列。直到所有的雙端隊列都爲空時,表示任務全部執行完畢。

這裏涉及到了一個問題:竊取任務的原子訪問。

如果多個線程同時對同一個雙端隊列進行任務竊取時,需要保證竊取的競爭問題。這裏有兩種思想:

  1. 每個雙端隊列同一時間只接受一個竊取請求,其他的竊取請求全部都拒絕。
  2. 每個雙端隊列同一時間只接受一個竊取請求,其他的竊取請求則被排隊,由雙端隊列來進行順序處理。這裏的處理規則爲:當雙端隊列至少有一個任務時,都不能拒絕,直到該雙端隊列也爲空。

在一般實際應用中,第二種方式使用的比較多,這種方式的任務處理延遲會比較小,平均任務處理效率會比較高。

 

如上圖,就是工作竊取的基本工作思想。

在JDK中的ForkJoin框架中就使用到了工作竊取的思想。

ForkJoin框架就是利用的分治思想,把一個大任務切分多若干子任務分別來執行,然後再將各個子任務的結果合併,基本可以理解爲單機版的MapReduce。在切分爲子任務後,如果某個任務執行結束後,則會利用到工作竊取的思想,空閒的線程會去竊取任務來執行,以此來提高工作效率。

在JDK中要使用ForkJoin,需要用到ForkJoinPoll和ForkJoinTask。一般情況下,我們不需要直接繼承ForkJoinTask,在JDK中已經爲我們提供了兩個線程的類:RecursiveAction和RecursiveTask。RecursiveTask是執行後有返回結果,而RecursiveAction執行後沒有返回結果。我們可以看下JDK中的例子,可以很好說明兩種的用法。

class Fibonacci extends RecursiveTask<Integer> {
	final int n;

	Fibonacci(int n) {
		this.n = n;
	}

	@Override
	protected Integer compute() {
		if (n <= 1) {
			return n;
		}
		Fibonacci f1 = new Fibonacci(n - 1);
		f1.fork();
		Fibonacci f2 = new Fibonacci(n - 2);
		return f2.compute() + f1.join();
	}
}

這是一個求斐波那契數列第n項的例子,把計算任務拆分爲多個子任務的求和。比如我們n=4,任務的拆分表現爲圖:

 

再看RecursiveAction的例子是一個數組所有元素加一的例子:

class IncrementTask extends RecursiveAction {
	final long[] array;
	final int lo, hi;

	IncrementTask(long[] array, int lo, int hi) {
		this.array = array;
		this.lo = lo;
		this.hi = hi;
	}

	@Override
	protected void compute() {
		if (hi - lo < THRESHOLD) {
			for (int i = lo; i < hi; ++i) {
				array[i]++;
			}
		} else {
			int mid = (lo + hi) >>> 1;
			invokeAll(new IncrementTask(array, lo, mid),
					new IncrementTask(array, mid, hi));
		}
	}
}

如要要用到想利用到工作竊取,需要使用ForkJoinPoll來執行任務。

在ForkJoinPoll中,每個任務都會再把自身細化爲更小的子任務,工作線程都會優先執行本線程內的雙端隊列任務,可以採用FIFO和LIFO兩種不同的策略,然後纔會隨機的竊取其他隊列中的任務,竊取任務採用FIFO的方式來竊取。ForkJoinPoll中的雙端隊列爲WorkQueue,它只提供了三個操作:push、pop和poll。push和pop操作都是隻能由本線程調用,而poll操作是由竊取線程來調用的。

push操作:

q.array[q.top] = task; ++q.top;

實際中也是通過CAS的方式來修改top和添加任務。

pop操作:

 if ((base != top) and
        (the task at top slot is not null) and
        (CAS slot to null))
        decrement top and return task;

poll操作:

if ((base != top) and
             (the task at base slot is not null) and
            (base has not changed) and
             (CAS slot to null))
                increment base and return task;

也都是通過CAS的方式來保證併發問題。

我們可以看到,實際在調用ForkJoinTask的fork方法時,就是調用了WorkQueue的push方法:

 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;
    }

在push任務後,會根據設置的工作線程數是否已經足夠,來繼續創建更多的工作線程,此時新創建的工作線程則會開始進行工作竊取。

private boolean createWorker() {
        ForkJoinWorkerThreadFactory fac = factory;
        Throwable ex = null;
        ForkJoinWorkerThread wt = null;
        try {
            if (fac != null && (wt = fac.newThread(this)) != null) {
                wt.start();
                return true;
            }
        } catch (Throwable rex) {
            ex = rex;
        }
        deregisterWorker(wt, ex);
        return false;
    }

我們看到實際創建的是ForJoinWorkerThread,他的run方法實際就是進行了工作竊取:

final void runWorker(WorkQueue w) {
        w.growArray();                   // allocate queue
        int seed = w.hint;               // initially holds randomization hint
        int r = (seed == 0) ? 1 : seed;  // avoid 0 for xorShift
        for (ForkJoinTask<?> t;;) {
            if ((t = scan(w, r)) != null)
                w.runTask(t);
            else if (!awaitWork(w, r))
                break;
            r ^= r << 13; r ^= r >>> 17; r ^= r << 5; // xorshift
        }
    }

這裏的scan方法,就是實際進行工作竊取的地方了。

當我們調用ForkJoinTask的join方法時,就是調用了WorkQueue的waitJoin等待任務結束,然後返回結果:

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

 private int doJoin() {
        int s; Thread t; ForkJoinWorkerThread wt; ForkJoinPool.WorkQueue w;
        return (s = status) < 0 ? s :
            ((t = Thread.currentThread()) instanceof ForkJoinWorkerThread) ?
            (w = (wt = (ForkJoinWorkerThread)t).workQueue).
            tryUnpush(this) && (s = doExec()) < 0 ? s :
            wt.pool.awaitJoin(w, this, 0L) :
            externalAwaitDone();
    }

我們挨個方法看,走一遍ForkJoin的大概流程:

首先會調用workQueue的tryUnpush方法,把當前任務移除雙端隊列,然後再調用doExec執行當前任務。

而doExec實際就是調用了我們自己寫的compute方法,如果都執行成功,則會繼續調用ForkJoinPoll的waitJoin方法等待當前任務結束。

final int awaitJoin(WorkQueue w, ForkJoinTask<?> task, long deadline) {
        int s = 0;
        if (task != null && w != null) {
            ForkJoinTask<?> prevJoin = w.currentJoin;
            U.putOrderedObject(w, QCURRENTJOIN, task);
            CountedCompleter<?> cc = (task instanceof CountedCompleter) ?
                (CountedCompleter<?>)task : null;
            for (;;) {
                if ((s = task.status) < 0)
                    break;
                if (cc != null)
                    helpComplete(w, cc, 0);
                else if (w.base == w.top || w.tryRemoveAndExec(task))
                    helpStealer(w, task);
                if ((s = task.status) < 0)
                    break;
                long ms, ns;
                if (deadline == 0L)
                    ms = 0L;
                else if ((ns = deadline - System.nanoTime()) <= 0L)
                    break;
                else if ((ms = TimeUnit.NANOSECONDS.toMillis(ns)) <= 0L)
                    ms = 1L;
                if (tryCompensate(w)) {
                    task.internalWait(ms);
                    U.getAndAddLong(this, CTL, AC_UNIT);
                }
            }
            U.putOrderedObject(w, QCURRENTJOIN, prevJoin);
        }
        return s;
    

如果當前線程的雙端隊列裏任務執行完,即雙端隊列的top等於base時,或者本地任務執行完後,則會進行工作竊取,即調用helpStealer。

可以看出來,ForkJoin框架在fork任務時,會在把任務拆分爲更小的子任務時,根據我們所設置的線程數量創建新的工作線程,而新的工作線程利用工作竊取來獲得任務執行。並且當我們使用join方法時,如果本線程對應的雙端隊列任務執行完成後,也會繼續進行工作竊取,來幫助其他執行較慢的線程快速完成任務。

具體的ForKJoin框架設計非常的複雜,我也只是簡單的梳理了一下大概流程,其中細節還有很多,需要再深入理解纔可以完全掌握。

以上,工作竊取的思想基本就闡述完畢,在實際中也有很多的應用,應用最爲廣泛的就是GC時的併發階段了。大量的region和有限的GC線程,並且每個任務間沒有關聯,剛好符合work steal和overpartition。

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