Concurrence avec LMAX Disruptor - Une introduction

1. Vue d'ensemble

Cet article présente le LMAX Disruptor et explique comment il permet d'obtenir une concurrence logicielle avec une faible latence. Nous verrons également une utilisation basique de la bibliothèque Disruptor.

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

Disruptor est une bibliothèque Java open source écrite par LMAX. C'est un framework de programmation simultanée pour le traitement d'un grand nombre de transactions, avec une faible latence (et sans les complexités du code concurrent). L'optimisation des performances est obtenue 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 la sympathie mécanique - il s'agit de comprendre comment le matériel sous-jacent fonctionne et de programmer de la manière qui fonctionne le mieux avec ce matériel.

Par exemple, voyons comment l'organisation du processeur et de la mémoire peut avoir un impact sur les performances logicielles. Le processeur dispose de plusieurs couches de cache entre lui et la mémoire principale. Lorsque le CPU effectue une opération, il 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 prendra de temps.

Si la même opération est effectuée plusieurs fois sur une donnée (par exemple, un compteur de boucle), il est logique de charger ces données dans un endroit très proche de la CPU.

Quelques chiffres indicatifs pour le coût des échecs de cache:

Latence du CPU à Cycles CPU Temps
Mémoire principale Plusieurs ~ 60 à 80 ns
Cache L3 ~ 40 à 45 cycles ~ 15 ns
Cache L2 ~ 10 cycles ~ 3 ns
Cache L1 ~ 3-4 cycles ~ 1 ns
S'inscrire 1 cycle Très très rapide

2.2. Pourquoi pas les files d'attente

Les implémentations de file d'attente ont tendance à avoir des conflits d'écriture sur les variables head, tail et size. Les files d'attente sont généralement toujours presque pleines ou presque vides en raison des différences de rythme entre les consommateurs et les producteurs. Ils opèrent très rarement dans un milieu équilibré où le taux de production et de consommation est uniformément égalé.

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

Pour obtenir le meilleur comportement de mise en cache, la conception ne doit avoir qu'un seul cœur d'écriture dans n'importe quel emplacement de mémoire (plusieurs lecteurs conviennent, car les processeurs utilisent souvent des liens haute vitesse spéciaux entre leurs caches). Les files d'attente ne respectent pas le principe d'un écrivain.

Si deux threads distincts écrivent sur 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). C'est un conflit d'écriture entre les deux threads même s'ils écrivent sur deux variables différentes. C'est ce qu'on appelle un faux partage, car à chaque fois que l'on accède à la tête, on accède également à la queue, et vice versa.

2.3. Comment fonctionne le disrupteur

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

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

Les producteurs et les consommateurs ont un compteur de séquence pour indiquer sur quel slot dans la mémoire tampon ils travaillent actuellement. Chaque producteur / consommateur peut écrire son propre compteur de séquence mais peut lire les compteurs de séquence des autres. Les producteurs et les consommateurs lisent les compteurs pour s'assurer que l'emplacement dans lequel ils souhaitent écrire est disponible sans aucun verrou.

3. Utilisation de la bibliothèque Disruptor

3.1. Dépendance de Maven

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

 com.lmax disruptor 3.3.6 

La dernière version de la dépendance peut être vérifiée ici.

3.2. Définition d'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 } 

Le EventFactory permet au Perturbateur Préallouer les événements.

3.3. Consommateur

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

public class SingleEventPrintConsumer { ... public EventHandler[] getEventHandler() { EventHandler 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 perturbateur

Construisez le perturbateur:

ThreadFactory threadFactory = DaemonThreadFactory.INSTANCE; WaitStrategy waitStrategy = new BusySpinWaitStrategy(); Disruptor disruptor = new Disruptor( ValueEvent.EVENT_FACTORY, 16, threadFactory, ProducerType.SINGLE, waitStrategy); 

Dans le constructeur de Disruptor, les éléments suivants sont définis:

  • Event Factory - Responsable de la génération d'objets qui seront stockés dans le tampon en anneau lors de l'initialisation
  • La taille du tampon en anneau - Nous avons défini 16 comme la taille du tampon en anneau. Il doit être une puissance de 2, sinon cela lèverait une exception lors de l'initialisation. Ceci est important car il est facile d'effectuer la plupart des opérations en utilisant des opérateurs binaires logiques, par exemple l'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 un abonné lent qui ne suit pas le rythme du producteur

Connectez le gestionnaire de consommateurs:

disruptor.handleEventsWith(getEventHandler()); 

Il est possible de fournir à plusieurs consommateurs Disruptor pour gérer les données produites par le producteur. Dans l'exemple ci-dessus, nous n'avons qu'un seul gestionnaire d'événement appelé consommateur.

3.5. Démarrage du disrupteur

Pour démarrer le disruptor:

RingBuffer ringBuffer = disruptor.start();

3.6. Produire et publier des événements

Les producteurs placent les données dans le tampon en anneau dans une séquence. Les producteurs doivent être conscients du prochain emplacement disponible afin de ne pas écraser les données qui ne sont pas encore consommé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 nouvel ID de séquence et publie. La prochaine fois, il devrait obtenir sequenceId + 1 comme séquenceId suivante .

4. Conclusion

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

L'exemple de code se trouve dans le projet GitHub - il s'agit d'un projet basé sur Maven, il devrait donc être facile à importer et à exécuter tel quel.