Kapitel 4. Ein Überblick über nebenläufige Systeme

Diese Arbeit wurde mithilfe von KI übersetzt. Wir freuen uns über dein Feedback und deine Kommentare: translation-feedback@oreilly.com

Verteilte Systeme bestehen aus mehreren unabhängigen Teilen von Code, die parallel oder gleichzeitig auf vielen Verarbeitungsknoten an verschiedenen Orten ausgeführt werden. Jedes verteilte System ist daher per Definition ein nebenläufiges System, auch wenn jeder Knoten ein Ereignis nach dem anderen verarbeitet. Das Verhalten der verschiedenen Knotenpunkte muss natürlich koordiniert werden, damit sich die Anwendung wie gewünscht verhält.

Wie ich in Kapitel 3 beschrieben habe, birgt die Koordinierung von Knoten in einem verteilten System viele Gefahren. Glücklicherweise ist unsere Branche inzwischen so weit gereift, dass sie komplexe, leistungsstarke Software-Frameworks anbietet, die viele dieser Gefahren für verteilte Systeme vor unseren Anwendungen verbergen (zumindest die meiste Zeit). Der größte Teil dieses Buches befasst sich mit der Frage, wie wir diese Frameworks nutzen können, um skalierbare verteilte Systeme zu bauen.

In diesem Kapitel geht es jedoch um das gleichzeitige Verhalten unserer Systeme auf einem einzelnen Knotenpunkt. Indem wir unsere Software explizit so schreiben, dass sie mehrere Aktionen gleichzeitig ausführt, können wir die Verarbeitung und die Ressourcennutzung auf einem einzelnen Knoten optimieren und so unsere Verarbeitungskapazität sowohl lokal als auch systemweit erhöhen.

Ich werde die Gleichzeitigkeitsfunktionen von Java 7.0 als Beispiel verwenden, da diese auf einer niedrigeren Abstraktionsebene angesiedelt sind als die in Java 8.0 eingeführten Funktionen. Zu wissen, wie nebenläufige Systeme "näher an der Maschine" funktionieren, ist ein wichtiges Grundwissen für die Entwicklung nebenläufiger und verteilter Systeme. Sobald du die Mechanismen auf der unteren Ebene für den Aufbau nebenläufiger Systeme verstehst, ist es einfacher, die abstrakteren Ansätze optimal zu nutzen. Auch wenn dieses Kapitel Java-spezifisch ist, ändern sich die grundlegenden Probleme von nebenläufigen Systemen nicht, wenn du Systeme in anderen Sprachen schreibst. Mechanismen für den Umgang mit Gleichzeitigkeit gibt es in allen gängigen Programmiersprachen. Unter "Modelle für Gleichzeitigkeit" findest du weitere Details zu alternativen Ansätzen und wie sie in modernen Sprachen implementiert sind.

Ein letzter Punkt. Dieses Kapitel ist eine Einführung in die Parallelität. Es wird dir nicht alles beibringen, was du wissen musst, um komplexe, leistungsstarke nebenläufige Systeme zu erstellen. Es ist auch dann nützlich, wenn du noch nicht viel Erfahrung mit dem Schreiben von nebenläufigen Programmen hast oder wenn du dich bereits mit nebenläufigem Code in einer anderen Programmiersprache beschäftigt hast. Der Abschnitt "Weiterführende Literatur" am Ende des Kapitels verweist auf umfassendere Abhandlungen zu diesem Thema für diejenigen, die tiefer einsteigen möchten.

Warum Gleichzeitigkeit?

Stell dir einen gut besuchten Coffee Shop vor. Wenn alle einen einfachen Kaffee bestellen, kann der Barista jedes Getränk schnell und zuverlässig liefern . Plötzlich bestellt die Person vor dir einen Eiskaffee mit Soja, Vanille, ohne Zucker und vierfachem Schuss. Alle in der Schlange seufzen und fangen an, ihre sozialen Medien zu lesen. In zwei Minuten ist die Schlange vor der Tür zu Ende.

Die Bearbeitung von Anfragen in Webanwendungen ist vergleichbar mit unserem Kaffee-Beispiel. In einem Coffeeshop stellen wir einen neuen Barista ein, der gleichzeitig an einer anderen Maschine Kaffee zubereitet, um die Schlange unter Kontrolle zu halten und die Kunden schnell zu bedienen. In der Software müssen wir, um die Anwendungen reaktionsschnell zu machen, die Anfragen in unserem Server irgendwie überlappend verarbeiten und gleichzeitig bearbeiten.

In der guten alten Zeit der Computertechnik konnte jede CPU immer nur einen einzigen Maschinenbefehl gleichzeitig ausführen. Wenn unsere Serveranwendung auf einer solchen CPU läuft, warum müssen wir dann unsere Softwaresysteme so strukturieren, dass sie potenziell mehrere Anweisungen gleichzeitig ausführen können? Das scheint alles etwas sinnlos.

Dafür gibt es eigentlich einen sehr guten Grund. Praktisch jedes Programm tut mehr, als nur Maschinenbefehle auszuführen. Wenn ein Programm zum Beispiel versucht, eine Datei zu lesen oder eine Nachricht über das Netzwerk zu senden, muss es mit dem Hardware-Subsystem (Festplatte, Netzwerkkarte) interagieren, das mit der CPU verbunden ist. Das Lesen von Daten von einer Magnetfestplatte dauert etwa 10 Millisekunden (ms). In dieser Zeit muss das Programm warten, bis die Daten zur Verarbeitung zur Verfügung stehen.

Heute kann selbst eine alte CPU wie ein Intel 80386 von 1988 mehr als 10 Millionen Befehle pro Sekunde (mips) ausführen. 10 ms sind ein Hundertstel einer Sekunde. Wie viele Anweisungen könnte unser 80386 in einer Hundertstelsekunde ausführen? Rechne mal nach. (Tipp: Es ist eine Menge!) In der Tat eine Menge vergeudete Rechenkapazität.

Auf diese Weise können Betriebssysteme wie Linux mehrere Programme auf einer einzigen CPU ausführen. Während ein Programm auf ein E/A-Ereignis wartet, plant das Betriebssystem ein anderes Programm für die Ausführung ein. Indem wir unsere Software explizit so strukturieren, dass mehrere Aktivitäten parallel ausgeführt werden können, kann das Betriebssystem Aufgaben einplanen, die zu erledigen sind, während andere auf E/A warten. Wir werden später in diesem Kapitel genauer sehen, wie das mit Java funktioniert.

Im Jahr 2001 stellte IBM den weltweit ersten Multicore-Prozessor vor, einen Chip mit zwei CPUs - siehe Abbildung 4-1 für eine vereinfachte Darstellung. Heute hat sogar mein Laptop 16 CPUs, oder "Kerne", wie sie allgemein genannt werden. Mit einem Multicore-Chip kann ein Softwaresystem, das so strukturiert ist, dass es mehrere parallele Aktivitäten ausführt, gleichzeitig auf jedem Kern ausgeführt werden, bis die Anzahl der verfügbaren Kerne erreicht ist. Auf diese Weise können wir die Verarbeitungsressourcen auf einem Multicore-Chip voll ausnutzen und so die Verarbeitungskapazität unserer Anwendung erhöhen.

Simplified view of a multicore processor
Abbildung 4-1. Vereinfachte Darstellung eines Multicore-Prozessors

Die wichtigste Methode, ein Softwaresystem als nebenläufige Aktivitäten zu strukturieren, ist die Verwendung von Threads. Praktisch jede Programmiersprache hat ihren eigenen Threading-Mechanismus. Die zugrundeliegende Semantik all dieser Mechanismen ist ähnlich - es gibt nur wenige gängige Threading-Modelle - aber die Syntax unterscheidet sich natürlich von Sprache zu Sprache. In den folgenden Abschnitten erkläre ich, wie Threads in Java unterstützt werden und wie wir unsere Programme gestalten müssen, damit sie sicher (d.h. korrekt) und effizient sind, wenn sie parallel ausgeführt werden. Mit diesem Wissen sollte der Einstieg in die Gleichzeitigkeitsfunktionen anderer Sprachen nicht allzu schwierig sein.

Themen

Jeder Softwareprozess hat standardmäßig einen einzigen Ausführungsstrang. Das ist der Thread, den das Betriebssystem verwaltet, wenn es den Prozess für die Ausführung einplant. In Java zum Beispiel definiert die Funktion main(), die du als Einstiegspunkt in deinen Code angibst, das Verhalten dieses Threads. Dieser einzelne Thread hat Zugriff auf die Programmumgebung und Ressourcen wie offene Datei-Handles und Netzwerkverbindungen. Wenn das Programm Methoden in Objekten aufruft, die im Code instanziiert werden, wird der Laufzeitstapel des Programms verwendet, um Parameter zu übergeben und Variablenbereiche zu verwalten. Das ist der Standard-Laufzeitkram einer Programmiersprache, den wir alle kennen und lieben. Dies ist ein sequentieller Prozess.

