你知道工作窃取算法吗?你知道 Java 中的 fork/join 框架吗?

前言

本文隶属于专栏《100个问题搞定Java并发》,该专栏为笔者原创,引用请注明来源,不足和错误之处请在评论区帮忙指出,谢谢!

本专栏目录结构和参考文献请见100个问题搞定Java并发


1. 概述

本文我们将讨论 Java 中工作窃取的概念。


2. 什么是工作窃取?

在 JDK7 中引入了工作窃取,旨在减少多线程应用程序中的竞争。

这是使用 fork/join 框架完成的。


2.1 分而治之

在 fork/join 框架中,问题或任务被递归分解为子任务。

然后单独解决子任务,子结果组合成结果。


2.2 工作线程

分解任务在线程池提供的工作线程的帮助下解决。

每个工作线程都有它负责的子任务。

这些存储在双端队列(deques)中。

每个工作线程通过不断从 deque 顶部弹出子任务从而在 deque 中获取子任务。

当工作线程的 deque 为空时,这意味着所有子任务都已弹出并完成。

此时,工作线程随机选择一个可以"窃取"工作的对等线程池线程。

然后,它使用先进先出方法(FIFO)从"受害者"的 deque 的尾端获取子任务。


3. fork/join 框架的实现

我们可以使用 ForkJoinPool 类或 Executors 类创建一个工作窃取线程池:

ForkJoinPool commonPool = ForkJoinPool.commonPool();
ExecutorService workStealingPool = Executors.newWorkStealingPool();

Executors类有一个重载的newWorkStealingPool方法,该方法有一个整型参数表示并行级别。

Executors.newWorkStealingPoolForkJoinPool.commonPool的抽象。

唯一的区别是,Executors.newWorkStealingPool在异步模式下创建一个线程池,而ForkJoinPool.commonPool没有。


4. 同步与异步线程池

ForkJoinPool.commonPool使用先进先出(LIFO)队列配置,而Executors.newWorkStealingPool使用先进先出的方法(FIFO)配置。

根据Doug Lea的说法,FIFO 方法比 LIFO 具有以下优势:

Doug Lea 是 JDK7 中 fork/join 框架的实现者,也是 JDK 中 JUC 的核心开发者,真 Java 大神。

  • 它通过让"窃贼"作为所有者在 deque 的另一侧操作来减少竞争。
  • 它利用了早期生成“大型”任务的递归分治算法的性质。

上面的第二点意味着可以通过一个被盗任务的线程可以进一步分解旧的被盗任务。

根据 JDK 官方文档,将 asyncMode 设置为 true 为从未加入的分叉任务建立本地先进先出的调度模式。在工作线程仅处理事件样式异步任务的应用程序中,这个模式可能比默认的基于本地堆栈的模式更合适。

    public ForkJoinPool(int parallelism,
                        ForkJoinWorkerThreadFactory factory,
                        UncaughtExceptionHandler handler,
                        boolean asyncMode) {
        this(checkParallelism(parallelism),
             checkFactory(factory),
             handler,
             asyncMode ? FIFO_QUEUE : LIFO_QUEUE,
             "ForkJoinPool-" + nextPoolId() + "-worker-");
        checkPermission();
    }

5. 实践 – 查找素数

我们使用从数字集合中查找素数的例子来显示工作窃取框架的计算时间的优势。

我们还将展示使用同步和异步线程池之间的区别。


5.1 素数问题

从数字集合中查找素数可能是一个计算成本高的过程。

这主要是由于数字集合的大小。

PrimeNumbers类可以帮助我们找到素数:

public class PrimeNumbers extends RecursiveAction {

    private int lowerBound;
    private int upperBound;
    private int granularity;
    static final List<Integer> GRANULARITIES
      = Arrays.asList(1, 10, 100, 1000, 10000);
    private AtomicInteger noOfPrimeNumbers;

    PrimeNumbers(int lowerBound, int upperBound, int granularity, AtomicInteger noOfPrimeNumbers) {
        this.lowerBound = lowerBound;
        this.upperBound = upperBound;
        this.granularity = granularity;
        this.noOfPrimeNumbers = noOfPrimeNumbers;
    }

    private List<PrimeNumbers> subTasks() {
        List<PrimeNumbers> subTasks = new ArrayList<>();

        for (int i = 1; i <= this.upperBound / granularity; i++) {
            int upper = i * granularity;
            int lower = (upper - granularity) + 1;
            subTasks.add(new PrimeNumbers(lower, upper, noOfPrimeNumbers));
        }
        return subTasks;
    }

