LMAXのかく乱パターンはどのように機能しますか?


205

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

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

残念ながら、私はそれがどのように機能するかを直感的に理解できません。私は多くの取引アプリケーションを実行し、アクターモデルを研究したり、SEDAを調べたりしました。

彼らのプレゼンテーションでは、このパターンは基本的にルーターがどのように機能するかであると述べました。しかし、ルーターがどのように機能するかについての良い説明は見つかりませんでした。

より良い説明への良い指針はありますか?

回答:


210

Google Codeプロジェクトは、リングバッファの実装に関するテクニカルペーパー参照してますが、それがどのように機能するのかを知りたいと考える人にとっては、少しドライで学術的で難しいものです。ただし、内部をより読みやすい方法で説明するようになったいくつかのブログ投稿があります。あるリングバッファの説明破砕パターンのコアであり、消費者障壁の説明(撹乱からの読み出しに関連する部分)と、いくつかの複数のプロデューサを処理について入手可能。

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

キューと比較して:

Disruptorは、メッセージを別のスレッドに渡し、必要に応じて起動する(BlockingQueueと同様)機能を提供します。ただし、3つの明確な違いがあります。

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

アクターと比較

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

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

SEDAと比較

LMAXは、SEDAベースのアプローチを置き換えるためにDisruptorパターンを構築しました。

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

メモリバリアと比較

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


1
マイケルに感謝します。あなたの記事とあなたが提供したリンクは、それがどのように機能するかをよりよく理解するのに役立ちました。残りは、私はちょうどそれが中に沈むようにする必要があると思う。
Shahbaz

私はまだ質問があります:(1)「コミット」はどのように機能しますか?(2)リングバッファがいっぱいになった場合、プロデューサはエントリを再利用できるように、すべてのコンシューマがデータを見たことをどのように検出しますか?
Qwertie

@Qwertie、おそらく新しい質問を投稿する価値があります。
Michael Barker

1
SEDAとの比較」の最後の箇条書き(番号2)の最初の文は、「他の消費者の結果を待つこともできます」と読むのではなく、他の消費者の結果を待つ必要があります。消費者は他の消費者の結果を待つ必要がなく、その間に別のキューイングステージを置く必要はありませんか(つまり、「with」を「without」に置き換える必要があります)?
runeks 2013

@runeks、そうですね。
Michael Barker

135

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

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

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

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

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

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

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

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

setNewEntry(EntryPopulator);

interface EntryPopulator{ void populate(Entry existingEntry); }

エントリを事前に割り当てることは、隣接するエントリが隣接するメモリセルに配置される可能性が非常に高いことも意味します。リーダーはエントリを順番に読み取るため、これはCPUキャッシュを利用するために重要です。

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

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


2
私には大丈夫に見えます。注釈という用語の使用が好きです。
マイケル・バーカー、

21
+1これは、OPが尋ねたように、かく乱パターンが実際にどのように機能するかを説明しようとする唯一の回答です。
G-Wiz

1
リングがいっぱいの場合、ライターは、最も遅いリーダーが進み、スペースを確保するまで待機します。-深いFIFOキューの問題の1つは、負荷がかかるとキューが満杯になりすぎてしまうことです。
bestsss

1
@irreputableライター側にも同様の説明を書いていただけますか?
ブチ2013年

気に入ったのですが、「ライターが既存のエントリを要求し、フィールドにデータを入力して、リーダーに通知します。この明らかな2フェーズアクションは、実際には単なるアトミックアクションです」と混乱し、おそらく間違っていると思いました。「通知する」権利はありませんか?また、それはアトミックではなく、単一の有効な/目に見える書き込みですよね?曖昧な言葉だけでいい答えは?
HaveAGuess 2014


17

純粋な好奇心から、私は実際に時間をかけて実際の情報源を研究しましたが、その背後にある考えは非常に単純です。この投稿の執筆時点での最新バージョンは3.2.1です。

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

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

プロデューサーはいくつでも存在できます。プロデューサーがバッファーに書き込みたい場合、長い数が生成されます(AtomicLong#getAndIncrementを呼び出す場合と同様に、Disruptorは実際には独自の実装を使用しますが、同じように動作します)。この生成されたlongをプロデューサーコールIDと呼びましょう。同様に、コンシューマーがバッファーからスロットの読み取りを終了すると、consumerCallIdが生成されます。最新のconsumerCallIdにアクセスします。

(コンシューマーが多数ある場合は、最小のIDを持つ呼び出しが選択されます。)

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

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

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


7

この記事から:

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

メモリの障壁については説明するのが難しいので、Trishaのブログは私の意見ではこの投稿で最善の試みをしました:http : //mechanitis.blogspot.com/2011/08/dissecting-disruptor-why-its-so-fast。 html

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

コードで概念を理解する方が簡単だと思うので、以下のコードはCoralQueueからの単純なhelloworldです。以下のコードでは、disruptorパターンがバッチ処理を実装する方法と、リングバッファー(つまり、循環配列)が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...
    }
}
弊社のサイトを使用することにより、あなたは弊社のクッキーポリシーおよびプライバシーポリシーを読み、理解したものとみなされます。
Licensed under cc by-sa 3.0 with attribution required.