分支/合并框架

版权声明:本文为博主原创文章,未经博主允许不得转载。 https://blog.csdn.net/CmdSmith/article/details/84874290

分支/合并框架的目的是以递归方式将可以并行的任务拆分成更小的任务,然后将每个子任务的结果合并起来生成整体结果。他是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中的所有线程上。每个线程都为分配给他的任务保存一个双向链式队列,每完成一个任务,就会从队列头上取出下一个任务开始执行。基于前面所述的原因,某个线程可能早早完成了分配给它的所有任务,也就是它的队列已经空了,而其他的线程还很忙。这时,这个线程并没有闲下来,而是随机选了一个别的线程,从队列的尾巴上”偷走“一个任务。这个过程一直继续下去,直到所有的任务都执行完毕,所有队列都清空。这就是为什么要划成许多小任务而不是少数大任务,这有助于更好地在工作线程之间平衡负载。

一般来说,这种工作窃取算法用于在池中的工作线程之间重新分配和平衡任务。

图7-5 分支/合并框架使用的工作窃取算法

猜你喜欢

转载自blog.csdn.net/CmdSmith/article/details/84874290