Concurrence avec LMAX Disruptor - Introduction

1. Vue d’ensemble

Cet article présente le LMAX Disruptor et explique comment il est utile de parvenir à une concurrence simultanée des logiciels avec une latence faible. Nous verrons également une utilisation de base de la bibliothèque Disruptor.

2. Qu’est-ce qu’un perturbateur?

Disruptor est une bibliothèque Java open source écrite par LMAX. Il s’agit d’un cadre de programmation simultané pour le traitement d’un grand nombre de transactions, avec une latence faible (et sans les complexités du code concurrent). L’optimisation des performances est réalisée par une conception logicielle qui exploite l’efficacité du matériel sous-jacent.

2.1. Sympathie mécanique

Commençons par le concept de base de mechanical sympathy - il s’agit de comprendre le fonctionnement et la programmation du matériel sous-jacent de manière à ce qu’il fonctionne le mieux avec ce matériel.

Par exemple, voyons comment l’organisation du processeur et de la mémoire peut influer sur les performances logicielles. La CPU a plusieurs couches de cache entre elle et la mémoire principale. Lorsque la CPU effectue une opération, elle recherche d’abord les données dans L1, puis L2, puis L3 et enfin la mémoire principale. Plus il faut aller loin, plus l’opération durera longtemps.

Si la même opération est effectuée plusieurs fois sur un élément de données (par exemple, un compteur de boucles), il est judicieux de charger ces données dans un emplacement très proche de la CPU.

Quelques chiffres indicatifs sur le coût des échecs en mémoire cache:

| =================================== | Latence du CPU à | Cycles du CPU | Time | Main mémoire | Multiple | ~ 60-80 ns | Cache L3 | ~ 40-45 cycles | ~ 15 ns | Cache L2 | ~ 10 cycles | ~ 3 ns | Cache L1 | ~ 3-4 cycles | ~ 1 ns | Inscription | 1 cycle | Très très rapide | ====================================

2.2. Pourquoi pas des files d’attente

Les mises en œuvre de file d’attente ont tendance à avoir des conflits d’écriture sur les variables de tête, de queue et de taille. Les files d’attente sont généralement toujours presque pleines ou presque vides en raison des différences de rythme entre consommateurs et producteurs.

Ils opèrent très rarement dans un juste milieu où le taux de production et de consommation est égal.

Pour gérer le conflit d’écriture, une file d’attente utilise souvent des verrous, ce qui peut provoquer un basculement du contexte vers le noyau. Lorsque cela se produit, le processeur impliqué risque de perdre les données stockées dans ses caches.

Pour obtenir le meilleur comportement de mise en cache, la conception ne doit comporter qu’un seul cœur d’écriture dans un emplacement de mémoire (plusieurs lecteurs conviennent, car les processeurs utilisent souvent des liens haut débit spéciaux entre leurs caches). Les files d’attente échouent au principe d’écriture unique.

Si deux threads distincts écrivent dans deux valeurs différentes, chaque cœur invalide la ligne de cache de l’autre (les données sont transférées entre la mémoire principale et le cache dans des blocs de taille fixe, appelés lignes de cache). Il s’agit d’un conflit d’écriture entre les deux threads même s’ils écrivent dans deux variables différentes. C’est ce qu’on appelle un faux partage, car chaque fois que l’on accède à la tête, la queue est également utilisée, et vice versa.

2.3. Comment fonctionne le perturbateur

Disruptor a une structure de données circulaire basée sur un tableau (tampon en anneau). C’est un tableau qui a un pointeur sur le prochain emplacement disponible. Il est rempli d’objets de transfert pré-alloués. Les producteurs et les consommateurs procèdent à l’écriture et à la lecture des données sur l’anneau sans verrouillage ni contention.

Dans un disrupteur, tous les événements sont publiés sur tous les consommateurs (multidiffusion), pour une consommation parallèle via des files d’attente en aval séparées. En raison du traitement parallèle par les consommateurs, il est nécessaire de coordonner les dépendances entre les consommateurs (graphe de dépendance).

Les producteurs et les consommateurs ont un compteur de séquence pour indiquer l’emplacement dans la mémoire tampon sur lequel ils travaillent actuellement. Chaque producteur/consommateur peut écrire son propre compteur de séquence mais peut lire les autres compteurs de séquence.

