线程池和Fork / Join的最终目标是相同的:两者都想尽可能地利用可用的CPU能力以实现最大吞吐量。最大吞吐量意味着应在尽可能长的时间内完成尽可能多的任务。需要做些什么?(对于以下情况,我们将假定不乏计算任务:对于100%的CPU使用率,总有足够的事情要做。此外,在超线程的情况下,我对内核或虚拟内核等效地使用“ CPU”)。
- 至少需要运行的线程数与可用的CPU数量一样,因为运行更少的线程将使内核不使用。
- 最多运行的线程数必须与可用的CPU数量一样多,因为运行更多的线程会为Scheduler产生额外的负载,Scheduler将CPU分配给不同的线程,这导致一些CPU时间流向了Scheduler而不是我们的计算任务。
因此,我们发现要获得最大吞吐量,我们需要拥有与CPU完全相同的线程数。在Oracle的模糊示例中,您既可以采用固定大小的线程池,而线程数量等于可用CPU的数量,也可以使用线程池。没关系,你是对的!
那么什么时候会遇到线程池问题?那是一个线程阻塞,因为您的线程正在等待另一个任务完成。假设以下示例:
class AbcAlgorithm implements Runnable {
public void run() {
Future<StepAResult> aFuture = threadPool.submit(new ATask());
StepBResult bResult = stepB();
StepAResult aResult = aFuture.get();
stepC(aResult, bResult);
}
}
我们在这里看到的是一个由三个步骤A,B和C组成的算法。A和B可以彼此独立执行,但是步骤C需要步骤A和B的结果。该算法将任务A提交给线程池并直接执行任务b。之后,线程将等待任务A也完成,然后继续执行步骤C。如果A和B同时完成,则一切正常。但是,如果A比B花费更长的时间怎么办?这可能是因为任务A的性质决定了它,但也可能是因为任务A开头没有可用线程,因此任务A需要等待。(如果只有一个CPU可用,因此您的线程池只有一个线程,这甚至会导致死锁,但是现在这还不重要)。关键是刚刚执行任务B的线程阻塞整个线程。由于我们拥有与CPU相同的线程数,并且一个线程被阻塞,这意味着一个CPU处于空闲状态。
Fork / Join解决了这个问题:在fork / join框架中,您将编写相同的算法,如下所示:
class AbcAlgorithm implements Runnable {
public void run() {
ATask aTask = new ATask());
aTask.fork();
StepBResult bResult = stepB();
StepAResult aResult = aTask.join();
stepC(aResult, bResult);
}
}
看起来一样,不是吗?但是,提示是aTask.join
不会阻塞。相反,这里是窃取工作的地方:线程将环顾过去已分叉的其他任务,并将继续执行这些任务。首先,它检查自己分叉的任务是否已经开始处理。因此,如果A尚未由另一个线程启动,它将继续执行A,否则它将检查其他线程的队列并窃取它们的工作。一旦另一个线程的另一个任务完成,它将检查A是否现在完成。如果是以上算法则可以调用stepC
。否则,它将寻找另一个要偷的任务。因此,即使面对阻塞操作,fork / join池也可以实现100%的CPU使用率。
但是有一个陷阱:只能在join
调用ForkJoinTask
s时进行工作窃取。对于外部阻塞操作,例如等待另一个线程或等待I / O操作,无法完成此操作。那么,等待I / O完成是常见的任务吗?在这种情况下,如果我们可以向Fork / Join池中添加一个额外的线程,那么在阻塞操作完成后立即将其再次停止将是第二好的选择。而ForkJoinPool
实际上可以做到这一点,如果我们使用的是ManagedBlocker
秒。
斐波那契
在JavaDoc for RecursiveTask中,有一个使用Fork / Join计算斐波那契数的示例。有关经典的递归解决方案,请参见:
public static int fib(int n) {
if (n <= 1) {
return n;
}
return fib(n - 1) + fib(n - 2);
}
正如在JavaDocs中所解释的那样,这是一种计算斐波纳契数的不错的转储方法,因为该算法具有O(2 ^ n)复杂度,而更简单的方法也是可能的。但是,此算法非常简单且易于理解,因此我们坚持使用它。假设我们想通过Fork / Join加快速度。天真的实现看起来像这样:
class Fibonacci extends RecursiveTask<Long> {
private final long n;
Fibonacci(long n) {
this.n = n;
}
public Long compute() {
if (n <= 1) {
return n;
}
Fibonacci f1 = new Fibonacci(n - 1);
f1.fork();
Fibonacci f2 = new Fibonacci(n - 2);
return f2.compute() + f1.join();
}
}
该任务划分的步骤太短了,因此会可怕地执行,但是您可以看到该框架通常运行得很好:两个求和项可以独立计算,但是我们需要两个都来构建最终结果。因此一半是在另一个线程中完成的。在没有死锁的情况下,对线程池执行相同的操作很有趣(可能,但并非如此简单)。
仅出于完整性考虑:如果您实际上想使用这种递归方法来计算斐波那契数,那么这里是一个优化的版本:
class FibonacciBigSubtasks extends RecursiveTask<Long> {
private final long n;
FibonacciBigSubtasks(long n) {
this.n = n;
}
public Long compute() {
return fib(n);
}
private long fib(long n) {
if (n <= 1) {
return 1;
}
if (n > 10 && getSurplusQueuedTaskCount() < 2) {
final FibonacciBigSubtasks f1 = new FibonacciBigSubtasks(n - 1);
final FibonacciBigSubtasks f2 = new FibonacciBigSubtasks(n - 2);
f1.fork();
return f2.compute() + f1.join();
} else {
return fib(n - 1) + fib(n - 2);
}
}
}
这使子任务小得多,因为只有在n > 10 && getSurplusQueuedTaskCount() < 2
为true 时才将其拆分,这意味着要执行do(n > 10
)的方法调用明显超过100个,并且没有非常多的人工任务在等待(getSurplusQueuedTaskCount() < 2
)。
在我的计算机上(4核(计数超线程时为8核,Intel(R)Core i7-2720QM CPU @ 2.20GHz)),fib(50)
采用经典方法需要64秒,而使用Fork / Join方法只需18秒。尽管在理论上不尽人意,但它是一个相当明显的收益。
摘要
- 是的,在您的示例中,Fork / Join与经典线程池相比没有任何优势。
- 涉及阻塞时,Fork / Join可以大大提高性能
- Fork / Join规避了一些死锁问题