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。

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