Come funziona il modello di disgregatore di LMAX?


205

Sto cercando di capire il modello del disgregatore . Ho visto il video di InfoQ e ho provato a leggere il loro articolo. Capisco che esiste un buffer ad anello, che è inizializzato come un array estremamente grande per sfruttare la localizzazione della cache, eliminare l'allocazione di nuova memoria.

Sembra che ci siano uno o più numeri interi atomici che tengono traccia delle posizioni. Ogni "evento" sembra avere un ID univoco e la sua posizione nell'anello viene trovata trovando il suo modulo rispetto alla dimensione dell'anello, ecc., Ecc.

Sfortunatamente, non ho un'idea intuitiva di come funzioni. Ho fatto molte applicazioni commerciali e studiato il modello dell'attore , ho guardato SEDA, ecc.

Nella loro presentazione hanno affermato che questo modello è fondamentalmente il funzionamento dei router; tuttavia non ho trovato buone descrizioni di come funzionano i router.

Ci sono alcuni buoni suggerimenti per una spiegazione migliore?

Risposte:


210

Il progetto Google Code fa riferimento a un documento tecnico sull'implementazione del ring buffer, tuttavia è un po 'secco, accademico e duro per qualcuno che vuole imparare come funziona. Tuttavia ci sono alcuni post sul blog che hanno iniziato a spiegare gli interni in un modo più leggibile. Esiste una spiegazione del buffer ad anello che costituisce il nucleo del modello di disgregatore, una descrizione delle barriere del consumatore (la parte relativa alla lettura del disgregatore) e alcune informazioni sulla gestione di più produttori disponibili.

La descrizione più semplice di Disruptor è: è un modo per inviare messaggi tra thread nel modo più efficiente possibile. Può essere usato come alternativa a una coda, ma condivide anche una serie di funzionalità con SEDA e attori.

Rispetto alle code:

Disruptor offre la possibilità di passare un messaggio su altri thread, riattivandolo se necessario (simile a BlockingQueue). Tuttavia, ci sono 3 differenze distinte.

  1. L'utente di Disruptor definisce il modo in cui i messaggi vengono archiviati estendendo la classe Entry e fornendo una factory per eseguire la preallocazione. Ciò consente il riutilizzo della memoria (copia) o la voce potrebbe contenere un riferimento a un altro oggetto.
  2. Inserire messaggi nel Disruptor è un processo in 2 fasi, prima viene richiesto uno slot nel buffer ad anello, che fornisce all'utente la Voce che può essere riempita con i dati appropriati. Quindi la voce deve essere impegnata, questo approccio in 2 fasi è necessario per consentire l'uso flessibile della memoria di cui sopra. È il commit che rende visibile il messaggio ai thread del consumatore.
  3. È responsabilità del consumatore tenere traccia dei messaggi che sono stati consumati dal ring buffer. Allontanare questa responsabilità dal ring buffer stesso ha contribuito a ridurre la quantità di contese di scrittura mentre ogni thread mantiene il proprio contatore.

Rispetto agli attori

Il modello Actor è più vicino a Disruptor rispetto alla maggior parte degli altri modelli di programmazione, soprattutto se si utilizzano le classi BatchConsumer / BatchHandler fornite. Queste classi nascondono tutte le complessità del mantenimento dei numeri di sequenza consumati e forniscono una serie di semplici callback quando si verificano eventi importanti. Tuttavia, ci sono un paio di sottili differenze.

  1. Il Disruptor utilizza un modello consumer 1 thread - 1, in cui gli attori utilizzano un modello N: M, ovvero è possibile avere quanti attori desideri e saranno distribuiti su un numero fisso di thread (generalmente 1 per core).
  2. L'interfaccia di BatchHandler fornisce un callback aggiuntivo (e molto importante) onEndOfBatch(). Ciò consente ai consumatori lenti, ad esempio quelli che eseguono I / O di raggruppare eventi insieme per migliorare la produttività. È possibile eseguire il batch in altri framework Actor, tuttavia poiché quasi tutti gli altri framework non forniscono un callback alla fine del batch, è necessario utilizzare un timeout per determinare la fine del batch, con conseguente scarsa latenza.

