分支/合并框架的目的是以递归方式将可以并行的任务拆分成更小的任务,然后将每个子任务的结果合并起来生成整体结果。他是ExecutorService接口的一个实现,它把子任务分配给线程池(称为ForkJoinPool)中的工作线程。
使用 RecursiveTask
要把任务提交到这个池,必须创建RecursiveTask的一个子类,其中R是并行化任务(以及所有子任务)产生的结果类型,或者如果任务不返回结果,则是RecursiveAction类型(当然它可能会更新其他非局部结构)。
protected abstract R compute();
这个方法同时定义了将任务拆分成子任务的逻辑,以及无法再拆分或不方便拆分时,生成单个子任务结果的逻辑。
伪代码:
if (任务足够小或不可分) {
顺序计算该任务
} else {
将任务分成两个子任务
递归调用本方法,拆分每个子任务,等待所有子任务完成
合并每个子任务的结果
}
这只不过是著名的分支算法的并行版本而已。
import java.util.concurrent.RecursiveTask;
/**
* 继承RecursiveTask来创建可以用于分支/合并框架的任务
*/
public class ForkJoinSumCalculator extends RecursiveTask<Long> {
// 要求和的数组
private final long[] numbers;
// 子任务处理的数组的起始和终止位置
private final int start;
private final int end;
// 不再将任务分解为子任务的数组大小
public static final long THRESHOLD = 10_000;
// 公共构造函数用于创建主任务
public ForkJoinSumCalculator(long[] numbers) {
this(numbers, 0, numbers.length);
}
// 私有构造函数用于以递归方式为主任务创建子任务
private ForkJoinSumCalculator(long[] numbers, int start, int end) {
this.numbers = numbers;
this.start = start;
this.end = end;
}
// 覆盖RecursiveTask抽象方法
@Override
protected Long compute() {
// 该任务负责求和的部分的大小
int length = end - start;
// 如果大小小于或等于阈值,顺序计算结果
if (length <= THRESHOLD) {
return computeSequentially();
}
// 创建一个子任务来为数组的前一半求和
ForkJoinSumCalculator leftTask =
new ForkJoinSumCalculator(numbers, start, start + length/2);
// 利用另一个ForkJoinPool线程异步执行新创建的子任务
leftTask.fork();
// 创建一个任务为数组的后一半求和
ForkJoinSumCalculator rightTask =
new ForkJoinSumCalculator(numbers, start + length/2, end);
// 同步执行第二个子任务,有可能允许进一步递归划分
Long rightResult = rightTask.compute();
// 读取第一个子任务的结果,如果尚未完成则阻塞等待
Long leftResult = leftTask.join();
// 该任务的结果是两个子任务结果的组合
return leftResult + rightResult;
}
// 在子任务不再可分时,计算结果的简单算法
private long computeSequentially() {
long sum = 0;
for (int i = start; i < end; i++) {
sum += numbers[i];
}
return sum;
}
}
现在编写一个方法来并行对前n个自然数求和,只需要把想要的数字数组传给ForkJoinSumCalculator的构造函数:
public static long forkJoinSum(long n) {
long[] numbers = LongStream.rangeClosed(1, n).toArray();
ForkJoinTask<Long> task = new ForkJoinSumCalculator(numbers);
return new ForkJoinPool().invoke(task);
}
在实际应用时,使用多个ForkJoinPool是没有什么意义的。正是出于这个原因,一般来说把他实例化一次,然后把实例保存在静态字段中,使之成为单例,可以方便重用。
这里创建ForkJoinPool时用了默认的无参构造函数,这意味着想让线程池使用JVM能够使用的所有处理器。更确切的说,该构造函数将使用Runtime.availableProcessors的返回值来决定线程池使用的线程数。请注意availableProcessors方法虽然看起来是处理器,但它实际上返回的是可用内核的数量,包括超线程生成是虚拟内核。
运行 ForkJoinSumCalculator
当把ForkJoinSumCalculator任务传给ForkJoinPool时,这个任务就由池中的一个线程执行,这个线程会调用任务的compute方法。该方法会检查任务是否小到足以顺序执行,如果不够小则会把要求和的数组分成两半,分给两个新的ForkJoinSumCalculator,而它们也由ForkJoinPool安排执行。因此这一过程可以递归重复,把原任务分为更小的任务,直到满足不方便或不可能再进一步拆分的条件(本例中是求和的项目数小于等于10 000。这时会顺序计算每个任务的结果,然后由分支过程创建(隐含的)任务二叉树遍历回到它的根。接下来会计算每个子任务的部分结果,从而得到总任务的结果。
System.out.println(measureSumPerf(ForkJoinSumCalculator::forkJoinSum, 10_000_000) + " msecs");
这个性能看起来比用并行流的版本要差,但这只是因为必须要先把整个数字流都放进一个long[],之后才能在ForkJoinSumCalculator任务中使用它。
使用分支/合并框架的最佳用法
虽然分支/合并框架还算简单易用,但是它也很容易被误用。以下是几个有效使用它的最佳做法。
- 对一个任务调用join方法会阻塞调用方,直到该任务做出结果。因此,有必要在两个子任务的计算都开始之后再调用它。否则,你得到的版本会比原始的顺序算法更慢更复杂,因为每个子任务都必须等待另一个子任务完成才能启动。
- 不应该在RecursiveTask内部使用ForkJoinPool的invoke方法。相反,你应该始终直接调用compute或fork方法,只有顺序代码才应该用invoke来启动并行计算。
- 对于子任务调用fork方法可以把它排进ForkJoinPool,同时对左边和右边的子任务调用它似乎很自然,但这样做的效率要比直接对其中一个调用compute低。这样做你可以为其中一个子任务重用一个线程,从而避免在线程池中多分配一个任务造成的开销。
- 调试使用分支/合并框架的并行计算可能有点棘手。特别是平常喜欢在IDE里面看栈跟踪(stack trace)来找问题,但放在分支-合并计算上就不行了,因为调用compute的线程并不是概念上的调用方,后者是调用fork的那个。
- 和并行流不一样,你不应该理所当然地认为在多核处理器上使用分支/合并框架就比顺序计算快。我们已经说过,一个任务可以分解成多个独立的子任务,才能让性能在并行化时有所提升。所有这些子任务的运行时间都应该比分出新任务所花的时间要长;一个惯用的方法时把输入-输出放在一个子任务里,计算放在另一个里,这样计算就可以和输入输出进行。此外,在比较同一算法的顺序和并行版本的性能时还有别的因素要考虑。就像其他任何Java代码一样,分支-合并框架需要“预热”或者说要执行几遍才会被JIT编译器优化。这就是为什么在测试性能钱跑几遍程序很重要,我们的测试框架就是这么做的。同时还要知道,编译器内置的优化可能为顺序版本带来一些优势(例如执行死码分析——删去从未被使用的计算)。
工作窃取
在ForkJoinSumCalculator的例子中,我们决定在要求和的数组中最多包含10000个项目时就不再创建子任务了。这个选择是很随意的,但大多数情况下也很难找到一个好的启发式方法来确定它,只能试几个不同的值来尝试优化它。
但分出大量的小任务一般来都是一个好的选择。这是因为,理想情况下,划分并行任务时,应该让每个任务都用完全相同的时间完成,让所有的cpu同样繁忙。不幸的是,实际中,每个子任务所花的时间可能天差地别,要么是因为划分效率低,要么是有不可预知的原因,比如磁盘访问慢,或是需要和外部服务协调执行。
分支/合并框架工程用一种称为工作窃取(work stealing)的技术来解决这个问题。
在实际应用中,这意味着这些任务差不多被平均分配到ForkJoinPool中的所有线程上。每个线程都为分配给他的任务保存一个双向链式队列,每完成一个任务,就会从队列头上取出下一个任务开始执行。基于前面所述的原因,某个线程可能早早完成了分配给它的所有任务,也就是它的队列已经空了,而其他的线程还很忙。这时,这个线程并没有闲下来,而是随机选了一个别的线程,从队列的尾巴上”偷走“一个任务。这个过程一直继续下去,直到所有的任务都执行完毕,所有队列都清空。这就是为什么要划成许多小任务而不是少数大任务,这有助于更好地在工作线程之间平衡负载。
一般来说,这种工作窃取算法用于在池中的工作线程之间重新分配和平衡任务。