    @Override
    protected void compute() {
        if (((upperBound + 1) - lowerBound) > granularity) {
            ForkJoinTask.invokeAll(subTasks());
        } else {
            findPrimeNumbers();
        }
    }

    void findPrimeNumbers() {
        for (int num = lowerBound; num <= upperBound; num++) {
            if (isPrime(num)) {
                noOfPrimeNumbers.getAndIncrement();
            }
        }
    }

    public int noOfPrimeNumbers() {
        return noOfPrimeNumbers.intValue();
    }
}

关于这个类,需要注意的有几点:

  • extends RecursiveAction,它允许我们使用线程池实现计算任务中使用的compute方法
  • 它根据granularity值递归地将任务分解为子任务
  • 构造函数采用下界和上界值,这些值控制我们想要确定素数的数字范围
  • 它使我们能够使用工作窃取线程池或单个线程来确定素数

5.2 使用线程池更快地解决问题

让我们以单线程方式确定素数,并使用工作窃取线程池。

首先,让我们看看单线程方法:

PrimeNumbers primes = new PrimeNumbers(10000);
primes.findPrimeNumbers();

现在,使用ForkJoinPool.commonPool方法:

PrimeNumbers primes = new PrimeNumbers(10000);
ForkJoinPool pool = ForkJoinPool.commonPool();
pool.invoke(primes);
pool.shutdown();

最后,我们来看看Executors.newWorkStealingPool方法:

PrimeNumbers primes = new PrimeNumbers(10000);
int parallelism = ForkJoinPool.getCommonPoolParallelism();
ForkJoinPool stealer = (ForkJoinPool) Executors.newWorkStealingPool(parallelism);
stealer.invoke(primes);
stealer.shutdown();

我们使用ForkJoinPool类的invoke方法将任务传递给线程池。

这种方法使用RecursiveAction子类的实例。

使用 JMH,我们根据每次操作的平均时间对照这些不同的方法进行基准测试:

# Run complete. Total time: 00:04:50

Benchmark                                                      Mode  Cnt    Score   Error  Units
PrimeNumbersUnitTest.Benchmarker.commonPoolBenchmark           avgt   20  119.885 ± 9.917  ms/op
PrimeNumbersUnitTest.Benchmarker.newWorkStealingPoolBenchmark  avgt   20  119.791 ± 7.811  ms/op
PrimeNumbersUnitTest.Benchmarker.singleThread                  avgt   20  475.964 ± 7.929  ms/op

显然,ForkJoinPool.commonPoolExecutors.newWorkStealingPool都使得我们比单线程方法更快地确定了素数。

fork/join 框架允许我们将任务分解为子任务。

我们将 10000 个整数的集合分为 1-100、101-200、201-300 等批次。

然后,我们确定了每批的素数,并使用我们的 noOfPrimeNumbers 方法提供了素数总数。


5.3 窃取工作进行计算

有了同步线程池,ForkJoinPool.commonPool只要任务仍在进行中,就会将线程放入池中。

因此,工作窃取的程度并不取决于任务粒度水平。

异步Executors.newWorkStealingPool的管理更强,允许工作窃取级别取决于任务粒度级别。

我们使用ForkJoinPool类的 getStealCount获得工作窃取级别:

long steals = forkJoinPool.getStealCount();

确定Executors.newWorkStealingPoolForkJoinPool.commonPool的工作窃取计数给我们带来了不同的行为:

Executors.newWorkStealingPool ->
Granularity: [1], Steals: [6564]
Granularity: [10], Steals: [572]
Granularity: [100], Steals: [56]
Granularity: [1000], Steals: [60]
Granularity: [10000], Steals: [1]

ForkJoinPool.commonPool ->
Granularity: [1], Steals: [6923]
Granularity: [10], Steals: [7540]
Granularity: [100], Steals: [7605]
Granularity: [1000], Steals: [7681]
Granularity: [10000], Steals: [7681]

Executors.newWorkStealingPool的粒度从精细变为粗(1到10,000)时,工作窃取水平就会降低。

因此,当任务没有分解时,窃取计数为1(粒度[granularity]为10000)。

ForkJoinPool.commonPool有不同的行为。

工作窃取水平总是很高,受到任务粒度变化的影响并不大。

从技术上讲,我们的素数示例支持异步处理事件式任务。

这是因为我们的实现不会强制结果的加入。

可以证明,Executors.newWorkStealingPool在解决问题时提供了最佳资源利用。


6. 结论

在本文中,我们研究了工作窃取以及如何使用 fork/join 框架应用它。

我们还研究了工作窃取的例子,以及它如何改善处理时间和资源的使用。

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值