一项计算可以拆成独立的小块,最后合并结果时,可以考虑 Fork/Join。它更适合分治计算,不是把任意慢操作放进去就能变快。

拆分求和任务

RecursiveTask 表示有返回值的任务,RecursiveAction 则没有返回值。下面把一个整数区间拆成两半:

import java.util.concurrent.ForkJoinPool;
import java.util.concurrent.RecursiveTask;

public class ForkJoinSumDemo {
    private static class Sum extends RecursiveTask<Long> {
        private final int from;
        private final int to;

        Sum(int from, int to) {
            this.from = from;
            this.to = to;
        }

        @Override
        protected Long compute() {
            if (to - from <= 1000) {
                long result = 0;
                for (int i = from; i <= to; i++) result += i;
                return result;
            }
            int middle = from + (to - from) / 2;
            Sum left = new Sum(from, middle);
            Sum right = new Sum(middle + 1, to);
            left.fork();
            long rightResult = right.compute();
            return left.join() + rightResult;
        }
    }

    public static void main(String[] args) {
        ForkJoinPool pool = new ForkJoinPool(2);
        try {
            System.out.println(pool.invoke(new Sum(1, 10000)));
        } finally {
            pool.shutdown();
        }
    }
}

输出 50005000。左半部分 fork 出去,当前线程直接计算右半部分,最后 join 左侧结果。两个区间互不重叠,合并时只需要相加。

工作窃取

工作线程有各自的任务队列。默认模式下,线程处理自己产生的任务通常偏向最近加入的一端;其他空闲线程可以从另一端取得任务,这就是工作窃取。

这种组织方式既利用了当前计算的局部性,也能把剩余工作交给空闲线程。join 等待子任务时,框架还可能协助执行任务,并不只是像普通阻塞等待那样停在那里。

但这不意味着任意相互等待都安全。任务间有环形依赖,或者阻塞在框架不了解的外部调用上,仍然会出问题。

拆得多细

阈值太小,会创建和调度大量小任务,开销可能超过计算本身;阈值太大,又可能没有足够任务供线程并行执行。

这里把阈值设为 1000,方便看到任务拆分的过程。这组数据量很小,计算又简单,拆分和调度的开销可能比直接循环求和还大。

实际要测量任务量、拆分成本和合并成本,而且最好让任务处理互不重叠的数据,避免计算中又争抢同一把锁。

阻塞与公共池

长时间的网络、文件阻塞不适合直接当成普通分治计算放进去。ManagedBlocker 能向池报告某些阻塞情况,普通阻塞代码却不会自动获得等价的补偿保证。

Java 8 的 parallelStream 通常也会使用公共 ForkJoinPool。若同时把很多耗时任务交给公共池,会与其他使用者互相影响。需要控制资源时,应该先明确任务的执行器和隔离方式,再决定是否使用并行操作。

最后修改:2018 年 05 月 02 日
如果觉得我的文章对你有用,请随意赞赏