web-dev-qa-db-fra.com

Comment fonctionne le modèle de disjoncteur de LMAX?

J'essaie de comprendre le motif de perturbation . J'ai regardé la vidéo InfoQ et j'ai essayé de lire leur journal. Je crois comprendre qu’un tampon d’anneau est impliqué, qu’il est initialisé sous la forme d’un tableau extrêmement volumineux pour tirer parti de la localisation en cache et éliminer l’allocation de mémoire supplémentaire.

On dirait qu’un ou plusieurs nombres entiers atomiques gardent une trace des positions. Chaque "événement" semble avoir un identifiant unique et sa position dans l'anneau est déterminée en trouvant son module par rapport à la taille de l'anneau, etc., etc.

Malheureusement, je n'ai pas une idée intuitive de la façon dont cela fonctionne. J'ai effectué de nombreuses applications commerciales et étudié le modèle d'acteur , examiné SEDA, etc.

Dans leur exposé, ils ont indiqué que ce modèle correspond essentiellement au fonctionnement des routeurs. Cependant, je n'ai trouvé aucune bonne description du fonctionnement des routeurs.

Existe-t-il de bonnes indications pour une meilleure explication?

202
Shahbaz

Le projet Google Code ne fait référence à un document technique sur la mise en œuvre du tampon circulaire, mais il est un peu aride, académique et difficile pour quelqu'un qui veut apprendre à l'utiliser. Cependant, certains articles de blog ont commencé à expliquer les éléments internes de manière plus lisible. Il y a un explication de l'anneau tampon qui constitue le noyau du modèle de perturbateur, un description des obstacles à la consommation (la partie relative à la lecture du disrupteur) et quelques autres - informations sur la gestion de plusieurs producteurs disponible.

La description la plus simple de Disruptor est la suivante: il s'agit d'un moyen d'envoyer des messages entre les threads de la manière la plus efficace possible. Il peut être utilisé comme alternative à une file d'attente, mais il partage également un certain nombre de fonctionnalités avec SEDA et les acteurs.

Comparé aux files d'attente:

Disruptor permet de transmettre un message à d'autres threads, en le réveillant si nécessaire (similaire à BlockingQueue). Cependant, il existe 3 différences distinctes.

  1. L'utilisateur de Disruptor définit le mode de stockage des messages en étendant la classe Entry et en fournissant une fabrique pour effectuer la pré-affectation. Cela permet la réutilisation de la mémoire (copie) ou l'entrée peut contenir une référence à un autre objet.
  2. L'envoi de messages dans Disruptor est un processus en deux phases. Tout d'abord, un emplacement est revendiqué dans le tampon en anneau, qui fournit à l'utilisateur l'entrée pouvant être remplie avec les données appropriées. Ensuite, l'entrée doit être validée, cette approche en 2 phases est nécessaire pour permettre l'utilisation flexible de la mémoire mentionnée ci-dessus. C'est le commit qui rend le message visible aux threads consommateurs.
  3. Il incombe au consommateur de garder une trace des messages qui ont été consommés à partir de la mémoire tampon circulaire. Éloigner cette responsabilité du tampon circulaire lui-même a permis de réduire le nombre de conflits d’écriture, chaque thread conservant son propre compteur.

par rapport aux acteurs

Le modèle Actor est plus proche du disrupteur que la plupart des autres modèles de programmation, notamment si vous utilisez les classes BatchConsumer/BatchHandler fournies. Ces classes masquent toutes les complexités du maintien des numéros de séquence consommés et fournissent un ensemble de rappels simples lorsque des événements importants se produisent. Cependant, il existe quelques différences subtiles.

  1. Disruptor utilise un modèle consommateur à 1 thread - 1, où les acteurs utilisent un modèle N: M, c’est-à-dire que vous pouvez avoir autant d’acteurs que vous le souhaitez et qu’ils seront répartis sur un nombre fixe de threads (généralement 1 par noyau).
  2. L'interface BatchHandler fournit un rappel supplémentaire (et très important) onEndOfBatch(). Cela permet aux consommateurs lents, par exemple ceux qui font des E/S pour traiter des événements par lots afin d’améliorer le débit. Il est possible d'effectuer le traitement par lots dans d'autres frameworks Actor. Toutefois, comme presque tous les frameworks ne fournissent pas de rappel à la fin du lot, vous devez utiliser un délai d'expiration pour déterminer la fin du lot, ce qui entraîne une latence faible.

par rapport à SEDA

LMAX a créé le modèle Disruptor pour remplacer une approche basée sur SEDA.

  1. La principale amélioration apportée par rapport à SEDA était la capacité de travailler en parallèle. Pour ce faire, Disruptor prend en charge la diffusion multiple des mêmes messages (dans le même ordre) à plusieurs consommateurs. Cela évite d'avoir à recourir à des étapes intermédiaires dans le pipeline.
  2. Nous permettons également aux consommateurs d’attendre les résultats des autres consommateurs sans avoir à mettre une autre étape en attente entre eux. Un consommateur peut simplement regarder le numéro de séquence d'un consommateur dont il dépend. Cela évite le besoin de joindre des étapes dans le pipeline.

