web-dev-qa-db-ja.com

fork / joinフレームワークはスレッドプールよりもどのように優れていますか?

新しい fork/join framework を使用する利点は何ですか?最初に大きなタスクを単にN個のサブタスクに分割し、キャッシュされたスレッドプールに送信するだけです( Executors から)そして各タスクが完了するのを待っていますか? fork/join抽象化を使用することで問題が単純化されるか、何年も前から持っていたものからソリューションをより効率的にする方法がわかりません。

たとえば、 チュートリアルの例 の並列化されたぼかしアルゴリズムは、次のように実装できます。

public class Blur implements Runnable {
    private int[] mSource;
    private int mStart;
    private int mLength;
    private int[] mDestination;

    private int mBlurWidth = 15; // Processing window size, should be odd.

    public ForkBlur(int[] src, int start, int length, int[] dst) {
        mSource = src;
        mStart = start;
        mLength = length;
        mDestination = dst;
    }

    public void run() {
        computeDirectly();
    }

    protected void computeDirectly() {
        // As in the example, omitted for brevity
    }
}

最初に分割し、タスクをスレッドプールに送信します。

// source image pixels are in src
// destination image pixels are in dst
// threadPool is a (cached) thread pool

int maxSize = 100000; // analogous to F-J's "sThreshold"
List<Future> futures = new ArrayList<Future>();

// Send stuff to thread pool:
for (int i = 0; i < src.length; i+= maxSize) {
    int size = Math.min(maxSize, src.length - i);
    ForkBlur task = new ForkBlur(src, i, size, dst);
    Future f = threadPool.submit(task);
    futures.add(f);
}

// Wait for all sent tasks to complete:
for (Future future : futures) {
    future.get();
}

// Done!

タスクはスレッドプールのキューに移動し、ワーカースレッドが利用可能になると、そこから実行されます。分割が十分に細かく(特に最後のタスクを待つ必要がないように)、スレッドプールに十分な(少なくともN個のプロセッサ)スレッドがある限り、すべてのプロセッサは計算が完了するまでフルスピードで動作します。

何か不足していますか? fork/joinフレームワークを使用することの付加価値は何ですか?

123
Joonas Pulakka