Les producteurs et les consommateurs lisent les compteurs pour s’assurer que le logement qu’il veut écrire est disponible sans aucun verrou.

3. Utilisation de la librairie Disruptor

3.1. Dépendance Maven

Commençons par ajouter la dépendance de la bibliothèque Disruptor dans pom.xml :

<dependency>
    <groupId>com.lmax</groupId>
    <artifactId>disruptor</artifactId>
    <version>3.3.6</version>
</dependency>

3.2. Définir un événement

Définissons l’événement qui transporte les données:

public static class ValueEvent {
    private int value;
    public final static EventFactory EVENT__FACTORY
      = () -> new ValueEvent();

   //standard getters and setters
}

EventFactory permet au disrupteur de préallouer les événements.

3.3. Consommateur

Les consommateurs lisent les données de la mémoire tampon en anneau. Définissons un consommateur qui gérera les événements:

public class SingleEventPrintConsumer {
    ...

    public EventHandler<ValueEvent>[]getEventHandler() {
        EventHandler<ValueEvent> eventHandler
          = (event, sequence, endOfBatch)
            -> print(event.getValue(), sequence);
        return new EventHandler[]{ eventHandler };
    }

    private void print(int id, long sequenceId) {
        logger.info("Id is " + id
          + " sequence id that was used is " + sequenceId);
    }
}

Dans notre exemple, le consommateur imprime simplement dans un journal.

3.4. Construire le disrupteur

Construire le disrupteur:

ThreadFactory threadFactory = DaemonThreadFactory.INSTANCE;

WaitStrategy waitStrategy = new BusySpinWaitStrategy();
Disruptor<ValueEvent> disruptor
  = new Disruptor<>(
    ValueEvent.EVENT__FACTORY,
    16,
    threadFactory,
    ProducerType.SINGLE,
    waitStrategy);

Dans le constructeur de Disruptor, sont définis:

  • Event Factory - Responsable de la génération d’objets qui seront

stocké dans le tampon circulaire pendant l’initialisation ** La taille de l’anneau tampon - Nous avons défini 16 comme la taille de l’anneau

tampon. Ce doit être une puissance de 2 sinon il lancerait une exception tout en initialisation. Ceci est important car il est facile de réaliser la plupart des les opérations utilisant des opérateurs logiques binaires, par ex. opération mod ** Thread Factory - Factory pour créer des threads pour les processeurs d’événements

  • Type de producteur - Spécifie si nous aurons un ou plusieurs

producteurs ** Stratégie d’attente - Définit comment nous aimerions gérer les abonnés lents

qui ne suit pas le rythme du producteur

Connectez le handler handler:

disruptor.handleEventsWith(getEventHandler());

Il est possible de fournir plusieurs consommateurs à Disruptor pour traiter les données produites par le producteur. Dans l’exemple ci-dessus, nous ne disposons que d’un seul gestionnaire d’événements consommateur.k.a.

3.5. Démarrer le perturbateur

Pour démarrer le disrupteur:

RingBuffer<ValueEvent> ringBuffer = disruptor.start();

3.6. Production et publication d’événements

Les producteurs placent les données dans le tampon circulaire dans une séquence. Les producteurs doivent connaître le prochain créneau disponible pour ne pas écraser les données qui ne sont pas encore utilisées.

Utilisez le RingBuffer de Disruptor pour la publication:

for (int eventCount = 0; eventCount < 32; eventCount++) {
    long sequenceId = ringBuffer.next();
    ValueEvent valueEvent = ringBuffer.get(sequenceId);
    valueEvent.setValue(eventCount);
    ringBuffer.publish(sequenceId);
}

Ici, le producteur produit et publie des articles en séquence. Il est important de noter ici que Disruptor fonctionne de manière similaire au protocole de validation en 2 phases. Il lit un nouveau sequenceId et publie. La prochaine fois, il devrait obtenir sequenceId 1 comme prochain sequenceId.

4. Conclusion

Dans ce tutoriel, nous avons vu ce qu’est un disrupteur et comment il réalise une concurrence simultanée avec une latence faible. Nous avons vu le concept de sympathie mécanique et comment il peut être exploité pour obtenir une faible latence. Nous avons ensuite vu un exemple utilisant la bibliothèque Disruptor.

L’exemple de code est disponible à l’adresse le projet GitHub - il s’agit d’un projet basé sur Maven, il devrait donc être facile à importer et à exécuter tel quel.