Rispetto a SEDA

LMAX ha creato il modello Disruptor per sostituire un approccio basato su SEDA.

  1. Il principale miglioramento offerto rispetto a SEDA è stata la capacità di lavorare in parallelo. A tale scopo, Disruptor supporta il cast multiplo degli stessi messaggi (nello stesso ordine) a più consumatori. Questo evita la necessità di stadi a forcella nella tubazione.
  2. Consentiamo inoltre ai consumatori di attendere i risultati di altri consumatori senza dover mettere un'altra fase di accodamento tra di loro. Un consumatore può semplicemente guardare il numero progressivo di un consumatore da cui dipende. Questo evita la necessità di unire le fasi della pipeline.

Rispetto alle barriere di memoria

Un altro modo di pensarci è come una barriera di memoria strutturata e ordinata. Laddove la barriera del produttore costituisce la barriera di scrittura e la barriera del consumatore è la barriera di lettura.


1
Grazie Michael. La tua scrittura e i link che hai fornito mi hanno aiutato a capire meglio come funziona. Il resto, penso di dover solo lasciarlo affondare.
Shahbaz

Ho ancora domande: (1) come funziona il 'commit'? (2) Quando il buffer dell'anello è pieno, in che modo il produttore rileva che tutti i consumatori hanno visto i dati in modo che il produttore possa riutilizzare le voci?
Qwertie,

@Qwertie, probabilmente vale la pena pubblicare una nuova domanda.
Michael Barker,

1
Non dovrebbe essere la prima frase dell'ultimo punto (numero 2) sotto Rispetto a SEDA invece di leggere "Consentiamo anche ai consumatori di attendere i risultati di altri consumatori con la necessità di mettere un'altra fase di accodamento tra loro" leggi "Consentiamo anche i consumatori devono attendere i risultati di altri consumatori senza dover inserire un'altra fase di accodamento tra loro "(ovvero" con "deve essere sostituito da" senza ")?
Runeks,

@runeks, sì, dovrebbe.
Michael Barker,

135

Innanzitutto vorremmo capire il modello di programmazione che offre.

Ci sono uno o più scrittori. Ci sono uno o più lettori. C'è una linea di voci, totalmente ordinate dal vecchio al nuovo (nella foto da sinistra a destra). Gli autori possono aggiungere nuove voci all'estremità destra. Ogni lettore legge le voci in sequenza da sinistra a destra. I lettori non possono leggere scrittori del passato, ovviamente.

Non esiste un concetto di cancellazione dell'iscrizione. Uso "reader" anziché "consumer" per evitare di consumare l'immagine delle voci. Tuttavia capiamo che le voci a sinistra dell'ultimo lettore diventano inutili.

Generalmente i lettori possono leggere contemporaneamente e indipendentemente. Tuttavia, possiamo dichiarare dipendenze tra i lettori. Le dipendenze del lettore possono essere un grafico aciclico arbitrario. Se il lettore B dipende dal lettore A, il lettore B non può leggere il lettore passato A.

La dipendenza del lettore sorge perché il lettore A può annotare una voce e il lettore B dipende da tale annotazione. Ad esempio, A esegue alcuni calcoli su una voce e memorizza il risultato nel campo anella voce. A quindi andare avanti e ora B può leggere la voce e il valore di aA memorizzato. Se il lettore C non dipende da A, C non dovrebbe tentare di leggere a.

Questo è davvero un modello di programmazione interessante. Indipendentemente dalle prestazioni, il modello da solo può beneficiare di molte applicazioni.

Ovviamente, l'obiettivo principale di LMAX è la prestazione. Utilizza un anello di voci pre-allocato. L'anello è abbastanza grande, ma è limitato in modo che il sistema non venga caricato oltre la capacità di progettazione. Se l'anello è pieno, gli scrittori attenderanno fino a quando i lettori più lenti avanzano e fanno spazio.

