Parallelität mit LMAX Disruptor - Eine Einführung

1. Übersicht

In diesem Artikel wird der LMAX-Disruptor vorgestellt und erläutert, wie er dazu beiträgt, Software-Parallelität mit geringer Latenz zu erreichen. Wir werden auch eine grundlegende Verwendung der Disruptor-Bibliothek sehen.

2. Was ist ein Disruptor?

Disruptor ist eine Open-Source-Java-Bibliothek, die von LMAX geschrieben wurde. Es ist ein gleichzeitiges Programmierframework für die Verarbeitung einer großen Anzahl von Transaktionen mit geringer Latenz (und ohne die Komplexität von gleichzeitigem Code). Die Leistungsoptimierung wird durch ein Software-Design erreicht, das die Effizienz der zugrunde liegenden Hardware ausnutzt.

2.1. Mechanische Sympathie

Beginnen wir mit dem Kernkonzept der mechanischen Sympathie - es geht darum, die Funktionsweise der zugrunde liegenden Hardware zu verstehen und so zu programmieren, dass sie mit dieser Hardware am besten funktioniert.

Lassen Sie uns beispielsweise sehen, wie sich die Organisation von CPU und Speicher auf die Softwareleistung auswirken kann. Die CPU verfügt über mehrere Cache-Schichten zwischen dem Hauptspeicher. Wenn die CPU eine Operation ausführt, sucht sie zuerst in L1 nach den Daten, dann in L2, dann in L3 und schließlich im Hauptspeicher. Je weiter es gehen muss, desto länger dauert die Operation.

Wenn dieselbe Operation mehrmals an einem Datenelement ausgeführt wird (z. B. einem Schleifenzähler), ist es sinnvoll, diese Daten an einen Ort in der Nähe der CPU zu laden.

Einige indikative Zahlen für die Kosten von Cache-Fehlern:

Latenz von CPU zu CPU-Zyklen Zeit
Haupterinnerung Mehrere ~ 60-80 ns
L3-Cache ~ 40-45 Zyklen ~ 15 ns
L2-Cache ~ 10 Zyklen ~ 3 ns
L1-Cache ~ 3-4 Zyklen ~ 1 ns
Registrieren 1 Zyklus Sehr sehr schnell

2.2. Warum nicht Warteschlangen?

Warteschlangenimplementierungen neigen dazu, Schreibkonflikte bei den Variablen head, tail und size zu haben. Warteschlangen sind aufgrund der unterschiedlichen Geschwindigkeit zwischen Verbrauchern und Herstellern in der Regel immer fast voll oder fast leer. Sie arbeiten sehr selten in einem ausgewogenen Mittelweg, in dem Produktions- und Verbrauchsrate gleichmäßig aufeinander abgestimmt sind.

Um mit dem Schreibkonflikt fertig zu werden, verwendet eine Warteschlange häufig Sperren, die einen Kontextwechsel zum Kernel verursachen können. In diesem Fall verliert der betroffene Prozessor wahrscheinlich die Daten in seinen Caches.

Um das beste Caching-Verhalten zu erzielen, sollte das Design nur einen Kern haben, der in einen Speicherort schreibt (mehrere Lesegeräte sind in Ordnung, da Prozessoren häufig spezielle Hochgeschwindigkeitsverbindungen zwischen ihren Caches verwenden). Warteschlangen verfehlen das Ein-Schriftsteller-Prinzip.

Wenn zwei separate Threads auf zwei verschiedene Werte schreiben, macht jeder Kern die Cache-Zeile des anderen ungültig (Daten werden zwischen Hauptspeicher und Cache in Blöcken fester Größe übertragen, die als Cache-Zeilen bezeichnet werden). Dies ist ein Schreibkonflikt zwischen den beiden Threads, obwohl sie in zwei verschiedene Variablen schreiben. Dies wird als falsches Teilen bezeichnet, da bei jedem Zugriff auf den Kopf auch auf den Schwanz zugegriffen wird und umgekehrt.

2.3. Wie der Disruptor funktioniert

Disruptor hat eine Array-basierte zirkuläre Datenstruktur (Ringpuffer). Es ist ein Array, das einen Zeiger auf den nächsten verfügbaren Steckplatz hat. Es ist mit vorab zugewiesenen Übertragungsobjekten gefüllt. Produzenten und Konsumenten schreiben und lesen Daten in den Ring, ohne sie zu sperren oder zu streiten.

