web-dev-qa-db-ja.com

LMAXのディスラプターパターンはどのように機能しますか?

ディスラプターパターン を理解しようとしています。 InfoQビデオを見て、彼らの論文を読んでみました。リングバッファが関係していることを理解しています。これは、キャッシュの局所性を利用して新しいメモリの割り当てをなくすために、非常に大きな配列として初期化されます。

位置を追跡する1つ以上の原子整数があるように聞こえます。各「イベント」は一意のIDを取得しているようであり、リング内の位置は、リングのサイズなどに関するモジュラスなどを見つけることで見つかります。

残念ながら、私はそれがどのように機能するのか直感的な感覚を持っていません。私は多くの取引アプリケーションを作成し、 actor model を研究し、SEDAなどを調べました。

彼らのプレゼンテーションで、彼らはこのパターンが基本的にルーターがどのように機能するかを述べました。ただし、ルーターの動作方法に関する適切な説明も見つかりませんでした。

より良い説明への良いポインタはありますか?

202
Shahbaz

Google Codeプロジェクトは テクニカルペーパーを参照 リングバッファーの実装について説明していますが、それがどのように機能するかを学びたい人にとっては少しドライで、アカデミックであり、大変です。しかし、内部をより読みやすい方法で説明し始めたブログ投稿がいくつかあります。 リングバッファの説明 がディスラプターパターンのコアであり、 消費者の障壁の説明 (ディスラプターからの読み取りに関連する部分)といくつかの- 複数のプロデューサーの取り扱いに関する情報 利用可能。

ディスラプターの最も簡単な説明は次のとおりです。これは、可能な限り最も効率的な方法でスレッド間でメッセージを送信する方法です。キューの代わりとして使用できますが、SEDAやアクターと多くの機能を共有しています。

キューと比較:

ディスラプターは、メッセージを別のスレッドに渡し、必要に応じてウェイクアップする機能を提供します(BlockingQueueと同様)。ただし、3つの明確な違いがあります。

  1. Disruptorのユーザーは、Entryクラスを拡張し、事前割り当てを行うファクトリを提供することにより、メッセージの格納方法を定義します。これにより、メモリの再利用(コピー)またはエントリに別のオブジェクトへの参照を含めることができます。
  2. ディスラプターへのメッセージの挿入は2フェーズプロセスであり、最初にリングバッファーでスロットが要求されます。これにより、適切なデータを入力できるエントリがユーザーに提供されます。次に、エントリをコミットする必要があります。この2フェーズアプローチは、上記のメモリの柔軟な使用を可能にするために必要です。コンシューマスレッドにメッセージを表示させるのはコミットです。
  3. リングバッファから消費されたメッセージを追跡するのは、消費者の責任です。この責任をリングバッファー自体から遠ざけると、各スレッドが独自のカウンターを維持するため、書き込み競合の量を減らすことができました。

俳優と比較

特に提供されているBatchConsumer/BatchHandlerクラスを使用する場合、アクターモデルは他のほとんどのプログラミングモデルよりもディスラプターに近くなります。これらのクラスは、消費されたシーケンス番号を維持する複雑さをすべて隠し、重要なイベントが発生したときに一連の単純なコールバックを提供します。ただし、いくつかの微妙な違いがあります。

  1. ディスラプターは1スレッド-1コンシューマーモデルを使用します。アクターはN:Mモデルを使用します。つまり、アクターは好きなだけ持つことができ、固定数のスレッド(通常はコアごとに1つ)に分散されます。
  2. BatchHandlerインターフェイスは、追加の(非常に重要な)コールバックonEndOfBatch()を提供します。これにより、遅い消費者、たとえばI/Oを行ってイベントをまとめてバッチ処理し、スループットを向上させます。他のActorフレームワークでバッチ処理を行うことは可能ですが、他のほとんどすべてのフレームワークはバッチの終わりにコールバックを提供しないため、タイムアウトを使用してバッチの終わりを決定する必要があり、結果としてレイテンシが低下します。

SEDAと比較

LMAXは、SEDAベースのアプローチに代わるディスラプターパターンを構築しました。

  1. SEDAに対して提供された主な改善点は、並行して作業を行うことができることです。これを行うために、ディスラプターは同じメッセージを(同じ順序で)複数のコンシューマーにマルチキャストすることをサポートしています。これにより、パイプラインのフォークステージが不要になります。
  2. また、消費者は、他の消費者の間に別のキューイングステージを配置することなく、他の消費者の結果を待つことができます。消費者は、依存している消費者のシーケンス番号を簡単に見ることができます。これにより、パイプラインの結合段階が不要になります。

メモリバリアと比較

それについて考える別の方法は、構造化され、順序付けられたメモリバリアとしてです。プロデューサーバリアが書き込みバリアを形成し、コンシューマバリアが読み取りバリアを形成します。

