Fork-Join 模型
fork-join (以下简称 F-J) 首先是一种并行模型,以 fork (开启)分支的方式执行代码逻辑,待全部分支递归执行完毕,join (合并)所有并行分支,而后代码会继续串行执行。从算法角度上来说,特别适合基于分治法的计算。
传统线程池
传统的线程池的实现大致是这样的:
首先生成一个线程池需要指定核心线程数(coreSize)、最大线程数(maxSize)、work queue 阻塞队列初始化、线程创建工厂类(ThreadFactory)以及阻塞队列满员后的丢弃策略等等。
理论上实现了 Runnable 的类实例都能作为 task 被 submit 到线程池里,那么这些任务在接下来的“命运”是怎样的呢?
任务不会马上 offer 到 work queue,而是逐步创建好足够数量(coreSize)的线程作为核心线程(worker thread),只要 worker thread 的数量小于 coreSize ,就继续创建下去,因此最早这批 task 会先和 worker thread 绑定,绑定完成后 worker thread也随之启动(Thread.start())。参考代码:
java.util.concurrent.ThreadPoolExecutor#execute
if (workerCountOf(c) < corePoolSize) {
if (addWorker(command, true))
return;
c = ctl.get();
}
假设 core size 是10,那么线程池 submit 11 个 task后,便只会有 10 个固定线程启动起来,当 worker thread 处理完 task 以后,也并不会马上销毁,而是继续从 work queue 中 poll task 数据。poll 行为本身也是线程安全的,而 queue 是阻塞队列,队列为空时,worker thread 如果此时 poll 则会被阻塞;但当队列中有数据的时候,worker thread 将被唤醒执行下一个 task。
我们看到,worker thread 和 task 是 1:N 的关系,task 并非是真正的线程实例,相对 1:1 而言,这种方式更加轻量;线程池相对于普通线程创建而言,线程数量是可控的;核心线程在执行过程中不会被回收,可以对核心线程资源充分利用,减少了线程上下文切换的成本。
F-J pool
F-J pool 基本特点:
- worker thread 是给定的,由OS直接调度,这点和传统线程池一致。通常,worker thread 会和处理器核心数量保持一致;
- F-J task 都是轻量级执行实例,worker thread 和 task 同样是 1:N 的关系 ;
- 通过 work queue 管理任务,worker thread 执行任务。与传统线程池不同是,传统线程池只会维护一个 work queue;而 F-J pool 中的每个 worker thread 都独立维护一个 work queue。
看看下面的构造方法,一目了然:
/**
* Creates a ForkJoinWorkerThread operating in the given pool.
*
* @param pool the pool this thread works in
* @throws NullPointerException if pool is null
*/
protected ForkJoinWorkerThread(ForkJoinPool pool) {
// Use a placeholder until a useful name can be set in registerWorker
super("aForkJoinWorkerThread");
this.pool = pool;
this.workQueue = pool.registerWorker(this);
}
工作量窃取(work-stealing)
F-J 核心就在于轻量级的调度(相对于传统线程池而言),理解了 work-stealing 就理解了 F-J 的调度算法。
- ForkJoinWorkerThread 就是 F-J pool 中的工作线程,与其对应的 work queue 是一个双端队列(deque),同时具备 FIFO(take/poll 操作) 和 LIFO(push 和 pop 操作) 的特点;
- 对于已经给定的 worker thread A,任务 fork 出来的子任务是会被 push 到 A 的 deque 之中(这里的 fork 只是入栈,是非阻塞的)。由于 deque 被 A 独占,push操作是线程安全的,无需加锁;
- worker thread A 基于 LIFO 规则对 deque pop 操作,从栈顶获取执行任务并执行。由于 deque 被 A独占, pop 操作也是线程安全,无需加锁;
- 当 worker thread 本地 deque 没有任务可以执行的时候,会随机在其他worker thread 的 deque中,基于 FIFO 规则在deque 尾部 take/poll(窃取)一个任务执行,这里是需要加锁的;
- worker thread A 执行到 fork 操作,push 到 deque,非阻塞;A 执行到 join 操作,原则上说 join 操作没有返回值以前,之后的逻辑都无法执行,但是为了提高线程利用率,这里的 A 也是非阻塞的,直到 A 被告知拿到了 join 的递归结果,join 后的操作才会继续执行;
- 当 worker thread 无法从其他线程中获取任务,线程就会正式进入到阻塞状态。
根据 work-stealing 模型我们可以知道,对于常规调度(非窃取调度)来说,是不需要加锁的,相较于传统线程池轻量不少;另外,先进入 deque 的任务被"窃取"的优先级越高。仔细想想这种设计十分巧妙。个人理解,“大任务”优先被窃取执行,好处在于更快速地将细化后的“小任务”平摊在各个工作线程上,这样就能够在较短时间内使线程池达到满载,充分利用了并行计算资源;
另外,大任务的优先窃取也能保证 work deque 尽可能丰满,因为这样可以减少窃取这种相对而言比较重的操作频次;反之,如果优先窃取小任务,而小任务理论上很少几率会被分解,那么一个 worker thread 窃取其他 deque 的频次会高许多,调度成本会增加。
如何简单实现一个F-J求和?
递归分支算法该怎么用?Doug Lea 给出了一个伪代码:
Result solve(Problem problem) {
if (problem is small)
directly solve problem
else {
split problem into independent parts
fork new subtasks to solve each part
join all subtasks
compose result from subresults
}
}
根据大神的指导思想,我写了一个简单的求和任务
public class Sum extends RecursiveTask<Integer> {
private final int start;
private final int end;
Sum(int start, int end) {
this.start = start;
this.end = end;
}
@Override
protected Integer compute() {
if (end - start < 10) {
// 当计算元素少于10的时候,那么这个任务足够小,不需要继续拆分
int sum = 0;
for (int i = start; i <= end; i++) {
sum += i;
}
return sum;
} else {
int middle = (start + end) / 2;
Sum left = new Sum(start, middle);
Sum right = new Sum(middle + 1, end);
left.fork();
right.fork();
return left.join() + right.join();
}
}
}
把任务丢到F-J pool执行
@Test
public void sumTest() throws ExecutionException, InterruptedException {
ForkJoinPool pool = new ForkJoinPool();
Future<Integer> value = pool.submit(new Sum(0, 1000));
// 打印结果 500500
System.out.println(value.get());
}
当计算量不够大的时候,这样拆分计算没有实际意义,计算速度并不一定会比单线程直接算要好,毕竟这里存在线程上下文切换成本。
parallelStream
除了递归分治计算场景,我在 parallelStream 中发现了 F-J pool 的影子。但是这里却没有用到递归 RecursiveTask。
参考
java.util.stream.ForEachOps.ForEachTask
// Similar to AbstractTask but doesn't need to track child tasks
public void compute() {
Spliterator<S> rightSplit = spliterator, leftSplit;
long sizeEstimate = rightSplit.estimateSize(), sizeThreshold;
if ((sizeThreshold = targetSize) == 0L)
targetSize = sizeThreshold = AbstractTask.suggestTargetSize(sizeEstimate);
boolean isShortCircuit = StreamOpFlag.SHORT_CIRCUIT.isKnown(helper.getStreamAndOpFlags());
boolean forkRight = false;
Sink<S> taskSink = sink;
ForEachTask<S, T> task = this;
while (!isShortCircuit || !taskSink.cancellationRequested()) {
if (sizeEstimate <= sizeThreshold ||
(leftSplit = rightSplit.trySplit()) == null) {
task.helper.copyInto(taskSink, rightSplit);
break;
}
ForEachTask<S, T> leftTask = new ForEachTask<>(task, leftSplit);
task.addToPendingCount(1);
ForEachTask<S, T> taskToFork;
if (forkRight) {
forkRight = false;
rightSplit = leftSplit;
taskToFork = task;
task = leftTask;
}
else {
forkRight = true;
taskToFork = leftTask;
}
taskToFork.fork();
sizeEstimate = rightSplit.estimateSize();
}
task.spliterator = null;
task.propagateCompletion();
}
从源码可以看出,compute 过程中只有fork 没有 join,fork 即是将任务入栈,这是因为这里不需要拿到合并计算的结果,只是需要将集合中每个元素分别独立且并行执行指定逻辑,在某些场景下,这种方式会比声明一个传统线程池直观清晰许多,也能节省不少代码。
另外,parallelStream 也不用像传统线程池那样需要 CountDownLatch 做线程协同,这是因为主线程(main)参与到了调度工作,而不会在 forEach 结束前去执行 forEach 之后的逻辑。
小结
F-J pool 比起传统线程池而言,调度更轻量了许多,毕竟相比于传统只有一个 work queue 而言,多个 deque 的出现减少了并发冲突,甚至在一般的调度中是线程安全且无需加锁的