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

新しいフォーク/結合フレームワークを使用する利点は何ですか?大きなタスクを最初に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 futures = new ArrayList();

// 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個のプロセッサー)スレッドがある限り、すべてのプロセッサーは計算全体が完了するまで最高速度で動作します。

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

103

10 答え

私は基本的な誤解は、フォーク/結合の例は盗むの作品を表示するのではなく、標準的な分割と征服のいくつかの種類のみを表示することではないと思います。

仕事の盗みはこうなるでしょう:労働者Bは仕事を終えました。彼は親切な人です。だから彼は見て、ワーカーAはまだ非常に懸命に働いていると見ています。彼は散歩して尋ねる:「ねえ、私はあなたに手を差し伸べることができる」 Aが答えます。 "クール、私は1000ユニットのこのタスクを持っています。これまで私は655を残して345を終了しました。あなたは673番から6700番まで作業してください。 Bさんは「OK、先にパブに行くことができるように始めましょう」と言っています。

あなたは、実際の仕事を始めた時でも、お互いの間でコミュニケーションしなければならないことが分かります。これは例の欠けている部分です。

もう一方の例では、「下請けを使用する」のようなものしか表示されません。

労働者A:「ダン、私には1000単位の仕事があります。私にはあまりにも多くのことをします。私は500人、500人は他の人に下請けします。これは、大きなタスクが10個ずつの小さなパケットに分解されるまで続きます。これらは利用可能な労働者によって実行されます。しかし、1つのパケットが毒薬の一種であり、他のパケットよりもかなり長い時間がかかる場合 - 不運、分割フェーズが終了しました。

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

フォーク/結合はより複雑で、キュー内のパケット数を小さく保つよう試みます。

  • ステップ1:(1 ... 1000)を含む1つのパケットをキューに入れる
  • ステップ2:1人のワーカーがパケット(1〜1000)をポップし、(1〜500)と(501〜1000)の2つのパケットに置き換えます。
  • ステップ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)、「split」と「work」のフェーズがインターリーブされています。

複数の作業者が同時にポップしたり押したりしている場合、相互作用はあまり明確ではありません。

114
追加された
私はこれが確かに答えだと思います。実際のFork/Joinの例がどこにあるのかわかりません。基本的な例では、ユニットのサイズ(例えば、配列の長さ)から作業負荷の量をかなり完全に予測することができるので、前もっての分割が容易である。盗みは確かに、ユニットあたりの作業量がユニットのサイズから十分に予測できない という問題に違いを生むでしょう。
追加された 著者 Joonas Pulakka,
@Marc:申し訳ありませんが、例はありません。
追加された 著者 A.H.,
作業の盗難が便利な場所の例: h-online.com/developer/features/&hellip
追加された 著者 volley,
Oracleの例であるIMOの問題は、(A.H.に記述されているように)仕事窃盗を実証しているわけではなく、(Joonasが行ったように)単純なThreadPoolのアルゴリズムをコーディングするのは簡単だということです。 F-Jは、作業を十分に独立したタスクに事前に分割することはできませんが、再帰的に独立したタスクに分割することができます。例については私の答えを見てください
追加された 著者 Edson Medina,
答えが正しければ、それはどのように説明されていません。 Oracleによって与えられた例では、仕事を盗むことはありません。ここで説明している例のように、forkとjoinはどのように動作しますか?あなたがフォークを作成し、それを記述する方法でスチール作業に参加するいくつかのJavaコードを表示できますか?ありがとう
追加された 著者 Marc,

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

問題を正確にn個の等分に分割することはできないかもしれません。たとえあなたがそうしたとしても、スレッドスケジューリングは、ある意味では公正ではありません。あなたは最も遅いスレッドを待つことになります。複数のタスクをお持ちの場合は、それぞれがnウェイ並列(通常はより効率的)未満で実行できますが、他のタスクが終了するとnウェイになります。

それでは、問題をFJサイズの部分に分割してスレッドプールを作成してみましょう。典型的なFJの使用法は問題を小さな断片に分割します。これらをランダムな順序で実行するには、ハードウェアレベルで多くの調整が必要です。間接費は殺人者になるだろう。 FJでは、スレッドはLast In First Out(LIFO /スタック)で読み込みキューに置かれ、Work Stealing(コアワークでは一般的に)はFirst In First Out(FIFO/"キュー")で実行されます。その結果、長い配列処理は、たとえそれが小さな塊に分割されていても、大部分を順番に実行することができます。 (1つのビッグバンで問題を小さな均等なサイズのチャンクに分割することは自明ではないかもしれません。均衡を保たずに何らかの形の階層を扱うとしましょう。)

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