In einem Disruptor werden alle Ereignisse für alle Verbraucher (Multicast) zum parallelen Verbrauch über separate nachgeschaltete Warteschlangen veröffentlicht. Aufgrund der parallelen Verarbeitung durch Verbraucher ist es erforderlich, Abhängigkeiten zwischen den Verbrauchern zu koordinieren (Abhängigkeitsdiagramm).

Produzenten und Konsumenten haben einen Sequenzzähler, der angibt, an welchem ​​Slot im Puffer gerade gearbeitet wird. Jeder Produzent / Konsument kann seinen eigenen Sequenzzähler schreiben, aber die Sequenzzähler anderer lesen. Die Produzenten und Konsumenten lesen die Zähler, um sicherzustellen, dass der Slot, in den sie schreiben möchten, ohne Sperren verfügbar ist.

3. Verwenden der Disruptor Library

3.1. Maven-Abhängigkeit

Beginnen wir mit dem Hinzufügen der Disruptor-Bibliotheksabhängigkeit in pom.xml :

 com.lmax disruptor 3.3.6 

Die neueste Version der Abhängigkeit kann hier überprüft werden.

3.2. Ereignis definieren

Definieren wir das Ereignis, das die Daten enthält:

public static class ValueEvent { private int value; public final static EventFactory EVENT_FACTORY = () -> new ValueEvent(); // standard getters and setters } 

Mit der EventFactory kann der Disruptor die Ereignisse vorab zuordnen.

3.3. Verbraucher

Verbraucher lesen Daten aus dem Ringpuffer. Definieren wir einen Verbraucher, der die Ereignisse behandelt:

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); } }

In unserem Beispiel druckt der Verbraucher nur in ein Protokoll.

3.4. Konstruieren des Disruptors

Konstruieren Sie den Disruptor:

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

Im Konstruktor von Disruptor sind folgende definiert:

  • Event Factory - Verantwortlich für die Generierung von Objekten, die während der Initialisierung im Ringpuffer gespeichert werden
  • Die Größe des Ringpuffers - Wir haben 16 als die Größe des Ringpuffers definiert. Es muss eine Potenz von 2 sein, sonst würde es bei der Initialisierung eine Ausnahme auslösen. Dies ist wichtig, da es einfach ist, die meisten Operationen mit logischen Binäroperatoren durchzuführen, z. B. Mod-Operationen
  • Thread Factory - Factory zum Erstellen von Threads für Ereignisprozessoren
  • Produzententyp - Gibt an, ob wir einzelne oder mehrere Produzenten haben
  • Wartestrategie - Definiert, wie wir mit langsamen Abonnenten umgehen möchten, die nicht mit dem Tempo des Produzenten Schritt halten

Schließen Sie den Consumer-Handler an:

disruptor.handleEventsWith(getEventHandler()); 

Es ist möglich, mehrere Verbraucher mit Disruptor zu versorgen, um die vom Hersteller erzeugten Daten zu verarbeiten. Im obigen Beispiel haben wir nur einen Consumer, auch bekannt als Event-Handler.

3.5. Starten des Disruptors

So starten Sie den Disruptor:

RingBuffer ringBuffer = disruptor.start();

3.6. Produzieren und Veröffentlichen von Events

Die Produzenten legen die Daten in einer Sequenz in den Ringpuffer. Die Hersteller müssen den nächsten verfügbaren Slot kennen, damit sie noch nicht verbrauchte Daten nicht überschreiben.

Verwenden Sie den RingBuffer von Disruptor zum Veröffentlichen:

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

Hier produziert und veröffentlicht der Produzent Artikel nacheinander. Hierbei ist zu beachten, dass Disruptor ähnlich wie das 2-Phasen-Festschreibungsprotokoll funktioniert. Es liest eine neue Sequenz-ID und veröffentlicht. Das nächste Mal sollte es die Sequenz-ID + 1 als nächste Sequenz-ID erhalten.

4. Fazit

In diesem Tutorial haben wir gesehen, was ein Disruptor ist und wie er Parallelität mit geringer Latenz erreicht. Wir haben das Konzept der mechanischen Sympathie gesehen und wie es genutzt werden kann, um eine geringe Latenz zu erreichen. Wir haben dann ein Beispiel mit der Disruptor-Bibliothek gesehen.

Der Beispielcode befindet sich im GitHub-Projekt - dies ist ein Maven-basiertes Projekt, daher sollte es einfach zu importieren und auszuführen sein, wie es ist.