par rapport aux barrières de mémoire

Une autre façon de penser à ce problème est de créer une barrière de mémoire structurée et ordonnée. Lorsque la barrière du producteur forme la barrière de l’écriture et que la barrière du consommateur est la barrière de lecture.

208
Michael Barker

Nous voudrions d’abord comprendre le modèle de programmation proposé.

Il y a un ou plusieurs écrivains. Il y a un ou plusieurs lecteurs. Il y a une ligne d'entrées, totalement ordonnées de l'ancien au nouveau (photo de gauche à droite). Les rédacteurs peuvent ajouter de nouvelles entrées à l'extrémité droite. Chaque lecteur lit les entrées séquentiellement de gauche à droite. Les lecteurs ne peuvent évidemment pas lire d'anciens écrivains.

Il n'y a pas de concept de suppression d'entrée. J'utilise "lecteur" au lieu de "consommateur" pour éviter que l'image des entrées ne soit consommée. Cependant, nous comprenons que les entrées à gauche du dernier lecteur deviennent inutiles.

Généralement, les lecteurs peuvent lire simultanément et indépendamment. Cependant, nous pouvons déclarer des dépendances entre lecteurs. Les dépendances de lecteur peuvent être des graphes acycliques arbitraires. Si le lecteur B dépend du lecteur A, le lecteur B ne peut pas lire après le lecteur A.

La dépendance du lecteur est due au fait que le lecteur A peut annoter une entrée et que le lecteur B dépend de cette annotation. Par exemple, A effectue un calcul sur une entrée et stocke le résultat dans le champ a de l'entrée. A puis passez à autre chose, et maintenant B peut lire l’entrée et la valeur de a A stockée. Si le lecteur C ne dépend pas de A, C ne doit pas essayer de lire a.

C'est en effet un modèle de programmation intéressant. Quelles que soient les performances, le modèle seul peut bénéficier à de nombreuses applications.

Bien entendu, le principal objectif de LMAX est la performance. Il utilise un anneau d'entrées pré-alloué. L'anneau est suffisamment grand, mais il est limité de sorte que le système ne soit pas chargé au-delà de sa capacité nominale. Si la sonnerie est pleine, les écrivains attendront que les lecteurs les plus lents avancent et fassent de la place.

Les objets d'entrée sont pré-alloués et vivent pour toujours, afin de réduire les coûts de collecte des déchets. Nous n'insérons pas de nouveaux objets d'entrée ni ne supprimons d'anciens objets d'entrée. Un écrivain demande une entrée préexistante, remplit ses champs et en informe les lecteurs. Cette action apparente à 2 phases est vraiment simplement une action atomique

setNewEntry(EntryPopulator);

interface EntryPopulator{ void populate(Entry existingEntry); }

La pré-affectation des entrées signifie également que les entrées adjacentes (très probablement) sont localisées dans des cellules de mémoire adjacentes et, comme les lecteurs lisent les entrées de manière séquentielle, il est important d'utiliser des caches de processeur.

Et beaucoup d’efforts pour éviter le verrouillage, le CAS et même la barrière de mémoire (par exemple, utilisez une variable de séquence non volatile s’il n’ya qu’un seul rédacteur).

Pour les développeurs de lecteurs: Différents lecteurs annotés doivent écrire dans différents champs pour éviter les conflits d’écriture. (En fait, ils doivent écrire sur différentes lignes de cache.) Un lecteur annoté ne doit toucher à aucun document lu par d’autres lecteurs non dépendants. C'est pourquoi je dis ces lecteurs annotez entrées, au lieu de modifiez entrées.

135
irreputable

Martin Fowler a écrit un article sur LMAX et le motif de perturbation The LMAX Architecture , qui pourrait l’éclairer davantage.

41
ChucK

En fait, j'ai pris le temps d'étudier la source réelle, par simple curiosité, et l'idée sous-jacente est très simple. La version la plus récente au moment de la rédaction de cet article est la 3.2.1.

Il existe un tampon stockant des événements pré-alloués contenant les données à lire par les consommateurs.

La mémoire tampon est protégée par un tableau de drapeaux (entier) de la longueur décrivant la disponibilité des slots de mémoire tampon (voir plus loin pour plus de détails). Le tableau est accédé comme un Java # AtomicIntegerArray. Par conséquent, pour les besoins de cette explication, vous pouvez également supposer qu'il en est un.

Il peut y avoir n'importe quel nombre de producteurs. Lorsque le producteur souhaite écrire dans la mémoire tampon, un nombre long est généré (comme dans l'appel AtomicLong # getAndIncrement, Disruptor utilise sa propre implémentation, mais fonctionne de la même manière). Appelons cela généré depuis longtemps ProducerCallId. De manière similaire, un consumerCallId est généré lorsqu'un consommateur ENDS lit un emplacement dans une mémoire tampon. Le consommateur le plus récent est appeléCallId.