In deinen Systemen kannst du Funktionen der Programmiersprache nutzen, um zusätzliche Threads zu erstellen und auszuführen. Jeder Thread ist eine unabhängige Ausführungssequenz und hat seinen eigenen Laufzeitstapel, um die Erstellung lokaler Objekte und Methodenaufrufe zu verwalten. Jeder Thread hat außerdem Zugriff auf die globalen Daten und die Umgebung des Prozesses. Eine einfache Darstellung dieses Schemas ist in Abbildung 4-2 zu sehen.

Comparing a single threaded and multithreaded process
Abbildung 4-2. Vergleich zwischen einem Single-Thread- und einem Multithread-Prozess

In Java können wir einen Thread mit einer Klasse definieren, die die Runnable Schnittstelle implementiert und die run() Methode definiert. Schauen wir uns ein einfaches Beispiel an:

class NamingThread implements Runnable {

private String name;
       
public NamingThread(String threadName) {
      name = threadName ;
           System.out.println("Constructor called: " + threadName) ;
       }
        
       public void run() {
      //Display info about this  thread
           System.out.println("Run called : " + name);
           System.out.println(name + " : " + Thread.currentThread());
           // and now terminate  ....
    }
}

Um den Thread auszuführen, müssen wir ein Thread Objekt mit einer Instanz unseres Runnable konstruieren und die start() Methode aufrufen, um den Code in seinem eigenen Ausführungskontext aufzurufen. Dies wird im nächsten Codebeispiel gezeigt, zusammen mit der fett gedruckten Ausgabe des Codes. In diesem Beispiel gibt es zwei Threads: den main() Thread und den NamingThread. Der Hauptthread startet den NamingThread Thread, der asynchron ausgeführt wird, und wartet dann 1 Sekunde, damit unsere run() Methode in NamingThread genügend Zeit hat, um ausgeführt zu werden:

public static void main(String[] args) {
      
  NamingThread name0 = new NamingThread("My first thread");
    
  //Create the thread
  Thread t0 = new Thread (name0);
    
  // start the threads
  t0.start();
      
  //delay the main thread for a second (1000 milliseconds)
  try {
    Thread.currentThread().sleep(1000);
  } catch (InterruptedException e) {}

      //Display info about the main thread and terminate
      System.out.println(Thread.currentThread());
    
}

===EXECUTION OUTPUT===
Constructor called: My first thread
Run called : My first thread
My first thread : Thread[Thread-0,5,main]
Thread[main,5,main]

Zur Veranschaulichung rufen wir auch die statische Methode currentThread() auf, die eine Zeichenkette zurückgibt:

  • Der vom System generierte Thread-Bezeichner.

  • Die Thread-Priorität, die standardmäßig für alle Threads 5 beträgt. Wir werden die Thread-Prioritäten später behandeln.

  • Der Bezeichner des übergeordneten Threads - in diesem Beispiel sind beide übergeordneten Threads die main thread.

Um einen Thread zu instanziieren, rufen wir die Methode start() auf, nicht die Methode run(), die wir in Runnable definieren. Die Methode start() enthält die interne Systemmagie, um den Ausführungskontext für die Ausführung eines eigenen Threads zu erstellen. Wenn wir run() direkt aufrufen, wird der Code zwar ausgeführt, aber es wird kein neuer Thread erstellt. Die Methode run() wird als Teil des Threads main ausgeführt, genau wie jeder andere Java-Methodenaufruf, den du kennst und liebst. Du hast also immer noch einen Single-Thread-Code.

Im Beispiel verwenden wir sleep(), um die Ausführung des Threads main anzuhalten und sicherzustellen, dass er nicht vor dem Thread NamimgThread beendet wird. Dieser Ansatz, nämlich zwei Threads durch Verzögerung für einen absoluten Zeitraum (im Beispiel 1 Sekunde) zu koordinieren, ist kein sehr robuster Mechanismus. Was passiert, wenn unser Thread aus irgendeinem Grund - eine langsamere CPU, eine lange Verzögerung beim Lesen der Festplatte, zusätzliche komplexe Logik in der Methode - nicht in der erwarteten Zeitspanne beendet wird? In diesem Fall wird main zuerst beendet - das ist nicht unsere Absicht. Generell gilt: Wenn du absolute Zeiten für die Thread-Koordination verwendest, machst du es falsch. Fast immer. In 99,99999% der Fälle.

Ein einfacher und robuster Mechanismus, mit dem ein Thread warten kann, bis ein anderer seine Arbeit beendet hat, ist die Methode join(). Wir könnten den try-catch Block im obigen Beispiel ersetzen durch:

t0.join();

Diese Methode bewirkt, dass der aufrufende Thread (in diesem Fall main) blockiert wird, bis der Thread, auf den t0 verweist, beendet wird. Wenn der referenzierte Thread vor dem Aufruf von join() beendet wurde, kehrt der Methodenaufruf sofort zurück. Auf diese Weise können wir das Verhalten mehrerer Threads koordinieren bzw. synchronisieren. Die Synchronisierung mehrerer Threads ist das Hauptthema des restlichen Teils dieses Kapitels.

Reihenfolge der Thread-Ausführung

Das Zeitplannungsprogramm des Systems (in Java ist dies in der Java Virtual Machine [JVM]) steuert die Reihenfolge der Thread-Ausführung. Aus der Sicht des Programmierers ist die Reihenfolge der Ausführung nicht deterministisch. Gewöhn dich an diesen Begriff, ich werde ihn noch oft verwenden. Das Konzept des Nondeterminismus ist grundlegend für das Verständnis von Multithreading-Code.

Zur Veranschaulichung baue ich auf dem früheren Beispiel NamingThread auf. Anstatt ein einzelnes NamingThread zu erstellen, werde ich mehrere erstellen und starten. Drei, um genau zu sein, wie im folgenden Codebeispiel gezeigt. Auch hier steht die Ausgabe des Codes in fettgedrucktem Text unter dem Code selbst:

      NamingThread name0 = new NamingThread("thread0");
      NamingThread name1 = new NamingThread("thread1");
      NamingThread name2 = new NamingThread("thread2");
    
      //Create the threads
      Thread t0 = new Thread (name0);
      Thread t1 = new Thread (name1);
      Thread t2 = new Thread (name2);    
      
      // start the threads
      t0.start();  1
      t1.start();  1
      t2.start();  1

===EXECUTION OUTPUT===
Run called : thread0
thread0 : Thread[Thread-0,5,main]  2
Run called : thread2  3
Run called : thread1
thread1 : Thread[Thread-1,5,main]  4
thread2 : Thread[Thread-2,5,main]
Thread[main,5,main]

Die gezeigte Ausgabe ist ein Beispiel für eine einzige Ausführung. Du kannst sehen, dass der Code drei Threads nacheinander startet, nämlich t0, t1 und t2 (siehe 1). In der Ausgabe sehen wir, dass der Thread t0 abgeschlossen ist (siehe 2), bevor die anderen starten. Als nächstes wird die Methode run() von t2aufgerufen (siehe 3), gefolgt von der Methode run() von t1, obwohl t1 vor t2 gestartet wurde. Der Thread t1 wird dann vor t2 zu Ende geführt (siehe 4) vor t2, und schließlich werden der Hauptthread und das Programm beendet.

Das ist nur eine mögliche Reihenfolge der Ausführung. Wenn wir dieses Programm noch einmal ausführen, werden wir höchstwahrscheinlich eine andere Ausführungsreihenfolge sehen. Das liegt daran, dass das Zeitplannungsprogramm der JVM entscheidet, welcher Thread wie lange ausgeführt werden soll. Vereinfacht gesagt, kann das Zeitplannungsprogramm, nachdem es einem Thread einen Zeitschlitz auf einer CPU zugewiesen hat, den Thread nach einer bestimmten Zeitspanne unterbrechen und einen anderen Thread zur Ausführung einplanen. Diese Unterbrechung wird als Vorkaufsrecht bezeichnet. Durch die Vorkaufsrechte wird sichergestellt, dass jeder Thread die Möglichkeit erhält, Fortschritte zu machen. Die Threads laufen also unabhängig und asynchron bis zur Fertigstellung, und das Zeitplannungsprogramm entscheidet anhand eines Zeitplanungsalgorithmus, welcher Thread wann läuft.

Das Zeitplannungsprogramm für Threads ist aber noch nicht alles. Ich werde den grundlegenden Zeitplanungsalgorithmus später in diesem Kapitel erklären. Für den Moment gibt es eine wichtige Konsequenz für Programmierer: Unabhängig von der Reihenfolge der Thread-Ausführung - die du nicht kontrollieren kannst - sollte dein Code korrekte Ergebnisse liefern. Klingt einfach? Lies weiter.

Probleme mit Threads

Das grundlegende Problem bei der nebenläufigen Programmierung ist die Koordination der Ausführung mehrerer Threads, so dass sie unabhängig von der Reihenfolge ihrer Ausführung die richtige Antwort liefern. Da Threads nicht-deterministisch gestartet und vorzeitig beendet werden können, gibt es in jedem einigermaßen komplexen Programm eine unendliche Anzahl von möglichen Ausführungsreihenfolgen. Diese Systeme sind nicht einfach zu testen.

