前言
本文隶属于专栏《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.newWorkStealingPool
是ForkJoinPool.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.commonPool
和Executors.newWorkStealingPool
都使得我们比单线程方法更快地确定了素数。
fork/join 框架允许我们将任务分解为子任务。
我们将 10000 个整数的集合分为 1-100、101-200、201-300 等批次。
然后,我们确定了每批的素数,并使用我们的 noOfPrimeNumbers
方法提供了素数总数。
5.3 窃取工作进行计算
有了同步线程池,ForkJoinPool.commonPool
只要任务仍在进行中,就会将线程放入池中。
因此,工作窃取的程度并不取决于任务粒度水平。
异步Executors.newWorkStealingPool
的管理更强,允许工作窃取级别取决于任务粒度级别。
我们使用ForkJoinPool
类的 getStealCount
获得工作窃取级别:
long steals = forkJoinPool.getStealCount();
确定Executors.newWorkStealingPool
和ForkJoinPool.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 框架应用它。
我们还研究了工作窃取的例子,以及它如何改善处理时间和资源的使用。