(S'il y a beaucoup de consommateurs, l'appel avec l'ID le plus bas est choisi.)

Ces identifiants sont ensuite comparés, et si la différence entre les deux est inférieure à celle du côté tampon, le producteur est autorisé à écrire.

(Si ProducerCallId est supérieur à ConsumerCallId + bufferSize récent, cela signifie que la mémoire tampon est saturée et que le producteur est obligé d'attendre jusqu'à ce qu'une place soit disponible.)

Le producteur se voit ensuite attribuer l’emplacement dans la mémoire tampon en fonction de son callId (qui est prducerCallId modulo bufferSize), mais puisque bufferSize est toujours une puissance de 2 (limite appliquée à la création de la mémoire tampon), l’opération actuall utilisée est ProducerIdId & (bufferSize - 1). )). Il est alors libre de modifier l'événement dans cet emplacement.

(L'algorithme lui-même est un peu plus compliqué, impliquant la mise en cache de ConsumerId récent dans une référence atomique distincte, à des fins d'optimisation.)

Lorsque l'événement a été modifié, la modification est "publiée". Lors de la publication, l'emplacement correspondant dans le tableau d'indicateurs est rempli avec l'indicateur mis à jour. La valeur de l'indicateur est le numéro de la boucle (ProducerCallId divisé par bufferSize (encore une fois, puisque bufferSize a une puissance de 2, l'opération réelle est un décalage à droite).

De manière similaire, il peut y avoir un nombre quelconque de consommateurs. Chaque fois qu'un consommateur veut accéder à la mémoire tampon, un ConsumerCallId est généré (en fonction de la façon dont les consommateurs ont été ajoutés au perturbateur, la forme atomique utilisée dans la génération d'un identifiant peut être partagée ou séparée pour chacun d'eux). Ce consommateurCallId est ensuite comparé au plus récent producentCallId et, s'il est moindre des deux, le lecteur est autorisé à progresser.

(De la même manière, si le producteur est le même pour le consommateur, cela signifie que le tampon est indépendant et que le consommateur est obligé d'attendre. La manière d'attendre est définie par une WaitStrategy lors de la création d'un perturbateur.)

Pour les consommateurs individuels (ceux avec leur propre générateur d'identifiant), la prochaine chose vérifiée est la possibilité de consommer par lots. Les créneaux de la mémoire tampon sont examinés dans l'ordre, de l'ordre respectif à consumCallId (l'indice est déterminé de la même manière que pour les producteurs), à celui respectif à productCallId récent.

Ils sont examinés dans une boucle en comparant la valeur d'indicateur écrite dans le tableau d'indicateurs, à une valeur d'indicateur générée pour consumerCallId. Si les drapeaux correspondent, cela signifie que les producteurs remplissant les créneaux horaires ont validé leurs modifications. Si ce n'est pas le cas, la boucle est rompue et le plus grand changement IDI engagé est renvoyé. Les emplacements de ConsumerCallId à recevoir dans changeId peuvent être consommés par lot.

Si un groupe de consommateurs lisent ensemble (ceux avec un générateur d'identifiant partagé), chacun d'eux ne prend qu'un seul identificateur d'appel et seul l'emplacement correspondant à cet identificateur unique est vérifié et renvoyé.

17

De cet article :

Le modèle de perturbateur est une file de traitement par lots sauvegardée par un tableau circulaire (c’est-à-dire le tampon d’anneau) rempli d’objets de transfert pré-alloués qui utilise des barrières de mémoire pour synchroniser les producteurs et les consommateurs à travers des séquences.

Les barrières de mémoire sont un peu difficiles à expliquer et le blog de Trisha a fait la meilleure tentative à mon avis avec cet article: http://mechanitis.blogspot.com/2011/08/dissecting-disruptor- why-its- so-fast.html

Mais si vous ne voulez pas vous plonger dans les détails de bas niveau, vous pouvez simplement savoir que les barrières à la mémoire dans Java sont implémentées via le mot clé volatile ou via le Java.util.concurrent.AtomicLong. Les séquences de motifs de perturbation sont AtomicLongs et sont communiquées entre producteurs et consommateurs par le biais de barrières de mémoire plutôt que de verrous.

Je trouve qu'il est plus facile de comprendre un concept à travers un code, le code ci-dessous est donc un simple helloworld de CoralQueue , qui est un mise en œuvre du modèle de perturbateur effectuée par CoralBlocks avec laquelle je suis affilié. Dans le code ci-dessous, vous pouvez voir comment le modèle de perturbateur implémente le traitement par lots et comment le tampon circulaire (c'est-à-dire un réseau circulaire) permet une communication sans faille entre deux threads:

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