Es gibt zwei grundlegende Probleme, die alle nebenläufigen Programme vermeiden müssen. Das sind Race Conditions und Deadlocks. Diese Themen werden in den nächsten beiden Unterabschnitten behandelt.

Rennbedingungen

Die nicht-deterministische Ausführung von Threads impliziert , dass die Code-Anweisungen, aus denen die Threads bestehen:

  • Wird nacheinander ausgeführt, wie in jedem Thread definiert.

  • Sie können sich in beliebiger Reihenfolge über die Threads hinweg überschneiden. Das liegt daran, dass die Anzahl der Anweisungen, die für jeden Thread-Ausführungsslot ausgeführt werden, vom Zeitplannungsprogramm festgelegt wird.

Wenn also viele Threads auf einem einzigen Prozessor ausgeführt werden, wird ihre Ausführung verschachtelt. Die CPU führt einige Schritte von einem Thread aus, dann einige Schritte von einem anderen und so weiter. Wenn wir auf einer Multicore-CPU arbeiten, können wir einen Thread pro Kern ausführen. Die Anweisungen der einzelnen Threads sind jedoch immer noch auf nicht-deterministische Weise verschachtelt.

Wenn nun jeder Thread einfach sein eigenes Ding macht und völlig unabhängig ist, ist das kein Problem. Jeder Thread wird ausgeführt, bis er beendet wird, wie in unserem trivialen Naming​Thread Beispiel. Das Ganze ist ein Kinderspiel! Warum sollen diese Threads so kompliziert sein?

Leider verhalten sich die meisten Multithreading-Systeme nicht wie völlig unabhängige Threads. In Abbildung 4-2 siehst du, dass sich mehrere Threads die globalen Daten innerhalb eines Prozesses teilen. In Java sind das sowohl globale als auch statische Daten.

Threads können gemeinsame Datenstrukturen verwenden, um ihre Arbeit zu koordinieren und den Status zwischen den Threads zu kommunizieren. Wir können zum Beispiel Threads haben, die Anfragen von Web-Clients bearbeiten, einen Thread pro Anfrage. Außerdem wollen wir einen Überblick darüber behalten, wie viele Anfragen wir jeden Tag bearbeiten. Wenn ein Thread eine Anfrage abschließt, erhöht er ein globales RequestCounter Objekt, das alle Threads gemeinsam nutzen und nach jeder Anfrage aktualisieren. Am Ende des Tages wissen wir dann, wie viele Anfragen bearbeitet wurden. Eine wirklich einfache und elegante Lösung. Nun, vielleicht?

Der folgende Code zeigt eine sehr einfache Implementierung, die das Beispielszenario des Anforderungszählers nachahmt. Es werden 50.000 Threads erstellt, um einen gemeinsamen Zähler zu aktualisieren. Der Einfachheit halber verwenden wir eine Lambda-Funktion, um die Threads zu erstellen, und eine (wirklich schlechte Idee) 5-Sekunden-Verzögerung in main, damit die Threads fertig werden können:1

public class RequestCounter {
  final static private int NUMTHREADS = 50000;
  private int count = 0;
    
  public  void inc() {
    count++;
  }
    
  public int getVal() {
    return this.count;
  }
    
  public static void main(String[] args) throws InterruptedException {
    final RequestCounter counter = new RequestCounter();
          
    for (int i = 0; i < NUMTHREADS; i++) {
      // lambda runnable creation 
      Runnable thread = () -> {counter.inc(); };
        new Thread(thread).start();
    }
          
    Thread.sleep(5000);
    System.out.println("Value should be " + NUMTHREADS + "It is: " +     counter.getVal());
  }
}

Was du zu Hause tun kannst, ist, diesen Code aus dem GitHub-Repository des Buches zu klonen, ihn ein paar Mal auszuführen und zu sehen, welche Ergebnisse du erhältst. Bei 10 Durchläufen lag mein Mittelwert bei 49.995. Ich habe nicht ein einziges Mal die richtige Antwort von 50.000 erhalten. Seltsam.

Und warum?

Die Antwort liegt in der Art und Weise, wie abstrakte, hochsprachliche Anweisungen, in diesem Fall in Java, auf einer Maschine ausgeführt werden. In diesem Beispiel muss die CPU einen Zähler inkrementieren, um ihn zu erhöhen:

  • Lade den aktuellen Wert in ein Register.

  • Erhöhe den Wert des Registers.

  • Schreibe die Ergebnisse zurück an den ursprünglichen Speicherplatz.

Dieses einfache Inkrement ist eigentlich eine Abfolge von drei Vorgängen auf Maschinenebene.

Wie Abbildung 4-3 zeigt, sind diese drei Vorgänge auf Maschinenebene unabhängig und werden nicht als ein einziger atomarer Vorgang behandelt. Unter einem atomaren Vorgang verstehen wir einen Vorgang, der nicht unterbrochen werden kann und daher, einmal begonnen, bis zum Ende ausgeführt wird.

Da der Vorgang des Inkrementierens auf Maschinenebene nicht atomar ist, kann ein Thread den Zählerwert aus dem Speicher in ein CPU-Register laden, aber bevor er den inkrementierten Wert zurückschreibt, setzt das Zeitplannungsprogramm den Thread vorzeitig außer Kraft und erlaubt einem anderen Thread, zu starten. Dieser Thread lädt den alten Wert des Zählers aus dem Speicher und schreibt den inkrementierten Wert zurück. Schließlich wird der ursprüngliche Thread erneut ausgeführt und schreibt seinen erhöhten Wert zurück, der zufällig mit dem bereits im Speicher befindlichen Wert übereinstimmt.

Das bedeutet, dass wir eine Aktualisierung verloren haben. Aus unseren 10 Tests des obigen Zählercodes geht hervor, dass dies im Durchschnitt 5 Mal in 50.000 Schritten passiert. Solche Ereignisse sind also selten, aber selbst wenn es 1 Mal in 10 Millionen Fällen passiert, hast du immer noch ein falsches Ergebnis.

Increments are not atomic at the machine level
Abbildung 4-3. Inkremente sind auf Maschinenebene nicht atomar

Wenn wir auf diese Weise Aktualisierungen verlieren, nennt man das eine Race Condition. Race Conditions können auftreten, wenn mehrere Threads Änderungen an einem gemeinsamen Zustand vornehmen, in diesem Fall an einem einfachen Zähler. Im Grunde genommen können verschiedene Verschachtelungen der Threads zu unterschiedlichen Ergebnissen führen.

Race Conditions sind heimtückische, böse Fehler, denn sie treten in der Regel nur selten auf und sind schwer zu erkennen, da die Antwort meistens richtig ist. Wenn du das Beispiel mit dem Multithreading-Zähler mit 1.000 statt 50.000 Threads durchführst, siehst du das in Aktion. Ich habe in neun von zehn Fällen die richtige Antwort erhalten.

Diese Situation lässt sich also als "gleicher Code, gelegentlich unterschiedliche Ergebnisse" zusammenfassen. Wie ich schon sagte: Race Conditions sind böse! Zum Glück ist es ganz einfach, sie zu beseitigen, wenn du ein paar Vorsichtsmaßnahmen triffst.

Der Schlüssel dazu ist, kritische Abschnitte zu identifizieren und zu schützen. Ein kritischer Abschnitt ist ein Teil des Codes, der gemeinsame Datenstrukturen aktualisiert und daher atomar ausgeführt werden muss, wenn mehrere Threads darauf zugreifen. Das Beispiel der Erhöhung eines gemeinsamen Zählers ist ein Beispiel für einen kritischen Abschnitt. Ein anderes Beispiel ist das Entfernen eines Elements aus einer Liste. Wir müssen den Kopfknoten der Liste löschen und den Verweis auf den Kopf der Liste von dem entfernten Knoten auf den nächsten Knoten in der Liste verschieben. Beide Vorgänge müssen atomar ausgeführt werden, damit die Integrität der Liste erhalten bleibt. Dies ist ein kritischer Abschnitt.

In Java definiert das Schlüsselwort synchronized einen kritischen Abschnitt. Wird er zur Dekoration einer Methode verwendet, darf nur ein Thread den kritischen Abschnitt betreten, wenn mehrere Threads versuchen, diese Methode für dasselbe gemeinsame Objekt aufzurufen. Alle anderen blockieren, bis der Thread die synchronisierte Methode verlässt. Dann wählt das Zeitplannungsprogramm den nächsten Thread für die Ausführung des kritischen Abschnitts aus. Wir sagen, dass die Ausführung des kritischen Abschnitts seriell erfolgt, da nur ein Thread zur gleichen Zeit den Code darin ausführen kann.

Um das Gegenbeispiel zu lösen, musst du also nur die Methode inc() als kritischen Abschnitt identifizieren und sie zu einer synchronisierten Methode machen, d. h.:

synchronized public void inc() {
    count++;
  }

Probiere es so oft aus, wie du willst. Du wirst immer die richtige Antwort erhalten. Etwas formeller ausgedrückt bedeutet das, dass jede Verschachtelung der Threads, die uns das Zeitplannungsprogramm vorgibt, immer zu den richtigen Ergebnissen führt.