23
追加された
しかし、なぜFJも最速のスレッドを待たないでしょうか?サブタスクはあらかじめ決定的に多くありますが、そのうちいくつかは常に最後のものになります。この例の maxSize パラメータを調整すると、FJの例(「 compute()」メソッドで行われる「バイナリ分割」とほぼ同じサブタスク分割が生成されます。 invokeAll()にサブタスクを送信します)。
追加された 著者 Joonas Pulakka,
もし、サブタスクの数が実際に並列に処理できるものより大きければ(最後のものを待たずに済むように)、私は調整問題を見ることができます。 FJの例では、部門が想定されている場合、誤解を招く可能性があります1000x1000のイメージでは16個の実際のサブタスクが生成され、各サブタスクでは62500個の要素が処理されます。 10000x10000イメージの場合、すでに1024個のサブタスクが存在します。
追加された 著者 Joonas Pulakka,
彼らははるかに小さいので、私は私の答えに追加します。
追加された 著者 Tom Hawtin - tackline,

Fork/join is different from a thread pool because it implements work stealing. From Fork/Join

任意のExecutorServiceと同様に、fork/joinフレームワークはタスクを配布します   スレッドプール内のワーカースレッド。フォーク/結合フレームワークは   ワークスティール・アルゴリズムを使用しているためです。ワーカースレッド   実行するものがなくなると、他のスレッドからタスクを盗むことができます   まだ忙しいです。

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

編集:Joonasが指摘しているように、タスクはスレッドに必ず事前に割り当てられるとは限りません。フォーク/ジョインのアイデアは、スレッドがタスクを複数のサブピースに分割することができるということです。だから上記を再:

We have two tasks (ab) and (cd) which take 2 and 11 seconds respectively. Thread 1 starts to execute ab and split it into two sub-tasks a & b. Similarly with thread 2, it splits into two sub-tasks c & d. When thread 1 has finished a & b, it can steal d from thread 2.

12
追加された
スレッドプールは、通常、 ThreadPoolExecutor インスタンスです。そのようなタスクでは、キュー実際にはBlockingQueue )、そこからワーカースレッドは直前のタスクを終了するとすぐにタスクを取得します。タスクは、わかっている限り、特定のスレッドに割り当てられていません。各スレッドは一度に(最大で)1つのタスクを持ちます。
追加された 著者 Joonas Pulakka,
@Matthew Farwell:各タスク内の FJの例 compute()はタスクを計算するか、それを2つのサブタスクに分割します。どのオプションが選択されるかは、タスクのサイズ( if(mLength ... ))によってのみに依存するため、固定数のタスクを作成するための素晴らしい方法です。 1000x1000イメージの場合、実際に何かを計算する正確に16のサブタスクが存在します。さらに、サブタスクを生成して呼び出すだけで、何も計算しない15(= 16 - 1)の "中間"タスクが存在します。
追加された 著者 Joonas Pulakka,
@Matthew Farwell:FJのすべてを理解できない可能性がありますが、サブタスクが computeDirectly()メソッドを実行することに決めた場合、これ以上何も盗む方法はありません。この例では、分割全体が a priori で行われます。
追加された 著者 Joonas Pulakka,
AFAIKには、複数スレッドを制御する 1つ ThreadPoolExecutorの 1つのキューがあります。つまり、タスクまたはランナブル(スレッドではありません!)をエグゼキュータに割り当てると、タスクも特定のスレッドに事前に割り当てられません。まさにFJもそれをやります。今のところFJを使用する利点はありません。
追加された 著者 A.H.,
@ JoonasPulakka:私はこのディスカッションの内容に対処しようとする答えを書いています。
追加された 著者 A.H.,
@A.H。はい、フォーク/ジョインを使用すると、現在のタスクを分割できます。タスクを実行しているスレッドは、2つの異なるタスクに分割できます。だから、ThreadPoolExecutorには、タスクの固定リストがあります。フォーク/ジョインでは、実行中のタスクは自身のタスクを2つに分割することができます。これらのタスクは、作業が終了したときに他のスレッドによって取得されます。または、あなたが最初に終わったらあなた。
追加された 著者 Matthew Farwell,
@ジュナ、はい、彼らは分割のために選んだ戦略です。しかし、各サブタスクがどのくらいの時間を取るかは分かりません。 1つのサブタスクが1秒かかる可能性があり、別のサブタスク(同じ 'サイズ')が15秒かかる可能性があります。この場合、仕事が盗まれる可能性があります。たぶん私はあなたが言っていることを誤解しています。
追加された 著者 Matthew Farwell,