基本的な誤解は、フォーク/結合の例が[〜#〜] not [〜#〜] show work stealingであるが、ある種の標準的な除算と征服する。

ワークスチールは次のようになります。ワーカーBは作業を終了しました。彼は親切な人なので、周りを見渡してみると、労働者Aがまだ一生懸命働いているのが見えます。彼は歩き回って、「ねえ、私はあなたに手を差し伸べることができます」と尋ねます。返信。 「クール、私は1000ユニットのこのタスクを持っています。これまでに655を残して345を終了しました。番号673から1000で作業してください。346から672を実行します。」 Bは「OK、始めましょう。早くパブに行こう」と言っています。

ご存知のように、労働者は実際の作業を開始したときでも、相互に通信する必要があります。これは、例の欠落部分です。

一方、例は「下請業者を使用する」ようなものだけを示しています。

労働者A:「ダン、私は1000単位の仕事をしている。私にとっては多すぎる。私は自分で500をやり、他の誰かに500を下請けする」これは、大きなタスクがそれぞれ10ユニットの小さなパケットに分割されるまで続きます。これらは利用可能なワーカーによって実行されます。しかし、1つのパケットが一種のポイズンピルであり、他のパケットよりもかなり長い時間がかかる場合(不運)、分割フェーズは終了します。

Fork/Joinと前もってタスクを分割することの唯一の残りの違いは次のとおりです。前もって分割する場合、作業キューは最初からいっぱいになります。例:1000単位、しきい値は10であるため、キューには100エントリがあります。これらのパケットは、スレッドプールメンバーに配信されます。

Fork/Joinはより複雑で、キュー内のパケット数をより小さくしようとします。

  • ステップ1:(1 ... 1000)を含む1つのパケットをキューに入れる
  • ステップ2:1人のワーカーがパケット(1 ... 1000)をポップし、2つのパケット(1 ... 500)と(501 ... 1000)に置き換えます。
  • ステップ3:1人のワーカーがパケット(500 ... 1000)をポップし、(500 ... 750)および(751 ... 1000)をプッシュします。
  • ステップn:スタックには次のパケットが含まれます:(1..500)、(500 ... 750)、(750 ... 875)...(991..1000)
  • ステップn + 1:パケット(991..1000)がポップされ、実行されます
  • ステップn + 2:パケット(981..990)がポップされ、実行されます
  • ステップn + 3:パケット(961..980)はポップされ、(961 ... 970)と(971..980)に分割されます。 ....

ご覧のとおり、Fork/Joinではキューが小さく(この例では6)、「分割」フェーズと「作業」フェーズが交互に配置されています。

複数のワーカーが同時にポップしてプッシュする場合、相互作用はもちろんそれほど明確ではありません。

129
A.H.

N個のビジースレッドがすべて独立して100%離れて作業している場合、それはFork-Join(FJ)プールのn個のスレッドよりも優れています。しかし、そのようにはうまくいきません。

問題を正確にn個に分割できない場合があります。たとえそれを行ったとしても、スレッドのスケジューリングはある程度公平です。一番遅いスレッドを待つことになります。複数のタスクがある場合、各タスクはnウェイ未満の並列性で実行できます(一般的にはより効率的ですが、他のタスクが終了するとnウェイになります。

それでは、問題をFJサイズに分割して、スレッドプールを機能させてみませんか。典型的なFJの使用法は、問題を小さな断片に切り分けます。これらをランダムな順序で実行するには、ハードウェアレベルで多くの調整が必要です。オーバーヘッドは致命的です。 FJでは、タスクはスレッドが後入れ先出しの順序(LIFO /スタック)で読み取るキューに入れられ、作業のスチール(コア作業では一般的に)は先入れ先出し(FIFO/"キュー")で行われます。その結果、長い配列の処理は、小さなチャンクに分割されていても、ほとんど連続して実行できます。 (問題を1つのビッグバンで均等なサイズの小さなチャンクに分割することは簡単ではない場合もあります。バランスを取らずに何らかの形式の階層を扱うとしましょう。)

結論:FJでは、不均等な状況でハードウェアスレッドをより効率的に使用できます。これは、複数のスレッドがある場合に常に発生します。

25

スレッドプールとFork/Joinの最終的な目標は似ています。どちらも、最大のスループットを得るために、利用可能なCPUパワーを最大限に活用したいと考えています。最大スループットとは、可能な限り多くのタスクを長期間にわたって完了する必要があることを意味します。それには何が必要ですか? (以下では、計算タスクが不足していないことを前提としています。100%のCPU使用率で十分です。さらに、ハイパースレッディングの場合、コアまたは仮想コアにも同等に「CPU」を使用します)。

  1. 少なくとも、使用可能なCPUと同数のスレッドを実行する必要があります。実行するスレッドが少ないと、コアが未使用のままになるためです。
  2. より多くのスレッドを実行すると、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の結果が必要です。このアルゴリズムはタスクAを送信しますスレッドプールとタスクbを直接実行します。その後、スレッドはタスクAも実行されるのを待ち、ステップCに進みます。AとBが同時に完了した場合、すべてが正常です。しかし、AがBよりも時間がかかる場合はどうでしょうか?これは、タスクAの性質がそれを指示しているためかもしれませんが、タスクAのスレッドが最初に使用可能でなく、タスクAが待機する必要がある場合もあります。 (使用可能なCPUが1つしかないため、スレッドプールにスレッドが1つしかない場合、これはデッドロックを引き起こすことさえありますが、現時点ではそれは重要ではありません)。ポイントは、タスクBを実行したばかりのスレッドがスレッド全体をブロックすることです。 CPUと同じ数のスレッドがあり、1つのスレッドがブロックされているため、1つの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_がブロックしないことです。代わりに、ここでwork-stealingが作用します:スレッドは、過去に分岐された他のタスクを探し、それらを継続します。最初に、分岐したタスク自体が処理を開始したかどうかを確認します。したがって、Aが別のスレッドによってまだ開始されていない場合は、次にAを実行します。そうでない場合は、他のスレッドのキューをチェックし、作業を盗みます。別のスレッドのこの他のタスクが完了すると、Aが現在完了しているかどうかを確認します。上記のアルゴリズムの場合、stepCを呼び出すことができます。それ以外の場合は、盗むためにさらに別のタスクを探します。したがって、fork/joinプールは、ブロックアクションに直面しても100%のCPU使用率を達成できます。

ただし、トラップがあります。ワークスティールは、joinsのForkJoinTask呼び出しに対してのみ可能です。別のスレッドの待機やI/Oアクションの待機などの外部ブロックアクションに対しては実行できません。それでは、I/Oの完了を待つのは一般的なタスクですか?この場合、Fork/Joinプールに追加のスレッドを追加できれば、ブロックアクションが完了するとすぐに再び停止するのが2番目に良い方法です。 ForkJoinPoolは、ManagedBlockersを使用している場合に実際に実行できます。

フィボナッチ

RecursiveTaskのJavaDoc は、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の場合にのみ分割されるため、はるかに小さくなります。つまり、実行するメソッド呼び出しが100回をはるかに超え(_n > 10_)、まだ非常にマンタスクがありません待機中(getSurplusQueuedTaskCount() < 2)。

私のコンピューター(4コア(ハイパースレッディングをカウントする場合は8)、Intel(R)Core(TM)i7-2720QM CPU @ 2.20GHz)では、fib(50)は従来のアプローチで64秒、わずか18秒かかりますFork/Joinアプローチを使用すると、かなりのゲインが得られますが、理論的には可能な限りではありません。

概要

  • はい、あなたの例では、Fork/Joinは従来のスレッドプールよりも利点がありません。
  • Fork/Joinは、ブロッキングが関係する場合にパフォーマンスを大幅に改善できます
  • Fork/Joinはいくつかのデッドロック問題を回避します
15
yankee

フォーク/結合は、ワークスチールを実装するため、スレッドプールとは異なります。 From Fork/Join

他のExecutorServiceと同様に、fork/joinフレームワークは、タスクをスレッドプール内のワーカースレッドに分散します。 fork/joinフレームワークは、ワークスティーリングアルゴリズムを使用するため、明確です。やるべきことを使い果たしたワーカースレッドは、まだビジーな他のスレッドからタスクを盗むことができます。

2つのスレッドと、それぞれ1、1、5、6秒かかる4つのタスクa、b、c、dがあるとします。最初に、aとbはスレッド1に、cとdはスレッド2に割り当てられます。スレッドプールでは、これには11秒かかります。 fork/joinでは、スレッド1が終了し、スレッド2から作業を盗むことができるため、タスクdはスレッド1によって実行されることになります。スレッド1はa、b、d、スレッド2はcだけを実行します。全体時間:11秒ではなく8秒。

編集:Joonasが指摘するように、タスクは必ずしもスレッドに事前に割り当てられるわけではありません。 fork/joinのアイデアは、スレッドがタスクを複数のサブピースに分割することを選択できるということです。したがって、上記を再言するには:

2つのタスク(ab)と(cd)があり、それぞれ2秒と11秒かかります。スレッド1はabの実行を開始し、2つのサブタスクaおよびbに分割します。同様に、スレッド2では、2つのサブタスクcとdに分割されます。スレッド1がaとbを完了すると、スレッド2からdを盗むことができます。

13
Matthew Farwell

上記のすべての人が正しいことは、仕事を盗むことによって利益が達成されることですが、これがなぜなのかを拡大することです。

主な利点は、ワーカースレッド間の効率的な調整です。作業は分割して再組み立てする必要があり、調整が必要です。上記のA.Hの回答でわかるように、各スレッドには独自のワークリストがあります。このリストの重要な特性は、ソートされていることです(上部に大きなタスク、下部に小さなタスク)。各スレッドは、リストの下部にあるタスクを実行し、他のスレッドリストの上部からタスクを盗みます。

この結果は次のとおりです。

  • タスクリストの先頭と末尾は独立して同期できるため、リスト上の競合が減少します。
  • 作業の重要なサブツリーは同じスレッドによって分割および再構築されるため、これらのサブツリーにスレッド間の調整は必要ありません。
  • スレッドが動作を盗むとき、それはそれ自身のリストに細分化する大きな部分を取ります
  • 加工鋼は、プロセスの最後までスレッドがほぼ完全に利用されることを意味します。

スレッドプールを使用する他のほとんどの分割統治方式では、より多くのスレッド間通信と調整が必要です。

12
iain

この例では、フォークは不要であり、ワークロードはワーカースレッド間で均等に分割されるため、Fork/Joinは値を追加しません。 Fork/Joinはオーバーヘッドのみを追加します。

これが Nice article です。見積もり:

全体として、ワークロードがワーカースレッド間で均等に分割される場合、ThreadPoolExecutorが優先されると言えます。これを保証するには、入力データがどのように見えるかを正確に知る必要があります。対照的に、ForkJoinPoolは入力データに関係なく良好なパフォーマンスを提供するため、非常に堅牢なソリューションです。

11
volley

もう1つの重要な違いは、F-Jを使用すると、複数の複雑な「結合」フェーズを実行できることです。 http://faculty.ycp.edu/~dhovemey/spring2011/cs365/lecture/lecture18.html からのマージソートを検討してください。この作業を事前に分割するにはオーケストレーションが多すぎます。例えば次のことを行う必要があります。

  • 第1四半期を並べ替える
  • 第二四半期を並べ替える
  • 最初の2四半期を統合する
  • 第3四半期を並べ替える
  • 第4四半期を並べ替える
  • 過去2四半期を統合する
  • 2つの半分をマージ

それらに関係するマージなどの前にソートを行う必要があることをどのように指定しますか。

私は、アイテムのリストごとに特定のことを行うのに最適な方法を検討してきました。リストを事前に分割し、標準のThreadPoolを使用するだけだと思います。 F-Jは、作業を十分に独立したタスクに事前に分割することはできませんが、それらの間で独立したタスクに再帰的に分割できる場合に最も便利なようです(たとえば、半分を並べ替えることは独立していますが、2つの並べ替えられた半分を並べ替えられた全体にマージすることはそうではありません)。

8
ashirley

F/Jには、コストのかかるマージ操作がある場合にも明確な利点があります。ツリー構造に分割されるため、線形スレッド分割によるnマージとは対照的に、log2(n)マージのみを実行します。 (これは、スレッドと同じ数のプロセッサがあるという理論上の仮定を行いますが、それでも利点があります)宿題の割り当てでは、各インデックスの値を合計して数千の2D配列(すべて同じ次元)をマージする必要がありました。 fork joinとPプロセッサでは、Pが無限に近づくにつれて時間がlog2(n)に近づきます。

1 2 3 .. 7 3 1 .... 8 5 4
4 5 6 + 2 4 3 => 6 9 9
7 8 9 .. 1 1 0 .... 8 9 9

6
Daemon Fisher

他のスレッドが完了するのを待つ必要があるような問題がある場合(配列または配列の合計のソートの場合など)、fork joinを使用する必要があります。Executor(Executors.newFixedThreadPool(2))は制限のためにチョークしますスレッドの数。この場合、forkjoinプールはより多くのスレッドを作成し、ブロックされたスレッドを隠蔽して同じ並列性を維持します

出典:http://www.Oracle.com/technetwork/articles/Java/fork-join-422606.html

分割統治アルゴリズムを実装するためのエグゼキューターの問題は、サブタスクの作成とは関係ありません。Callableは新しいサブタスクをそのエグゼキューターにサブミットし、同期または非同期の方法で結果を待つためです。問題は並列処理の問題です。Callableが別のCallableの結果を待機すると、待機状態になり、実行待ちの別のCallableを処理する機会が無駄になります。

Doug Leaの努力によりJava.util.concurrentパッケージに追加されたfork/joinフレームワークがJava SE 7でギャップを埋めます

出典:https://docs.Oracle.com/javase/7/docs/api/Java/util/concurrent/ForkJoinPool。 html

プールは、一部のタスクが他のタスクへの参加を待機している場合でも、内部ワーカースレッドを動的に追加、一時停止、または再開することにより、十分なアクティブ(または使用可能な)スレッドを維持しようとします。ただし、ブロックされたIOまたはその他のアンマネージド同期に直面した場合、そのような調整は保証されません

public int getPoolSize()開始されたがまだ終了していないワーカースレッドの数を返します。 このメソッドによって返される結果は、他が協調的にブロックされている場合に並列性を維持するためにスレッドが作成されるとき、getParallelism()と異なる場合があります。

2
V S

クローラーのようなアプリケーションでのForkJoinのパフォーマンスには驚くでしょう。ここからが最高です tutorial あなたが学ぶでしょう。

Fork/Joinのロジックは非常に単純です。(1)各大きなタスクを小さなタスクに分離(フォーク)します。 (2)各タスクを個別のスレッドで処理します(必要に応じてそれらをさらに小さなタスクに分離します)。 (3)結果に参加します。

2
danielad

長い回答を読む時間があまりない人のために、短い回答を追加したいと思います。比較は、Applied Akka Patternsの本から取られています。

Fork-join-executorとthread-pool-executorのどちらを使用するかは、そのディスパッチャーの操作がブロックされるかどうかに大きく依存します。 fork-join-executorは最大数のアクティブスレッドを提供しますが、thread-pool-executorは固定数のスレッドを提供します。スレッドがブロックされると、fork-join-executorはさらに作成しますが、thread-pool-executorは作成しません。ブロッキング操作の場合、スレッドカウントの爆発を防ぐため、一般的にthread-pool-executorを使用する方が適切です。 fork-join-executorでは、より多くの「リアクティブ」操作が優れています。

1
Vadim S.