Das Schlüsselwort synchronized kann auch auf Anweisungsblöcke innerhalb einer Methode angewendet werden. Wir könnten zum Beispiel das obige Beispiel umschreiben als:

public void inc() {
        synchronized(this){
           count++;   
        }
}

Unter der Haube hat jedes Java-Objekt eine Monitorsperre, manchmal auch als intrinsische Sperre bezeichnet, als Teil seiner Laufzeitrepräsentation. Der Monitor ist wie die Toilette in einem Fernbus - nur eine Person darf (und sollte!) ihn gleichzeitig betreten, und das Türschloss verhindert, dass andere ihn betreten, wenn er benutzt wird.

In unserer absolut sauberen Java-Laufzeitumgebung muss ein Thread die Monitorsperre erwerben, um eine synchronisierte Methode oder einen synchronisierten Anweisungsblock zu betreten. Nur ein Thread kann die Sperre zu jeder Zeit besitzen, daher ist die Ausführung serialisiert. Das ist im Grunde die Art und Weise, wie Java und ähnliche Sprachen kritische Abschnitte implementieren.

Als Faustregel gilt, dass du kritische Abschnitte so klein wie möglich halten solltest, damit der serialisierte Code minimiert wird. Das kann sich positiv auf die Leistung und damit auf die Skalierbarkeit auswirken. Ich komme später auf dieses Thema zurück, aber eigentlich geht es hier wieder um das Amdahlsche Gesetz, das in Kapitel 2 eingeführt wurde. Synchronisierte Blöcke sind die seriellen Teile eines Systems, wie Amdahl sie beschreibt. Je länger sie ausgeführt werden ( ), desto weniger Potenzial haben wir für die Skalierbarkeit des Systems.

Deadlocks

Um korrekte Ergebnisse in Multithreading-Code zu gewährleisten, habe ich erklärt, dass wir den inhärenten Nondeterminismus einschränken müssen, um den Zugriff auf kritische Abschnitte zu serialisieren. Dadurch werden Race Conditions vermieden. Wenn wir jedoch nicht vorsichtig sind, können wir einen Code schreiben, der den Nichtdeterminismus so stark einschränkt, dass unser Programm stehen bleibt. Und niemals fortgesetzt wird. Dies wird formal als Deadlock bezeichnet.

Ein Deadlock entsteht, wenn zwei oder mehr Threads für immer blockiert sind und keiner von ihnen fortfahren kann. Das passiert, wenn Threads exklusiven Zugriff auf eine gemeinsame Gruppe von Ressourcen benötigen und die Threads in unterschiedlicher Reihenfolge Sperren erwerben. Das folgende Beispiel zeigt, dass zwei Threads exklusiven Zugriff auf die kritischen Abschnitte A und B benötigen. Thread 1 erhält die Sperre für den kritischen Abschnitt A und Thread 2 die Sperre für den kritischen Abschnitt B. Beide blockieren dann für immer, da sie die Sperren nicht erhalten können, die sie zum Fortfahren benötigen.

Zwei Threads teilen sich den Zugriff auf zwei gemeinsame Variablen über synchronisierte Blöcke:

  • Thread 1: betritt den kritischen Abschnitt A.

  • Thread 2: betritt den kritischen Abschnitt B.

  • Thread 1: blockiert beim Eintritt in den kritischen Abschnitt B.

  • Thread 2: blockiert beim Eintritt in den kritischen Abschnitt A.

  • Beide Threads warten ewig.

Ein Deadlock, auch bekannt als tödliche Umarmung, führt dazu, dass ein Programm anhält. Man braucht keine blühende Fantasie, um zu erkennen, dass dies zu allen möglichen unerwünschten Ergebnissen führen kann. Ich schreibe fröhlich eine SMS, während mein autonomes Fahrzeug mich in die Bar fährt. Plötzlich blockiert der Fahrzeugcode. Das wird nicht gut ausgehen.

Deadlocks treten unter subtileren Umständen auf als in dem einfachen Beispiel oben. Das klassische Beispiel ist das Problem der Essensphilosophen. Die Geschichte geht folgendermaßen.

Fünf Philosophen sitzen um einen gemeinsamen Tisch. Da sie Philosophen sind, verbringen sie viel Zeit mit tiefem Nachdenken. Zwischen den Denkpausen füllen sie ihre Gehirnfunktionen wieder auf, indem sie von einem Teller mit Essen essen, der vor ihnen steht. Ein Philosoph ist also entweder am Essen oder am Denken oder er wechselt zwischen diesen beiden Zuständen.

Außerdem müssen sich die Philosophen körperlich sehr nahe stehen, sehr geschickt sein und COVID-19-geimpfte Freunde sein, denn sie teilen sich Stäbchen zum Essen. Auf dem Tisch liegen nur fünf Essstäbchen, die zwischen jedem Philosophen liegen. Wenn einer der Philosophen essen möchte, nimmt er nach einem bestimmten Protokoll zuerst sein linkes Stäbchen und dann sein rechtes Stäbchen. Sobald sie wieder bereit sind zu denken, geben sie erst das rechte Stäbchen zurück, dann das linke.

Abbildung 4-4 zeigt unsere Philosophen, die jeweils durch eine eindeutige Nummer gekennzeichnet sind. Da jeder gleichzeitig isst oder denkt, können wir jeden Philosophen als einen Thread modellieren.

The dining philosophers problem
Abbildung 4-4. Das Problem der Essensphilosophen

Der Code ist in Beispiel 4-1 dargestellt. Die gemeinsam genutzten Essstäbchen werden durch Instanzen der Java-Klasse Object dargestellt. Da immer nur ein Objekt die Monitorsperre für ein Objekt halten kann, werden sie als Eingangsbedingungen für die kritischen Abschnitte verwendet, in denen die Philosophen die Stäbchen erwerben, die sie zum Essen brauchen. Nach dem Essen werden die Stäbchen wieder auf den Tisch gelegt und die Sperre für die Stäbchen aufgehoben, damit die benachbarten Philosophen essen können, wann immer sie bereit sind.

Beispiel 4-1. Der Philosophen-Thread
public class Philosopher implements Runnable {

  private final Object leftChopStick;
  private final Object rightChopStick;

  Philosopher(Object leftChopStick, Object rightChopStick) {
    this.leftChopStick = leftChopStick;
    this.rightChopStick = rightChopStick;
  }
  private void LogEvent(String event) throws InterruptedException {
    System.out.println(Thread.currentThread()
                                  .getName() + " " + event);
    Thread.sleep(1000);
  }

  public void run() {
    try {
      while (true) {
        LogEvent(": Thinking deeply"); 
        synchronized (leftChopStick) {
          LogEvent( ": Picked up left chopstick");
          synchronized (rightChopStick) {
            LogEvent(": Picked up right chopstick – eating");
            LogEvent(": Put down right chopstick");
          }
          LogEvent(": Put down left chopstick. Ate too much");
        }
      } // end while
    } catch (InterruptedException e) {
       Thread.currentThread().interrupt();
  }
 }
}

Um die in Beispiel 4-1 beschriebenen Philosophen zum Leben zu erwecken, müssen wir für jeden einen Thread instanziieren und jedem Philosophen Zugriff auf seine benachbarten Stäbchen geben. Dies geschieht durch den Aufruf des Thread-Konstruktors unter 1 in Beispiel 4-2. In der Schleife for erstellen wir fünf Philosophen und starten diese als unabhängige Threads, wobei jedes Stäbchen für zwei Threads zugänglich ist, eines als linkes Stäbchen und eines als rechtes.

Beispiel 4-2. Dining Philosophen-verschlossene Version
private final static int NUMCHOPSTICKS = 5 ;
private final static int NUMPHILOSOPHERS = 5; 
public static void main(String[] args) throws Exception {
 
  final Philosopher[] ph = new Philosopher[NUMPHILOSOPHERS];
  Object[] chopSticks = new Object[NUMCHOPSTICKS];
 
  for (int i = 0; i < NUMCHOPSTICKS; i++) {
    chopSticks[i] = new Object();
  }
 
  for (int i = 0; i < NUMPHILOSOPHERS; i++) {
    Object leftChopStick = chopSticks[i];
    Object rightChopStick = chopSticks[(i + 1) % chopSticks.length];
    
    ph[i] = new Philosopher(leftChopStick, rightChopStick);  1
          
    Thread th = new Thread(ph[i], "Philosopher " + i);
    th.start();
  }
}

Wenn ich diesen Code ausführe, erhalte ich bei meinem ersten Versuch die folgende Ausgabe. Wenn du den Code ausführst, wirst du mit Sicherheit andere Ergebnisse sehen, aber das Endergebnis wird dasselbe sein:

Philosopher 3 : Thinking deeply
Philosopher 4 : Thinking deeply
Philosopher 0 : Thinking deeply
Philosopher 1 : Thinking deeply
Philosopher 2 : Thinking deeply
Philosopher 3 : Picked up left chopstick
Philosopher 0 : Picked up left chopstick
Philosopher 2 : Picked up left chopstick
Philosopher 4 : Picked up left chopstick
Philosopher 1 : Picked up left chopstick