Gli oggetti entry sono pre-allocati e vivono per sempre, per ridurre i costi di raccolta dei rifiuti. Non inseriamo nuovi oggetti entry o eliminiamo vecchi oggetti entry, invece, uno scrittore chiede una voce preesistente, popola i suoi campi e avvisa i lettori. Questa apparente azione in 2 fasi è in realtà semplicemente un'azione atomica

setNewEntry(EntryPopulator);

interface EntryPopulator{ void populate(Entry existingEntry); }

Pre-allocazione delle voci significa anche che le voci adiacenti (molto probabilmente) si trovano nelle celle di memoria adiacenti e, poiché i lettori leggono le voci in sequenza, questo è importante per utilizzare le cache della CPU.

E molti sforzi per evitare il blocco, CAS, persino la barriera della memoria (ad esempio, utilizzare una variabile di sequenza non volatile se esiste un solo writer)

Per gli sviluppatori di lettori: diversi lettori di annotazioni dovrebbero scrivere in campi diversi, per evitare contese di scrittura. (In realtà dovrebbero scrivere su diverse righe della cache.) Un lettore di annotazioni non dovrebbe toccare nulla che altri lettori non dipendenti possano leggere. Questo è il motivo per cui dico che questi lettori annotano le voci, invece di modificarle .


2
Mi sembra a posto. Mi piace l'uso del termine annotate.
Michael Barker,

21
+1 questa è l'unica risposta che tenta di descrivere come funziona effettivamente il modello di disgregatore, come richiesto dall'OP.
G-Wiz,

1
Se l'anello è pieno, gli scrittori attenderanno fino a quando i lettori più lenti avanzano e fanno spazio. - uno dei problemi con le profonde code FIFO è quello di caricarle troppo facilmente sotto carico, in quanto non tentano davvero di ridurre la pressione finché non vengono riempite e la latenza è già elevata.
bestsss

1
@irreputable Puoi anche scrivere spiegazioni simili per il lato scrittore?
Buchi,

Mi piace ma ho trovato questo "uno scrittore chiede una voce preesistente, popola i suoi campi e avvisa i lettori. Questa apparente azione in 2 fasi è davvero semplicemente un'azione atomica" confusa e forse sbagliata? Non c'è "notifica" giusto? Inoltre non è atomico, è solo una singola scrittura efficace / visibile, giusto? Ottima risposta solo la lingua che è ambigua?
Avere il


17

In realtà mi sono preso il tempo di studiare la fonte reale, per pura curiosità, e l'idea alla base è abbastanza semplice. La versione più recente al momento della stesura di questo post è la 3.2.1.

Esiste un buffer che memorizza eventi pre-allocati che conterrà i dati affinché i consumatori possano leggerli.

Il buffer è supportato da un array di flag (array intero) della sua lunghezza che descrive la disponibilità degli slot del buffer (vedere più avanti per i dettagli). L'array è accessibile come un java # AtomicIntegerArray, quindi ai fini di questa spiegazione si può anche supporre che sia uno.