上記の人はすべて、仕事の盗みによって得られる利益は正しいが、なぜこれが拡大するのかについては、

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

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

  • タスクリストの先頭と末尾を独立して同期させることができ、リストの競合を減らすことができます。
  • 作業の重要なサブツリーは分割され、同じスレッドによって再アセンブルされるため、これらのサブツリーにはスレッド間の調整は必要ありません。
  • スレッドが作業を盗むと、大きな部分が取り込まれ、それ自身のリストに細分されます。
  • 作業鍛造とは、プロセスが終了するまで糸がほぼ完全に利用されることを意味します。

スレッドプールを使用する他のほとんどの分割および征服スキームでは、スレッド間の通信と調整が必要です。

10
追加された

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

素敵な記事ですを入力します。見積もり:

全体として、ThreadPoolExecutorが優先されると言えます   ワークロードがワーカースレッド間で均等に分割されます。できること   これを保証するには、入力データが何であるかを正確に知る必要があります   見える。対照的に、ForkJoinPoolは優れたパフォーマンスを提供します   入力データとは無関係に、したがって、   ソリューション。

10
追加された
非常に良い記事、ありがとう!
追加された 著者 Joonas Pulakka,

スレッドプールとFork/Joinの究極の目標は同じです:両方とも、スループットを最大限に高めるために、使用可能なCPUパワーを最大限に活用したいと考えています。最大スループットとは、可能な限り多くのタスクを長期間に渡って完了することを意味します。それをするために何が必要ですか? (ハイパースレッディングの場合には、コアや仮想コアに「CPU」を同等に使用する)、CPU使用率を100%にするには十分です。

  1. 少なくてもスレッドを実行するとコアが使用されなくなるため、使用可能なCPUがある場合と同じくらい多くのスレッドを実行する必要があります。
  2. スレッド数を増やすとCPUを異なるスレッドに割り当てるスケジューラに負荷がかかり、CPU時間が計算機ではなくスケジューラに送られるため、使用可能なCPUの数と同じ数のスレッドを最大で実行する必要がありますタスク。

したがって、スループットを最大限にするには、CPUと同じ数のスレッドを用意する必要があることがわかりました。 Oracleのぼかしの例では、使用可能なCPUの数に等しい数のスレッドを持つ固定サイズのスレッドプールを使用することも、スレッドプールを使用することもできます。それは違いはありません、あなたは正しいです!

So when will you get into trouble with a thread pools? That is if a thread blocks, because your thread is waiting for another task to complete. Assume the following example:

class AbcAlgorithm implements Runnable {
    public void run() {
        Future aFuture = threadPool.submit(new ATask());
        StepBResult bResult = stepB();
        StepAResult aResult = aFuture.get();
        stepC(aResult, bResult);
    }
}

ここで見ているのは、A、B、Cの3つのステップからなるアルゴリズムです。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を実行し、そうでなければ他のスレッドのキューをチェックして作業を盗みます。別のスレッドのこの他のタスクが完了すると、Aが完了したかどうかをチェックします。上記のアルゴリズムであれば stepC を呼び出すことができます。さもなければ、それは盗む別のタスクを探します。したがって、フォーク/ジョインプールは、ブロック動作にもかかわらず100%CPU使用率を達成することができます

しかし、トラップがあります:Work-stealingは、 ForkJoinTask join 呼び出しに対してのみ可能です。別のスレッドを待ったり、I/Oアクションを待ったりするような外部のブロックアクションでは、実行できません。ですから、I/Oが完了するのを待つことは共通の課題ですか?この場合、Fork/Joinプールに追加のスレッドを追加できれば、ブロックアクションが完了すると直ちに停止します。これは2番目に良いことです。 ManagedBlocker を使用している場合、 ForkJoinPool は実際にそれを行うことができます。

フィボナッチ

In the JavaDoc for RecursiveTask is an example for calculating フィボナッチ numbers using Fork/Join. For a classic recursive solution see:

public static int fib(int n) {
    if (n <= 1) {
        return n;
    }
    return fib(n - 1) + fib(n - 2);
}

As is explained int the JavaDocs this is a pretty dump way to calculate フィボナッチ numbers, as this algorithm has O(2^n) complexity while simpler ways are possible. However this algorithm is very simple and easy to understand, so we stick with it. Let's assume we want to speed this up with Fork/Join. A naive implementation would look like this:

class フィボナッチ extends RecursiveTask {
    private final long n;

    フィボナッチ(long n) {
        this.n = n;
    }

    public Long compute() {
        if (n <= 1) {
            return n;
        }
        フィボナッチ f1 = new フィボナッチ(n - 1);
        f1.fork();
        フィボナッチ f2 = new フィボナッチ(n - 2);
        return f2.compute() + f1.join();
   }
}