Zehn Zeilen Ausgabe, dann...nichts! Wir haben einen Deadlock. Dies ist eine klassische Warteschleife. Stell dir das folgende Szenario vor:

  • Jeder Philosoph gibt sich einer langen Denkpause hin.

  • Gleichzeitig beschließen sie alle, dass sie Hunger haben und greifen nach ihrem linken Stäbchen.

  • Kein Philosoph kann essen (fortfahren), da keiner sein rechtes Stäbchen in die Hand nehmen kann.

Echte Philosophen würden in dieser Situation einen Weg finden, indem sie ein oder zwei Stäbchen weglegen, bis einer oder mehrere ihrer Kollegen essen können. In unserer Software können wir das manchmal erreichen, indem wir Timeouts für blockierende Operationen verwenden. Wenn die Zeitüberschreitung abläuft, gibt ein Thread den kritischen Abschnitt frei und versucht es erneut, damit andere blockierte Threads die Chance haben, weiterzumachen. Das ist jedoch nicht optimal, da blockierte Threads die Leistung beeinträchtigen und die Festlegung von Timeout-Werten eine ungenaue Wissenschaft ist.

Es ist daher viel besser, eine Lösung zu entwerfen, die frei von Deadlocks ist. Das bedeutet, dass ein oder mehrere Threads immer in der Lage sind, Fortschritte zu machen. Bei Deadlocks mit zirkulären Warteschleifen kann dies erreicht werden, indem ein Protokoll zur Ressourcenzuweisung für die gemeinsam genutzten Ressourcen eingeführt wird, so dass die Threads nicht immer in der gleichen Reihenfolge Ressourcen anfordern.

Bei dem Problem mit den speisenden Philosophen können wir dies erreichen, indem wir dafür sorgen, dass einer unserer Philosophen sein rechtes Stäbchen zuerst aufhebt. Nehmen wir an, wir beauftragen den Philosophen 4, dies zu tun. Daraus ergibt sich eine mögliche Abfolge von Operationen wie die folgende:

  • Philosoph 0 nimmt das linke Stäbchen auf (chopStick[0] ) und dann das rechte (chopStick[1] )

  • Philosoph 1 nimmt das linke Stäbchen auf (chopStick[1] ) und dann das rechte (chopStick[2] )

  • Philosoph 2 hebt das linke Stäbchen auf (chopStick[2] ) und dann das rechte (chopStick[3] )

  • Philosoph 3 hebt das linke Stäbchen auf (chopStick[3] ) und dann das rechte (chopStick[4] )

  • Philosoph 4 hebt das rechte Stäbchen auf (chopStick[0] ) und dann das linke (chopStick[4] )

In diesem Beispiel muss Philosoph 4 blockieren, da Philosoph 0 bereits Zugang zu chopstick[0] hat. Da Philosoph 4 blockiert ist, hat Philosoph 3 Zugang zu chopstick[4] und kann seinen Appetit befriedigen.

Die Lösung für die Essensphilosophen wird in Beispiel 4-3 gezeigt.

Beispiel 4-3. Die Sackgasse der Essensphilosophen lösen
if (i == NUMPHILOSOPHERS - 1) {
  // The last philosopher picks up the right chopstick first
  ph[i] = new Philosopher(rightChopStick, leftChopStick); 
} else {
  // all others pick up the left chopstick first 
  ph[i] = new Philosopher(leftChopStick, rightChopStick);
}

Genauer gesagt, legen wir eine Reihenfolge für den Erwerb gemeinsamer Ressourcen fest, so dass:

chopStick[0] < chopStick[1] < chopStick[2] < chopStick[3] < chopStick[4]

Das bedeutet, dass jeder Thread immer versuchen wird, chopstick[0] vor chopstick[1] und chopstick[1] vor chopstick[2] zu erhalten, und so weiter. Für Philosopher 4 bedeutet das, dass er versuchen wird, chopstick[0] vor chopstick[4] zu erreichen, um so die Gefahr einer Warteschleife zu vermeiden.

Deadlocks sind ein kompliziertes Thema und dieser Abschnitt hat nur an der Oberfläche gekratzt. Du wirst Deadlocks in vielen verteilten Systemen finden. Ein Beispiel: Eine Benutzeranfrage erhält eine Sperre für einige Daten in der Datenbanktabelle " Schüler" und muss dann Zeilen in der Tabelle " Klassen" aktualisieren, um die Anwesenheit der Schüler wiederzugeben. Gleichzeitig erwirbt eine andere Benutzeranforderung eine Sperre für die Tabelle Classes und muss dann einige Informationen in der Tabelle Students aktualisieren. Wenn sich diese Anfragen so überschneiden, dass jede Anfrage Sperren erwirbt, haben wir ein Deadlock.

Thema Staaten

Multithreading-Systeme haben ein Zeitplannungsprogramm, das entscheidet, welche Threads wann ausgeführt werden sollen. In Java ist das Zeitplannungsprogramm als präemptives, prioritätsbasiertes Zeitplannungsprogramm bekannt. Kurz gesagt bedeutet das, dass der Thread mit der höchsten Priorität ausgeführt wird.

Jeder Thread hat eine Priorität (standardmäßig 5, Bereich 0 bis 10). Ein Thread erbt seine Priorität von seinem Eltern-Thread. Threads mit höherer Priorität werden häufiger eingeplant als Threads mit niedrigerer Priorität, aber bei den meisten Anwendungen reicht es aus, wenn alle Threads die Standardpriorität haben.

Das Zeitplannungsprogramm durchläuft die Threads je nach ihrem Verhalten in vier verschiedenen Zuständen. Diese sind:

Erstellt
Ein Thread-Objekt wurde erstellt , aber seine Methode start() wurde noch nicht aufgerufen. Sobald start() aufgerufen wird, geht der Thread in den Zustand runnable über.
Lauffähig
Ein Thread ist in der Lage zu laufen. Das Zeitplannungsprogramm wählt die auszuführenden Threads nach dem FIFO-Prinzip (first-in, first-out) aus - jedem Kern des Knotens kann zu jeder Zeit ein Thread zugewiesen werden. Die Threads werden dann ausgeführt, bis sie blockieren (z. B. bei einer synchronized Anweisung), eine yield(), suspend() oder sleep() Anweisung ausführen, die run() Methode beendet wird oder sie vom Zeitplannungsprogramm vorgezogen werden. Die Vorkaufsrechte treten ein, wenn ein Thread mit höherer Priorität lauffähig wird oder wenn eine systemspezifische Zeitspanne, die so genannte Zeitscheibe, abläuft. Durch das Zeitplannungsprogramm kann sichergestellt werden, dass alle Threads eine Chance bekommen, ausgeführt zu werden - keine Threads, die nur auf die Ausführung warten, können die CPU in Beschlag nehmen.
Blockiert
Ein Thread ist blockiert, wenn er auf eine Sperre, ein Benachrichtigungsereignis (z. B. Ablauf des Sleep-Timers, Ausführung der Methode resume() ) oder auf den Abschluss einer Netzwerk- oder Festplattenanforderung wartet. Wenn das Ereignis, auf das ein blockierter Thread wartet, eintritt, wechselt er wieder in den Zustand "lauffähig".
Beendet
Die Methode run() eines Threads wurde abgeschlossen oder er hat die Methode stop() aufgerufen. Der Thread wird dann nicht mehr eingeplant.

Abbildung 4-5 zeigt eine Illustration dieses Schemas. Das Zeitplannungsprogramm hält für jede Thread-Priorität eine FIFO-Warteschlange im lauffähigen Zustand bereit. Threads mit hoher Priorität werden in der Regel verwendet, um auf Ereignisse zu reagieren (z. B. auf einen Notfall-Timer) und für eine kurze Zeitspanne ausgeführt. Threads mit niedriger Priorität werden für laufende Aufgaben im Hintergrund verwendet, z. B. für die Überprüfung von Dateien auf Beschädigung durch Neuberechnung von Prüfsummen. Hintergrund-Threads verbrauchen vor allem ungenutzte CPU-Zyklen.

Threads states and transitions
Abbildung 4-5. Zustände und Übergänge von Threads

Gewinde-Koordination

Es gibt viele Probleme, bei denen Threads mit unterschiedlichen Rollen ihre Aktivitäten koordinieren müssen. Stell dir eine Sammlung von Threads vor, die jeweils Dokumente von Nutzern annehmen, diese verarbeiten (z. B. ein PDF erzeugen) und dann das verarbeitete Dokument an einen gemeinsamen Druckerpool senden. Jeder Drucker kann nur ein Dokument auf einmal drucken, also lesen sie aus einer gemeinsamen Warteschlange und drucken die Dokumente in der Reihenfolge, in der sie ankommen.