208
Michael Barker

まず、それが提供するプログラミングモデルを理解したいと思います。

1人以上の作家がいます。 1人以上の読者がいます。エントリの行があり、古いものから新しいものへと完全に順序付けられています(左から右に描かれています)。作家は、右端に新しいエントリを追加できます。すべてのリーダーは、エントリを左から右に順番に読み取ります。明らかに、読者は過去の作家を読むことができません。

エントリ削除の概念はありません。消費されるエントリのイメージを避けるために、「コンシューマ」ではなく「リーダー」を使用します。ただし、最後の読者の左側のエントリが役に立たなくなることを理解しています。

一般的に、読者は同時に独立して読むことができます。ただし、リーダー間の依存関係を宣言できます。リーダーの依存関係は、任意の非循環グラフにすることができます。リーダーBがリーダーAに依存している場合、リーダーBは過去のリーダーAを読むことができません。

リーダーAはエントリに注釈を付けることができ、リーダーBはその注釈に依存するため、リーダーの依存関係が発生します。たとえば、Aはエントリに対して何らかの計算を行い、その結果をエントリのフィールドaに格納します。 Aが次に進むと、Bはエントリを読み取ることができ、a Aの値が保存されます。リーダーCがAに依存していない場合、Cはaを読み取ろうとしてはなりません。

これは確かに興味深いプログラミングモデルです。パフォーマンスに関係なく、モデルだけで多くのアプリケーションにメリットがあります。

もちろん、LMAXの主な目標はパフォーマンスです。事前に割り当てられたエントリのリングを使用します。リングは十分な大きさですが、設計容量を超えてシステムがロードされないように制限されています。リングがいっぱいの場合、ライターは最も遅いリーダーが進んでスペースを空けるまで待機します。

エントリオブジェクトは事前に割り当てられ、永久に存続し、ガベージコレクションのコストを削減します。新しいエントリオブジェクトを挿入したり、古いエントリオブジェクトを削除したりするのではなく、ライターが既存のエントリを要求し、そのフィールドに入力して、読者に通知します。この明らかな2フェーズアクションは、実際には単純なアトミックアクションです。

setNewEntry(EntryPopulator);

interface EntryPopulator{ void populate(Entry existingEntry); }

エントリを事前に割り当てることは、隣接するエントリを(おそらく)隣接するメモリセルに配置することも意味します。リーダーはエントリを順番に読み取るため、CPUキャッシュを利用することが重要です。

また、ロック、CAS、メモリバリアさえ回避するための多くの努力(たとえば、ライターが1人しかない場合は、不揮発性シーケンス変数を使用します)

読者の開発者向け:書き込みの競合を避けるために、異なる注釈を付ける読者は異なるフィールドに書き込む必要があります。 (実際には、異なるキャッシュラインに書き込む必要があります。)注釈を付けるリーダーは、他の依存関係のないリーダーが読む可能性のあるものには触れないでください。これが、これらの読者がmodifyエントリではなくannotateエントリだと言う理由です。

135
irreputable

Martin Fowlerは、LMAXと破壊パターンに関する記事 The LMAX Architecture を書いています。

41
ChucK

私は実際に、純粋な好奇心から実際のソースを研究するために時間をかけました。その背後にあるアイデアは非常に単純です。この投稿を書いている時点での最新バージョンは3.2.1です。

コンシューマが読み取るデータを保持する事前に割り当てられたイベントを格納するバッファがあります。

バッファーは、バッファースロットの可用性を説明する長さのフラグの配列(整数配列)によってサポートされます(詳細については、さらに参照してください)。配列はJava#AtomicIntegerArrayのようにアクセスされるため、この説明の目的上、配列は1つであると想定することもできます。