Possono esserci un numero qualsiasi di produttori. Quando il produttore vuole scrivere nel buffer, viene generato un numero lungo (come nel chiamare AtomicLong # getAndIncrement, il Disruptor utilizza effettivamente la propria implementazione, ma funziona allo stesso modo). Chiamiamo questo generato a lungo un producerCallId. Allo stesso modo, un consumerCallId viene generato quando un consumatore ENDS legge uno slot da un buffer. Si accede al consumerCallId più recente.

(Se ci sono molti consumatori, viene scelta la chiamata con l'ID più basso.)

Questi ID vengono quindi confrontati e se la differenza tra i due è minore rispetto al lato buffer, il produttore può scrivere.

(Se producerCallId è maggiore della recente consumerCallId + bufferSize, significa che il buffer è pieno e il produttore è costretto ad aspettare il bus fino a quando non diventa disponibile uno spot.)

Al produttore viene quindi assegnato lo slot nel buffer in base al suo callId (che è prducerCallId modulo bufferSize, ma poiché bufferSize è sempre una potenza di 2 (limite applicato alla creazione del buffer), l'operazione attuall utilizzata è producerCallId & (bufferSize - 1 )). È quindi libero di modificare l'evento in quello slot.

(L'algoritmo effettivo è un po 'più complicato, e comporta la memorizzazione nella cache di un consumerId recente in un riferimento atomico separato, a fini di ottimizzazione.)

Quando l'evento è stato modificato, la modifica è "pubblicata". Quando si pubblica il rispettivo slot nell'array flag viene riempito con il flag aggiornato. Il valore flag è il numero del loop (producerCallId diviso per bufferSize (di nuovo poiché bufferSize ha una potenza di 2, l'operazione effettiva è uno spostamento a destra).

Allo stesso modo possono esserci un numero qualsiasi di consumatori. Ogni volta che un consumatore desidera accedere al buffer, viene generato un consumerCallId (a seconda di come i consumatori sono stati aggiunti al disgregatore, l'atomico utilizzato nella generazione dell'id può essere condiviso o separato per ciascuno di essi). Questo consumerCallId viene quindi confrontato con il più recente producCCallId e, se è minore dei due, il lettore può avanzare.

(Allo stesso modo se producerCallId è pari a consumerCallId, significa che il buffer è vuoto e il consumatore è costretto ad aspettare. Il modo di attesa è definito da WaitStrategy durante la creazione del disgregatore.)

Per i singoli consumatori (quelli con il proprio generatore di identità), la cosa successiva controllata è la capacità di consumare in batch. Gli slot nel buffer vengono esaminati in ordine da quello relativo al consumerCallId (l'indice è determinato nello stesso modo dei produttori), a quello relativo al recente producerCallId.

Vengono esaminati in un ciclo confrontando il valore del flag scritto nell'array del flag, con un valore del flag generato per consumerCallId. Se le bandiere corrispondono, significa che i produttori che riempiono gli slot hanno commesso le loro modifiche. In caso contrario, il loop viene interrotto e viene restituito il changeId con il commit più alto. Gli slot da ConsumerCallId a ricevuti in changeId possono essere consumati in batch.

Se un gruppo di consumatori legge insieme (quelli con generatore di ID condiviso), ognuno accetta solo un singolo ID di chiamata e viene controllato e restituito solo lo slot per quel singolo ID di chiamata.


7

Da questo articolo :

Il modello di disgregatore è una coda di batch supportata da un array circolare (ovvero il buffer dell'anello) riempito con oggetti di trasferimento pre-allocati che utilizza barriere di memoria per sincronizzare produttori e consumatori attraverso sequenze.

Le barriere di memoria sono piuttosto difficili da spiegare e il blog di Trisha ha fatto il miglior tentativo secondo me con questo post: http://mechanitis.blogspot.com/2011/08/dissecting-disruptor-why-its-so-fast. html

Ma se non vuoi immergerti nei dettagli di basso livello, puoi semplicemente sapere che le barriere di memoria in Java sono implementate attraverso la volatileparola chiave o tramite java.util.concurrent.AtomicLong. Le sequenze del modello del disgregatore sono AtomicLongse comunicate avanti e indietro tra produttori e consumatori attraverso barriere di memoria anziché blocchi.

Trovo più facile comprendere un concetto attraverso il codice, quindi il codice qui sotto è semplice mondo di CoralQueue , che è un'implementazione del modello di disgregatore fatta da CoralBlocks con cui sono affiliato. Nel codice qui sotto puoi vedere come il pattern disruptor implementa il batch e come il ring-buffer (cioè array circolare) consente una comunicazione senza immondizia tra due thread:

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...
    }
}
Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.