Dieses Druckproblem ist eine Illustration des klassischen Produzenten-Verbraucher-Problems. Die Produzenten erzeugen und senden Nachrichten über einen gemeinsamen FIFO-Puffer an die Konsumenten. Die Verbraucher rufen diese Nachrichten ab, verarbeiten sie und fordern dann weitere Arbeit aus dem Puffer an. Eine einfache Veranschaulichung dieses Problems ist in Abbildung 4-6 zu sehen. Es ist ein bisschen wie in einem Buffet-Restaurant, das 24 Stunden am Tag und 365 Tage im Jahr geöffnet ist: Die Küche produziert, die Kellner holen das Essen ab und stellen es auf das Buffet, und die hungrigen Gäste bedienen sich. Für immer.

The producer-consumer problem
Abbildung 4-6. Das Erzeuger-Verbraucher-Problem

Wie praktisch alle realen Ressourcen hat auch der Puffer eine begrenzte Kapazität. Wenn der Puffer jedoch voll ist, müssen die Produzenten warten, bis ein oder mehrere Artikel verbraucht wurden, bevor sie den neuen Artikel in den Puffer aufnehmen können. Wenn die Konsumenten schneller konsumieren als die Produzenten produzieren, müssen sie warten, bis der Puffer voll ist, und werden benachrichtigt, wenn neue Artikel eintreffen.

Eine Möglichkeit für einen Produzenten, auf Platz im Puffer zu warten, oder für einen Konsumenten, auf ein Element zu warten, ist, einen Vorgang immer wieder zu versuchen. Ein Produzent könnte eine Sekunde lang schlafen und dann die Put-Operation so lange wiederholen, bis sie erfolgreich ist. Ein Verbraucher könnte das Gleiche tun.

Diese Lösung wird Polling oder Busy Waiting genannt. Sie funktioniert gut, aber wie der zweite Name schon sagt, verbrauchen sowohl Producer als auch Consumer jedes Mal Ressourcen (CPU, Speicher, vielleicht Netzwerk?), wenn sie es erneut versuchen und fehlschlagen. Wenn das kein Problem ist, ist das in Ordnung, aber in skalierbaren Systemen versuchen wir immer, die Ressourcennutzung zu optimieren, und Polling kann verschwenderisch sein.

Eine bessere Lösung ist, dass Producer und Consumer so lange blockieren, bis die gewünschte Operation, Put oder Get, erfolgreich abgeschlossen werden kann. Blockierte Threads verbrauchen keine Ressourcen und sind daher eine effiziente Lösung. Um dies zu erleichtern, bieten Thread-Programmiermodelle blockierende Operationen, mit denen Threads anderen Threads signalisieren können, wenn ein Ereignis eintritt. Beim Producer-Consumer-Problem sieht das grundlegende Schema wie folgt aus:

  • Wenn ein Produzent ein Element zum Puffer hinzufügt, sendet er ein Signal an alle blockierten Verbraucher, um sie darüber zu informieren, dass sich ein Element im Puffer befindet.

  • Wenn ein Verbraucher ein Element aus dem Puffer abruft, sendet er ein Signal an alle blockierten Produzenten, um ihnen mitzuteilen, dass im Puffer Platz für neue Elemente ist.

In Java gibt es zwei grundlegende Primitive, nämlich wait() und notify(), die verwendet werden können, um dieses Signalisierungsschema umzusetzen. Kurz gesagt, funktionieren sie folgendermaßen:

  • Ein Thread kann wait() innerhalb eines synchronisierten Blocks aufrufen, wenn eine Bedingung, die er erfüllen muss, nicht erfüllt ist. Ein Thread kann zum Beispiel versuchen, eine Nachricht aus einem Puffer abzurufen, aber wenn der Puffer keine Nachrichten zum Abrufen enthält, ruft er wait() auf und blockiert, bis ein anderer Thread eine Nachricht hinzufügt, die Bedingung auf true setzt und notify() für dasselbe Objekt aufruft.

  • notify() weckt einen Thread auf, der wait() für das Objekt aufgerufen hat.

Diese Java-Primitive werden verwendet, um guarded blocks zu implementieren. Bei Guarded Blocks wird eine Bedingung als Guard verwendet, die erfüllt sein muss, bevor ein Thread die Ausführung fortsetzt. Der folgende Codeschnipsel zeigt, wie die Guard-Bedingung " leer" verwendet wird, um einen Thread zu blockieren, der versucht, eine Nachricht aus einem leeren Puffer abzurufen:

while (empty) {
  try {
    System.out.println("Waiting for a message");
    wait();
  } catch (InterruptedException e) {}
}

Wenn ein anderer Thread eine Nachricht in den Puffer einfügt, führt er notify() wie folgt aus:

// Store message.
this.message = message;
empty = false;
// Notify consumer that message is available
notify();

Die vollständige Implementierung dieses Beispiels findest du in den Codebeispielen im Git-Repository des Buches. Es gibt eine Reihe von Variationen der Methoden wait() und notify(), die aber den Rahmen dieses Überblicks sprengen würden. Zum Glück bietet Java uns Thread-sichere Abstraktionen, die diese Komplexität vor deinem Code verbergen.

Ein Beispiel, das für das Producer-Consumer-Problem relevant ist, ist die Schnittstelle BlockingQueue in java.util.concurrent.BlockingQueue. Die Implementierung BlockingQueue stellt ein thread-sicheres Objekt zur Verfügung, das als Puffer in einem Producer-Consumer-Szenario verwendet werden kann. Es gibt 5 verschiedene Implementierungen der Schnittstelle BlockingQueue. Ich verwende eine davon, die LinkedBlockingQueue, um den Producer-Consumer zu implementieren. Dies wird in Beispiel 4-4 gezeigt.

Beispiel 4-4. Erzeuger-Verbraucher mit einer LinkedBlockingQueue
class ProducerConsumer {
   public static void main(String[] args)
     BlockingQueue buffer = new LinkedBlockingQueue();
     Producer p = new Producer(buffer);
     Consumer c = new Consumer(buffer);
     new Thread(p).start();
     new Thread(c).start();
   }
 }

