スレッドプールとフォーク/ジョインの最終的な目標は同じです。どちらも、利用可能なCPUパワーを最大限に活用して、最大のスループットを実現したいと考えています。最大スループットとは、できるだけ多くのタスクを長期間で完了する必要があることを意味します。それには何が必要ですか?(以下では、計算タスクが不足していないと仮定します:CPU使用率が100%の場合は常に十分です。さらに、ハイパースレッディングの場合は、コアまたは仮想コアに「CPU」を同等に使用します)。
- 実行するCPUの数と同じ数のスレッドを実行する必要があります。実行するスレッドが少なくなると、コアが未使用のままになるためです。
- 使用可能なCPUの数と同じ数のスレッドが実行されている必要があります。実行するスレッドが増えると、CPUを別のスレッドに割り当てるスケジューラに追加の負荷がかかり、CPU時間が計算タスクではなくスケジューラに送られるようになります。
したがって、最大のスループットを得るには、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);
}
}
ここに表示されるのは、3つのステップA、B、Cで構成されるアルゴリズムです。AとBは互いに独立して実行できますが、ステップCにはステップAとBの結果が必要です。スレッドプールとタスクbを直接実行します。その後、スレッドはタスクAが完了するまで待機し、ステップCに進みます。AとBが同時に完了している場合は、すべて正常です。しかし、AがBよりも時間がかかるとどうなりますか?これは、タスクAの性質がそれを指示するためである可能性がありますが、タスクAのスレッドが最初に使用可能でなく、タスクAが待機する必要があるためである場合もあります。(CPUが1つしかなく、スレッドプールに1つのスレッドしかない場合、これによりデッドロックが発生しますが、今のところそれは問題です。)ポイントは、タスクBを実行したスレッドがスレッド全体をブロックします。CPUと同じ数のスレッドがあり、1つのスレッドがブロックされているため、1つのCPUがアイドル状態になります。
フォーク/ジョインはこの問題を解決します。フォーク/ジョインフレームワークでは、次のように同じアルゴリズムを記述します。
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使用率を達成できます。
ただし、トラップがあります。ワークスティーリングはs のjoin
呼び出しに対してのみ可能ですForkJoinTask
。別のスレッドを待機したり、I / Oアクションを待機したりするなど、外部のブロックアクションに対しては実行できません。では、I / Oが完了するのを待つのは一般的なタスクでしょうか。この場合、追加のスレッドをFork / Joinプールに追加できれば、ブロックアクションが完了するとすぐに停止し、2番目に最適な方法になります。そして、s ForkJoinPool
を使用している場合、実際にそれを行うことができManagedBlocker
ます。
フィボナッチ
RecursiveTaskのJavaDocには、フォーク/ジョインを使用してフィボナッチ数を計算する例があります。従来の再帰的ソリューションについては、以下を参照してください。
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の場合にのみ分割されるため、サブタスクをはるかに小さく保ちます。つまり、実行するメソッド呼び出しが100を大幅に超えることになります(n > 10
)、待機しているmanタスクはほとんどありません(getSurplusQueuedTaskCount() < 2
)。
私のコンピューター(4コア(ハイパースレッディングを数えると8)、Intel(R)Core(TM)i7-2720QM CPU @ 2.20GHz)では、 fib(50)
、クラシックアプローチでは64秒、フォーク/ジョインアプローチではわずか18秒かかります。理論的に可能な限りではありませんが、かなり顕著な利得です。
概要
- はい、あなたの例では、Fork / Joinは従来のスレッドプールに勝るものはありません。
- フォーク/ジョインは、ブロッキングが関係している場合にパフォーマンスを大幅に向上させることができます
- フォーク/ジョインはいくつかのデッドロック問題を回避します