このTaskが分割されているステップは短すぎるため、恐ろしく実行されますが、フレームワークが一般的にどのように機能するかを見ることができます:2つのsummandは独立して計算できますが、結果。したがって、半分は他のスレッドで行われます。デッドロックを起こすことなくスレッドプールで同じことをやってみましょう(可能ですが、それほど単純ではありません)。

Just for completeness: If you'd actually want to calculate フィボナッチ numbers using this recursive approach here is an optimized version:

class フィボナッチBigSubtasks extends RecursiveTask {
    private final long n;

    フィボナッチBigSubtasks(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 フィボナッチBigSubtasks f1 = new フィボナッチBigSubtasks(n - 1);
            final フィボナッチBigSubtasks f2 = new フィボナッチBigSubtasks(n - 2);
            f1.fork();
            return f2.compute() + f1.join();
        } else {
            return fib(n - 1) + fib(n - 2);
        }
    }
}

This keeps the subtasks much smaller because they are only split when n > 10 && getSurplusQueuedTaskCount() < 2 is true, which means that there are significantly more than 100 method calls to do (n > 10) and there are not very man tasks already waiting (getSurplusQueuedTaskCount() < 2).

私のコンピュータ(4コア(ハイパースレッディングをカウントすると8)、Intel(R)Core(TM)i7-2720QM CPU @ 2.20GHz)では、古典的なアプローチで fib(50)理論的には可能ではありませんが、フォーク/ジョインのアプローチではわずか18秒です。

概要

  • はい、あなたの例では、Fork/Joinは古典的なスレッドプールよりも利点がありません。
  • フォーク/結合は、ブロックが関係しているときにパフォーマンスを大幅に向上させることができます。
  • フォーク/結合がデッドロックの問題を回避する
8
追加された

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

  • 第1四半期をソートする
  • 第2四半期をソートする
  • 最初の2四半期をマージする
  • 第3四半期をソートする
  • 四半期をソートする
  • 過去2四半期をマージする
  • 2つの半分をマージする

あなたがそれらに関係するマージの前にその種類をしなければならないことをどのように指定しますか?

私はアイテムのリストのそれぞれについて特定のことをするのが最善の方法を見てきました。私はリストをあらかじめ分割し、標準のThreadPoolを使用しています。 F-Jは、作業を独立した十分なタスクに予め分割することができない場合に最も有用であると思われるが、それらの間で独立したタスクに再帰的に分割することができる(例えば半分をソートすることは独立しているが、2つのソートされた半分をソートされた全体にマージすることはない)。

7
追加された

高価なマージ操作を行うと、F/Jにも利点があります。それは木構造に分割されるので、線形スレッド分割によるn個のマージとは対照的に、log2(n)マージだけを行います。 (これは、あなたがスレッドと同じ数のプロセッサを持っているという理論上の前提ですが、それでも利点です)宿題の割り当てのために、各インデックスの値を合計することによって数千の2D配列(すべて同じ次元)をマージする必要がありました。フォークジョイントと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

5
追加された

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

Source: http://www.oracle.com/technetwork/articles/java/fork-join-422606.html

エグゼキュータが分割および征服アルゴリズムを実装する際の問題は、サブタスクの作成には関係しません。なぜなら、Callableは、エグゼキュータに新しいサブタスクを自由に提出し、その結果を同期または非同期の方法で待機するからです。問題は並行処理の問題です:Callableが別のCallableの結果を待つとき、それは待機状態になり、実行のためにキューに入れられた別のCallableを処理する機会を無駄にします。

Doug Leaの努力によってJava SE 7のjava.util.concurrentパッケージに追加されたfork/joinフレームワークは、そのギャップを埋める

Source: https://docs.oracle.com/javase/7/docs/api/java/util/concurrent/ForkJoinPool.html

プールは、他のタスクへの参加を待っているタスクが停止した場合でも、内部ワーカースレッドを動的に追加、中断、または再開することによって、十分なアクティブ(または利用可能な)スレッドを維持しようとします。ただし、ブロックされたIOまたはその他の管理されていない同期にもかかわらず、そのような調整は保証されません

public int getPoolSize() Returns the number of worker threads that have started but not yet terminated. The result returned by this method may differ from getParallelism() when threads are created to maintain parallelism when others are cooperatively blocked.

2
追加された

あなたはクローラのようなアプリケーションでForkJoinのパフォーマンスに驚くでしょう。 ここで最高のチュートリアルをご覧ください。

Fork/Joinのロジックは非常にシンプルです:(1)それぞれの大きなタスク   より小さなタスクに; (2)別のスレッドで各タスクを処理する   (必要に応じてそれらをさらに小さなタスクに分割する)。 (3)参加する   結果。

1
追加された