class Producer implements Runnable {
   private boolean active = true;
   private final BlockingQueue buffer;
   public Producer(BlockingQueue q) { buffer = q; }
   public void run() {
     
     try {
       while (active) { buffer.put(produce()); }
     } catch (InterruptedException ex) { // handle exception}
   }
   Object produce() { // details omitted, sets active=false }
 }

 class Consumer implements Runnable {
   private boolean active = true;  
   private final BlockingQueue buffer;
   public Consumer(BlockingQueue q) { buffer = q; }
   public void run() {
     
     try {
       while (active) { consume(buffer.take()); }
     } catch (InterruptedException ex) { // handle exception }
   }
   void consume(Object x) {  // details omitted, sets active=false }
 }

Mit dieser Lösung muss sich der Programmierer nicht mehr um die Implementierung des koordinierten Zugriffs auf den gemeinsamen Puffer kümmern und der Code wird stark vereinfacht.

Das Paket java.util.concurrent ist eine Fundgrube für die Entwicklung von Multithreading-Java-Lösungen. In den folgenden Abschnitten werde ich einige dieser leistungsstarken und äußerst nützlichen Funktionen kurz vorstellen.

Fadenpools

Viele Multithreading-Systeme müssen erstellen und eine Sammlung von Threads verwalten, die ähnliche Aufgaben ausführen. Beim Producer-Consumer-Problem können wir zum Beispiel eine Sammlung von Producer-Threads und eine Sammlung von Consumer-Threads haben, die alle gleichzeitig Elemente hinzufügen und entfernen und dabei koordiniert auf den gemeinsamen Puffer zugreifen.

Diese Sammlungen werden als Thread-Pools bezeichnet. Thread-Pools bestehen aus mehreren Worker-Threads, die in der Regel einen ähnlichen Zweck erfüllen und wie eine Sammlung verwaltet werden. Wir könnten einen Pool von Producer-Threads erstellen, die alle auf die Verarbeitung eines Artikels warten, das Endprodukt in den Puffer schreiben und dann darauf warten, einen weiteren Artikel zur Verarbeitung anzunehmen. Wenn wir aufhören, Artikel zu produzieren, kann der Pool auf sichere Weise geschlossen werden, damit keine teilweise verarbeiteten Artikel durch eine unvorhergesehene Ausnahme verloren gehen.

Im Paket java.util.concurrent werden Thread-Pools durch die Schnittstelle ExecutorService unterstützt. Diese erweitert die Basisschnittstelle Executor um eine Reihe von Methoden zur Verwaltung und Beendigung von Threads im Pool. Ein einfaches Producer-Consumer-Beispiel mit einem Thread-Pool fester Größe wird in den Beispielen 4-5 und 4-6 gezeigt. Die Klasse Producer in Beispiel 4-5 ist eine Runnable, die eine einzelne Nachricht an den Puffer sendet und sich dann beendet. Die Klasse Consumer nimmt so lange Nachrichten aus dem Puffer entgegen, bis sie eine leere Zeichenkette erhält und sich dann beendet.

Beispiel 4-5. Producer und Consumer für die Thread-Pool-Implementierung
class Producer implements Runnable {
  
  private final BlockingQueue buffer;

  public Producer(BlockingQueue q) { buffer = q; }

  @Override
  public void run() {
     
  try {
    sleep(1000);
    buffer.put("hello world");
              
  } catch (InterruptedException ex) {
    // handle exception
  }
 } 
}

class Consumer implements Runnable {
  private final BlockingQueue buffer;

  public Consumer(BlockingQueue q) { buffer = q; }

  @Override
   public void run() {
      boolean active = true; 
      while (active) {
          try {
             String  s = (String) buffer.take();
             System.out.println(s);
             if (s.equals("")) active = false;
          } catch (InterruptedException ex) {
              / handle exception
          }
      } /
      System.out.println("Consumer terminating");
    }
 }

In Beispiel 4-6 erstellen wir einen einzelnen Consumer, der Nachrichten aus dem Puffer abholt. Dann erstellen wir einen Thread-Pool mit fester Größe von 5, um unsere Produzenten zu verwalten. Dies veranlasst die JVM, fünf Threads vorzubelegen, die für die Ausführung aller Runnable Objekte verwendet werden können, die vom Pool ausgeführt werden.

In der Schleife for() verwenden wir dann die ExecutorService, um 20 Produzenten auszuführen. Da im Thread-Pool nur 5 Threads zur Verfügung stehen, werden nur maximal 5 Producer gleichzeitig ausgeführt. Alle anderen werden in eine Warteschlange gestellt, die vom Thread-Pool verwaltet wird. Wenn ein Producer beendet wird, wird der nächste Runnable in der Warteschlange mit einem beliebigen verfügbaren Thread aus dem Pool ausgeführt.

Wenn wir alle Produzenten aufgefordert haben, vom Thread-Pool ausgeführt zu werden, rufen wir die Methode shutdown() für den Pool auf. Dadurch wird ExecutorService angewiesen, keine weiteren Aufgaben mehr anzunehmen. Als Nächstes rufen wir die Methode awaitTermination() auf, die den aufrufenden Thread so lange blockiert, bis alle vom Thread-Pool verwalteten Threads im Leerlauf sind und keine Arbeit mehr in der Warteschlange wartet. Sobald awaitTermination() zurückkehrt, wissen wir, dass alle Nachrichten an den Puffer gesendet wurden, und senden daher einen leeren String an den Puffer, der als Abbruchwert für den Verbraucher dient.

Beispiel 4-6. Thread-Pool-basierte Producer-Consumer-Lösung
public static void main(String[] args) throws InterruptedException 
  {
    BlockingQueue buffer = new LinkedBlockingQueue();
    
    //start a single consumer 
    (new Thread(new Consumer(buffer))).start();

    ExecutorService producerPool = Executors.newFixedThreadPool(5);
    for (int i = 0; i < 20; i++) 
      {
        Producer producer = new Producer(buffer) ;
        System.out.println("Producer created" );
        producerPool.execute(producer);
      }

      producerPool.shutdown();
      producerPool.awaitTermination(10, TimeUnit.SECONDS);
        
      //send termination message to consumer 
      buffer.put("");        
    }

Wie bei den meisten Themen in diesem Kapitel gibt es auch im Executor Framework viele ausgefeiltere Funktionen, die zur Erstellung von Multithreading-Programmen genutzt werden können. Diese Beschreibung hat nur die Grundlagen behandelt. Thread-Pools sind wichtig, denn sie ermöglichen es unseren Systemen, die Ressourcen für Threads rationell zu nutzen. Jeder Thread verbraucht Speicher; die Stack-Größe eines Threads beträgt zum Beispiel normalerweise etwa 1 MB. Wenn wir den Ausführungskontext wechseln, um einen neuen Thread auszuführen, verbraucht dies ebenfalls CPU-Zyklen. Wenn unsere Systeme Threads auf undisziplinierte Weise erstellen, wird uns irgendwann der Speicher ausgehen und das System wird abstürzen. Mit Thread-Pools können wir die Anzahl der erstellten Threads kontrollieren und sie effizient nutzen.

Ich werde im weiteren Verlauf dieses Buches immer wieder auf Thread-Pools eingehen, da sie ein Schlüsselkonzept für die effiziente und skalierbare Verwaltung von den ständig steigenden Anfragen sind, die die Server erfüllen müssen.

Synchronisierung der Barriere

Ich hatte eine Schulfreundin, deren Familie zur Essenszeit niemandem erlaubte, mit dem Essen zu beginnen, bevor nicht die ganze Familie am Tisch saß. Ich fand das seltsam, aber viele Jahre später diente es als gute Analogie für das Konzept der Synchronisierung von Barrieren. Mit dem Essen wurde erst begonnen, als alle Familienmitglieder am Tisch saßen.

Multithreading-Systeme müssen oft einem solchen Verhaltensmuster folgen. Stell dir ein Multithreading-Bildverarbeitungssystem vor. Ein Bild kommt an und ein bestimmter Teil des Bildes wird an jeden Thread weitergeleitet, um eine Transformation durchzuführen - wie bei Instagram-Filtern auf Steroiden. Das Bild ist erst dann vollständig verarbeitet, wenn alle Threads abgeschlossen sind. In Softwaresystemen verwenden wir einen Mechanismus namens Barrier-Synchronisation, um diese Art der Thread-Koordination zu erreichen.

Das allgemeine Schema ist in Abbildung 4-7 dargestellt. In diesem Beispiel erstellt der Thread main() vier neue Threads, die alle unabhängig voneinander ablaufen, bis sie den durch die Barriere definierten Ausführungszeitpunkt erreichen. Wenn jeder Thread ankommt, blockiert er. Wenn alle Threads an diesem Punkt angekommen sind, wird die Barriere aufgehoben und jeder Thread kann mit seiner Bearbeitung fortfahren.

Barrier synchronization
Abbildung 4-7. Synchronisierung der Barriere

Java bietet drei Primitive für die Barriere-Synchronisierung. Ich zeige hier, wie eine der drei, CountDownLatch, funktioniert. Die grundlegenden Konzepte gelten auch für andere Barrier-Synchronisationsprimitive.

Wenn du eine CountDownLatch erstellst, übergibst du ihrem Konstruktor einen Wert, der die Anzahl der Threads angibt, die an der Barriere blockiert werden müssen, bevor sie alle fortfahren dürfen. Diese Funktion wird in dem Thread aufgerufen, der die Sperrpunkte für das System verwaltet - in Abbildung 4-7 wäre das main():

CountDownLatch  nextPhaseSignal = new CountDownLatch(numThreads);

Als Nächstes erstellst du die Worker-Threads, die einige Aktionen ausführen und dann an der Barriere blockieren, bis sie alle abgeschlossen sind. Dazu musst du jedem Thread einen Verweis auf CountDownLatch übergeben:

for (int i = 0; i < numThreads; i++) {
            Thread worker = new Thread(new WorkerThread(nextPhaseSignal));
            worker.start();
        }

Nachdem die Worker-Threads gestartet wurden, ruft der Thread main() die Methode .await() auf, um zu blockieren, bis der Latch von den Worker-Threads ausgelöst wird:

nextPhaseSignal.await();

Jeder Worker-Thread beendet seine Aufgabe und ruft vor dem Beenden die Methode .countDown() für den Latch auf. Dadurch wird der Latch-Wert dekrementiert. Wenn der letzte Thread .countDown() aufruft und der Latch-Wert Null wird, gehen alle Threads, die .await() auf dem Latch aufgerufen haben, vom blockierten in den lauffähigen Zustand über. Zu diesem Zeitpunkt können wir sicher sein, dass alle Arbeiter/innen die ihnen zugewiesene Aufgabe erledigt haben:

nextPhaseSignal.countDown();

Alle nachfolgenden Aufrufe von .countDown() kehren sofort zurück, da die Sperre effektiv ausgelöst wurde. .countDown () ist nicht blockierend, was eine nützliche Eigenschaft für Anwendungen ist, in denen Threads nach dem Erreichen der Barriere noch mehr zu tun haben.

In diesem Beispiel wird ein CountDownLatch verwendet, um einen einzelnen Thread zu blockieren, bis eine Sammlung von Threads ihre Arbeit beendet hat. Du kannst diesen Anwendungsfall jedoch mit einem Latch umkehren, wenn du seinen Wert auf eins initialisierst. Mehrere Threads könnten .await( ) aufrufen und blockieren, bis ein anderer Thread .countDown() aufruft, um alle wartenden Threads freizugeben. Dieses Beispiel ist vergleichbar mit einem einfachen Tor, das ein Thread öffnet, um eine Sammlung anderer Threads weiterlaufen zu lassen.

CountDownLatch ist ein einfacher Barriere-Synchronisierer. Er kann nur einmal verwendet werden, da der Initialisierungswert nicht zurückgesetzt werden kann. Komplexere Funktionen bieten die Klassen CyclicBarrier und Phaser in Java. Mit dem Wissen aus diesem Abschnitt, wie die Barrieresynchronisierung funktioniert, sind diese leicht zu verstehen.

Fadensichere Sammlungen

Viele Java-Programmiererinnen und -Programmierer sind überrascht, dass die Sammlungen des Pakets java.util nicht thread-sicher sind, sobald sie sich mit den Wundern von Multithreading-Programmen beschäftigen: .2 Warum, wirst du dich fragen? Die Antwort ist zum Glück ganz einfach. Es hat mit der Leistung zu tun. Das Aufrufen von synchronisierten Methoden verursacht Overhead. Um eine schnellere Ausführung von Single-Thread-Programmen zu erreichen, sind die Sammlungen daher nicht thread-sicher.

Wenn du eine ArrayList, Map oder deine bevorzugte Datenstruktur von java.util über mehrere Threads hinweg gemeinsam nutzen willst, musst du sicherstellen, dass Änderungen an der Struktur in kritischen Abschnitten vorgenommen werden. Dieser Ansatz bürdet dem Client der Sammlung die Last auf, Aktualisierungen sicher vorzunehmen, und ist daher fehleranfällig - ein Programmierer könnte vergessen, Änderungen in einem synchronized Block vorzunehmen.

Es ist immer sicherer, inhärent thread-sichere Collections in deinem Multithreading-Code zu verwenden. Aus diesem Grund bietet das Java Collections Framework eine Factory-Methode, die eine thread-sichere Version von java.util Collections erstellt. Hier ist ein Beispiel für die Erstellung einer thread-sicheren Liste:

List<String> list = Collections.synchronizedList(new ArrayList<>());

Was hier wirklich passiert, ist, dass du einen Wrapper um die Basisklasse der Sammlung erstellst, der über synchronized Methoden verfügt. Diese delegieren die eigentliche Arbeit an die ursprüngliche Klasse, natürlich auf eine thread-sichere Weise. Du kannst diesen Ansatz für jede beliebige Sammlung im java.util Paket verwenden. Die allgemeine Form ist

Collections.synchronized....(new collection<>())

wobei "...." für List, Map, Set und so weiter steht.

Wenn du die synchronisierten Wrapper verwendest, zahlst du natürlich den Leistungsnachteil, dass du die Monitorsperre erwerben und den Zugriff von mehreren Threads serialisieren musst. Das bedeutet, dass die gesamte Sammlung gesperrt ist, während ein einzelner Thread eine Änderung vornimmt, was die gleichzeitige Leistung stark einschränkt (wieder das Amdahlsche Gesetz). Aus diesem Grund wurde in Java 5.0 das Concurrent Collections Package java.util.concurrent eingeführt. Es enthält eine umfangreiche Sammlung von Klassen, die speziell für den effizienten Multithreading-Zugriff entwickelt wurden.

Eine dieser Klassen haben wir bereits kennengelernt: LinkedBlockingQueue. Sie verwendet einen Sperrmechanismus, mit dem Elemente parallel zur Warteschlange hinzugefügt und entfernt werden können. Dieser feinkörnige Sperrmechanismus verwendet die Klasse java.util.concurrent.lock.Lock und nicht den Ansatz der Monitorsperre. Dadurch können mehrere Sperren für dieselbe Sammlung verwendet werden, was einen sicheren gleichzeitigen Zugriff ermöglicht.

Eine weitere sehr nützliche Sammlung, die dieses feinkörnige Locking ermöglicht, ist ConcurrentHashMap. Sie bietet ähnliche Methoden wie die nicht-threadsichere HashMap, erlaubt aber nicht-blockierende Lese- und Schreibvorgänge auf der Grundlage eines concurrencyLevel Wertes, den du dem Konstruktor übergeben kannst (der Standardwert ist 16):

ConcurrentHashMap (int initialCapacity, float loadFactor, 
                     int concurrencyLevel)

Intern ist die Hashtabelle in einzeln sperrbare Segmente unterteilt, die oft als Shards bezeichnet werden. Die Sperren sind mit jedem Shard verbunden und nicht mit der gesamten Sammlung. Das bedeutet, dass Aktualisierungen von Hash-Tabelleneinträgen in verschiedenen Shards der Sammlung gleichzeitig vorgenommen werden können, was die Leistung erhöht.

Abrufvorgänge sind aus Leistungsgründen nicht blockierend, das heißt, sie können sich mit mehreren gleichzeitigen Aktualisierungen überschneiden. Das bedeutet, dass Abrufe nur die Ergebnisse der zuletzt abgeschlossenen Aktualisierungsvorgänge zum Zeitpunkt der Ausführung des Abrufs widerspiegeln.

Aus ähnlichen Gründen sind Iteratoren für eine ConcurrentHashMap so genannte "weakly consistent". Das bedeutet, dass der Iterator eine Kopie der Hash Map enthält, die ihren Zustand zum Zeitpunkt der Erstellung des Iterators widerspiegelt. Während der Iterator in Gebrauch ist, können neue Knoten hinzugefügt und bestehende Knoten aus der zugrunde liegenden Hash Map entfernt werden. Diese Zustandsänderungen werden jedoch nicht in den Iterator übernommen.

Wenn du einen Iterator brauchst, der immer den aktuellen Hashmap-Zustand widerspiegelt, während er von mehreren Threads aktualisiert wird, musst du Leistungseinbußen in Kauf nehmen, und ConcurrentHashMap ist nicht der richtige Ansatz. Dies ist ein Beispiel dafür, dass man die Leistung der Konsistenz vorzieht - ein klassischer Kompromiss beim Design.

Zusammenfassung und weiterführende Literatur

Im weiteren Verlauf des Buches werde ich mich auf die wichtigsten in diesem Kapitel vorgestellten Konzepte stützen. Threads sind fester Bestandteil der Datenverarbeitungs- und Datenbankplattformen, die wir zum Aufbau skalierbarer verteilter Systeme verwenden. In vielen Fällen schreibst du vielleicht keinen expliziten Multithreading-Code. Der Code, den du schreibst, wird jedoch in einer Multithreading-Umgebung aufgerufen, was bedeutet, dass du auf die Thread-Sicherheit achten musst. Das bedeutet, dass du die Auswirkungen der verschiedenen Threading- und Threadpool-Einstellungen kennen musst, um die Leistung des Systems zu optimieren. In der Welt der skalierbaren verteilten Systeme gibt es im Grunde kein Entrinnen vor der Gleichzeitigkeit.

Abschließend ist zu erwähnen, dass sich die Primitive der nebenläufigen Programmierung zwar von Programmiersprache zu Programmiersprache unterscheiden, sich die grundlegenden Probleme jedoch nicht ändern und dass sorgfältig entwickelter Multithreading-Code zur Vermeidung von Race Conditions und Deadlocks erforderlich ist. Egal, ob du dich mit der pthreads Bibliothek in C/C++ oder dem klassischen, von CSP inspirierten Go-Gleichzeitigkeitsmodell beschäftigst, die Probleme, die du vermeiden musst, sind dieselben. Das Wissen, das du in diesem Kapitel erworben hast, wird dich auf den richtigen Weg bringen, egal welche Sprache du verwendest.

Dieses Kapitel hat nur die Oberfläche der Gleichzeitigkeit im Allgemeinen und ihrer Unterstützung in Java gestreift. Das beste Buch, um mehr über die grundlegenden Konzepte der Gleichzeitigkeit zu erfahren, ist der Klassiker Java Concurrency in Practice(JCiP) von Brian Goetz et al. (Addison-Wesley Professional, 2006). Wenn du alles im Buch verstehst, wirst du ziemlich guten nebenläufigen Code schreiben können.

Die Unterstützung von Parallelität in Java hat sich seit Java 5 erheblich weiterentwickelt. In der Welt von Java 12 (oder der Version, die aktuell ist, wenn du dies liest) gibt es neue Funktionen wie CompletableFutures, Lambda-Ausdrücke und parallele Streams. Der funktionale Programmierstil, der in Java 8.0 eingeführt wurde, macht es einfach, nebenläufige Lösungen zu erstellen, ohne direkt Threads zu erstellen und zu verwalten. Eine gute Quelle für das Wissen über die Funktionen von Java 8.0 ist Mastering Concurrency Programming with Java 8 von Javier Fernández González (Packt, 2017).

Weitere ausgezeichnete Quellen sind:

  • Doug Lea, Concurrent Programming in Java: Design Principles and Patterns, 2. Aufl. (Addison-Wesley Professional, 1996)

  • Raoul-Gabriel Urma, Mario Fusco, und Alan Mycroft, Modern Java in Action: Lambdas, Streams, funktionale und reaktive Programmierung (Manning, 2019)

  • Die Baeldung-Website bietet eine umfassende Sammlung von Artikeln zum Erlernen der Java-Gleichzeitigkeit und diente als Grundlage für das Beispiel der Essensphilosophen in diesem Kapitel.

1 Der richtige Weg, mit diesen Problemen umzugehen, nämlich die Synchronisierung von Barrieren, wird später in diesem Kapitel behandelt .

2 Außer Vector und HashTable, die Legacy-Klassen sind; thread-sicher und langsam!

Get Grundlagen der skalierbaren Systeme now with the O’Reilly learning platform.

O’Reilly members experience books, live events, courses curated by job role, and more from O’Reilly and nearly 200 top publishers.