プロデューサーはいくつあってもかまいません。プロデューサーがバッファーに書き込みたい場合、長い数値が生成されます(AtomicLong#getAndIncrementの呼び出しのように、Disruptorは実際に独自の実装を使用しますが、同じように機能します)。生成された長いこれをproducerCallIdと呼びましょう。同様に、コンシューマがバッファからスロットを読み取ると、consumerCallIdが生成されます。最新のconsumerCallIdにアクセスします。

(多くのコンシューマーがある場合、最も低いIDの呼び出しが選択されます。)

次に、これらのIDが比較され、2つの間の差がバッファー側よりも小さい場合、プロデューサーは書き込みを許可されます。

(producerCallIdが最近のconsumerCallId + bufferSizeよりも大きい場合、バッファーがいっぱいであり、プロデューサーはスポットが利用可能になるまでバス待機を強制されます。)

次に、プロデューサーは自分のcallId(prducerCallId modulo bufferSizeに基づいてバッファー内のスロットを割り当てられますが、bufferSizeは常に2のべき乗(バッファーの作成時に適用される制限)であるため、使用される実際の操作はproducerCallId&(bufferSize-1 ))。その後、そのスロットのイベントを自由に変更できます。

(実際のアルゴリズムはもう少し複雑で、最適化のために最近のconsumerIdを個別のアトミックリファレンスにキャッシュする必要があります。)

イベントが変更されると、変更は「公開」されます。フラグ配列内のそれぞれのスロットを公開すると、更新されたフラグで埋められます。フラグ値はループの数です(producerCallIdをbufferSizeで除算します(ここでも、bufferSizeは2の累乗なので、実際の操作は右シフトです)。

同様に、消費者はいくつでもあります。コンシューマーがバッファーにアクセスするたびに、consumerCallIdが生成されます(コンシューマーがディスラプターに追加された方法に応じて、id生成で使用されるアトミックは、それぞれに対して共有または個別になります)。次に、このconsumerCallIdが最新のproducentCallIdと比較され、2つのうち小さい場合、リーダーは進行できます。

(同様に、producerCallIdがconsumerCallIdに等しい場合、バッファが空になり、コンシューマが待機することを強制されることを意味します。待機の方法は、ディスラプター作成中のWaitStrategyによって定義されます。)

個々の消費者(独自のIDジェネレーターを持つ消費者)にとって、次にチェックするのはバッチ消費機能です。バッファー内のスロットは、consumerCallIdに対応するスロット(プロデューサーと同じ方法でインデックスが決定される)から、最近のproducerCallIdに対応するスロットまで順に検査されます。

これらは、フラグ配列に書き込まれたフラグ値を、consumerCallIdに対して生成されたフラグ値と比較することにより、ループで検査されます。フラグが一致する場合、スロットを埋めているプロデューサーが変更をコミットしたことを意味します。そうでない場合、ループは中断され、コミットされた最高のchangeIdが返されます。 ConsumerCallIdからchangeIdで受信するスロットは、バッチで消費できます。

コンシューマーのグループが一緒に読み取る場合(共有IDジェネレーターを持つもの)、各コンシューマーは単一のcallIdのみを受け取り、その単一のcallIdのスロットのみがチェックされて返されます。

17

この記事 から:

ディスラプターパターンは、メモリバリアを使用してシーケンスを通じてプロデューサーとコンシューマーを同期する、事前に割り当てられた転送オブジェクトで満たされた循環配列(つまり、リングバッファー)によってバックアップされるバッチキューです。

記憶の障壁を説明するのは一種の困難であり、Trishaのブログは私の意見ではこの投稿で最善の試みを行っています: http://mechanitis.blogspot.com/2011/08/dissecting-disruptor-why-its- so-fast.html

ただし、低レベルの詳細に飛び込みたくない場合は、JavaのメモリバリアがvolatileキーワードまたはJava.util.concurrent.AtomicLongを介して実装されていることを知ることができます。ディスラプターパターンシーケンスはAtomicLongsであり、ロックの代わりにメモリバリアを介してプロデューサーとコンシューマーの間でやり取りされます。

コードで概念を理解する方が簡単だと思うので、以下のコードは CoralQueue からの単純なhelloworldです。私が所属しているCoralBlocksによって実行されたディスラプターパターンの実装。以下のコードでは、ディスラプターパターンがバッチ処理を実装する方法と、リングバッファー(つまり、循環配列)が2つのスレッド間のガベージフリー通信を可能にする方法を確認できます。

package com.coralblocks.coralqueue.sample.queue;

import com.coralblocks.coralqueue.AtomicQueue;
import com.coralblocks.coralqueue.Queue;
import com.coralblocks.coralqueue.util.MutableLong;

public class Sample {

    public static void main(String[] args) throws InterruptedException {

        final Queue<MutableLong> queue = new AtomicQueue<MutableLong>(1024, MutableLong.class);

        Thread consumer = new Thread() {

            @Override
            public void run() {

                boolean running = true;

                while(running) {
                    long avail;
                    while((avail = queue.availableToPoll()) == 0); // busy spin
                    for(int i = 0; i < avail; i++) {
                        MutableLong ml = queue.poll();
                        if (ml.get() == -1) {
                            running = false;
                        } else {
                            System.out.println(ml.get());
                        }
                    }
                    queue.donePolling();
                }
            }

        };

        consumer.start();

        MutableLong ml;

        for(int i = 0; i < 10; i++) {
            while((ml = queue.nextToDispatch()) == null); // busy spin
            ml.set(System.nanoTime());
            queue.flush();
        }

        // send a message to stop consumer...
        while((ml = queue.nextToDispatch()) == null); // busy spin
        ml.set(-1);
        queue.flush();

        consumer.join(); // wait for the consumer thread to die...
    }
}
7
rdalmeida