Kolumne

Pleiten, Pech und Pannen mit Threads [Java-Trickkiste]

Arno Haase

© Shutterstock/isak55

Zur letzten Ausgabe der Java-Trickkiste kam als Feedback die Bitte, das Verhalten von volatile genauer zu erklären. Also gibt es in dieser Folge ein paar überraschende Codebeispiele mit Erklärungen. 

Früher bedeutete Multi-Threading, dass mehrere Threads auf den (potenziell) selben Daten arbeiteten. Das Betriebssystem konnte zu jedem Zeitpunkt den aktuell laufenden Thread unterbrechen und einen anderen aktivieren. Dabei konnte es Race Conditions geben, und nebenläufiger Code war schon damals kompliziert zu schreiben, zu verstehen und erst recht zu debuggen. Aber die Welt funktionierte so, wie man es erwartete. Wenn ein Thread einen Wert in den Speicher schrieb und ein anderer ihn las, dann war entweder der Schreibzugriff vor dem Lesezugriff oder umgekehrt. Und wenn eine Variable volatile war, erzwang das einfach, dass ihre Werte direkt in den Speicher geschrieben wurden, statt z. B. zuerst eine Weile in einem Prozessorregister geparkt zu werden.

Die Welt ist kompliziert

Diese gute alte Zeit ist spätestens seit Multicore-Prozessoren und Java 5 endgültig vorbei. Und die oben skizzierte naive Sicht auf Multi-Threading reicht nicht mehr aus, um das Verhalten von Programmen zu verstehen. Mehrere Cores führen Code tatsächlich gleichzeitig aus. Es gibt mehrstufige CPU-Caches, und wenn ein Core einen Wert in den „Speicher“ schreibt, landet er zunächst im Cache – und wenn ein anderer Core später die „selbe“ Speicherzelle liest, heißt das noch lange nicht, dass er den aktuellen, geänderten Wert sieht.
Und moderne CPUs „raten“ bei Fallunterscheidungen, welcher Zweig ausgeführt wird, und führen Code spekulativ aus – und machen die Änderung später rückgängig, wenn sie sich vertan haben. So können vorübergehend Werte im Speicher stehen, die „eigentlich“ nie hätten entstehen können. Und moderne Compiler ändern die Reihenfolge von Instruktionen, um bessere Performance zu erreichen.
Alle diese Effekte machen Programme deutlich schneller, und es wäre dumm, sich die „gute alte Zeit“ zurückzuwünschen. Aber sie machen die Welt für uns Entwickler komplizierter – und natürlich auch spannender. Das sollen die folgenden Codebeispiele illustrieren. Vorher aber noch ein Hinweis: Die Java-Spezifikation sagt, was das JDK optimieren darf, und nicht, was es tatsächlich optimiert (siehe Kapitel 17). Der Beispielcode ist absichtlich fehlerhaft und darf sich deshalb merkwürdig verhalten. Die konkreten Merkwürdigkeiten treten in meiner Umgebung auf: Intel i7 Quad-Core, Oracle Java 8, 64-Bit-Linux.
Umgekehrt gilt: Selbst wenn Code sich auf einer bestimmten Plattform korrekt verhält, kann er kaputt sein und nach dem nächsten JRE-Patch (oder Hardwareupgrade) mehr oder weniger subtile Fehler produzieren. Das entscheidende Kriterium für korrekte Synchronisation ist die JLS und nicht ein konkretes JDK.

Instruction Reordering

Listing 1 startet einen Thread, der in einer Endlosschleife zwei normale int-Attribute x und y verändert. Er inkrementiert erst x und dann y, anschließend dekrementiert er x und inkrementiert y noch einmal. Der Hauptthread wartet eine Sekunde, um HotSpot Zeit zum Optimieren dieser Schleife zu geben, und prüft dann fortlaufend den Wert von x.

class A implements Runnable {
  public int x, y;

  public void run() {
    while (true) {
      x++;
      y++;
      x--;
      y++;
    }
  }
}

A a = new A();
new Thread(a).start();

Thread.sleep (1000); // for HotSpot

while (true) {
  int i=a.y;
  if(a.x != 0) System.out.println("!");
}

Naiv betrachtet sollte x abwechselnd die Werte 0 und 1 haben. Tatsächlich ist x aber (aus Sicht des Hauptthreads) immer 0. Die Ursache ist, dass HotSpot die beiden mittleren Variablenzugriffe vertauscht: x++; x–; y++; y++;. Das ist erlaubt, weil es innerhalb des Threads keinen Unterschied macht.
In einem zweiten Schritt entfernt HotSpot die beiden Zugriffe auf x komplett aus dem Code, weil sie zusammen keine Auswirkung haben und innerhalb des Threads das Zwischenergebnis nicht verwendet wird.
Wenn wir die Variable y mit dem Zusatz volatile versehen, ist dieses Instruction Reordering verboten, und x hat aus Sicht des Hauptthreads abwechselnd die Werte 0 und 1. Es gibt nämlich starke Beschränkungen für das Verschieben von Instruktionen an volatile-Zugriffen vorbei (für Details siehe hier). Der Lesezugriff auf y in der lesenden Schleife gewährleistet die Sichtbarkeit der Werte von x (s. u.). Er tut nur scheinbar nichts.
Diese Garantien übernimmt Java übrigens auch für Instruction Reordering, Branch Prediction und Speculative Execution im Prozessor sowie für alle Prozessorcacheeffekte. Oder anders gesagt: Es ist Sache der JVM, das Ergebnis auf der jeweiligen Hardware sicherzustellen, und wir als Entwickler müssen uns nicht mehr darum kümmern.
Eine historische Randnote: Die ursprüngliche Java-Spezifikation hatte diese Einschränkung nicht. Das führte zu subtilen Bugs und war Anlass zu einer Revision des JMM mit Java 5.

Sichtbarkeit von Änderungen

Listing 2 zeigt, dass man sich ohne Synchronisierung auf die Sichtbarkeit von Daten in anderen Threads nicht verlassen kann. Der Code startet sechzehn Threads, von denen jeder eine eigene int-Variable x hat. Jeder Thread setzt diese Variable in einer Endlosschleife zunächst auf 0 und zählt sie anschließend in einer inneren Schleife hoch. Der Hauptthread fragt in einer Endlosschleife die Werte aus allen Threads ab und gibt sie aus.

A[] arr = new A[16];
for(int i=0; i<arr.length; i++) {
  arr[i] = new A();
  new Thread(arr[i]).start();
}

while(true) {
  for(A a: arr) {
    // int i = a.y;
    System.out.print(String.format("%4d", a.x));
  }
  System.out.println();
}

class A implements Runnable {
  public int x;
  public volatile int y;

  public void run() {
    while(true) {
      x=0;
      // y=0;

      for(int i=0; i<10; i++) {
        x+=i;
        // y=0;
      }
    }
  }
}

Das Ergebnis ist (zumindest auf meiner Maschine) überraschend: Für einige Threads sieht der Hauptthread alle Zwischenwerte, während er für andere Threads immer 45 sieht. Wie viele der Threads zu welcher dieser Gruppen gehören, schwankt mit jedem Start des Programms.
Zunächst einmal sei festgehalten, dass das Programm sich so verhalten darf. Die Variable x ist nicht volatile, und es gibt auch sonst keine Synchronisierung zwischen den Threads, also darf das Programm laut Java-Spezifikation beliebige Werte ausgeben.
Das konkrete Verhalten des Programms auf meiner Maschine liegt an der Aufteilung der Threads auf die Prozessorkerne. Die Zwischenwerte innerhalb der Schleife landen offenbar im L2-Cache der CPU, ohne in den Hauptspeicher geschrieben zu werden; erst das Ergebnis des Hochzählens wird offenbar tatsächlich ins RAM geschrieben. Der Hauptthread sieht also die Zwischenwerte von allen Threads, die auf Kernen laufen, die denselben L2-Cache nutzen, von den anderen aber nur die Summen.
Bei einem größeren Programm tritt dieses Verhalten eventuell erst unter Last oder auf einer produktiven Hardware mit vielen Prozessoren auf, und auch dann nur sporadisch … Zur Korrektur genügt es, die Variable x volatile zu machen. Wir können aber auch für jeden Thread eine zusätzliche volatile-Variable y einführen, der wir nach jeder Änderung an x den Wert 0 zuweisen und deren Wert wir vor jedem Lesezugriff auf x auslesen.
Wenn ein Thread nämlich eine volatile-Variable schreibt und ein anderer Thread dieselbe Variable liest, garantiert Java nämlich, dass der zweite Thread alle Werte aller Variablen sieht, die der erste Thread vor dem Schreibzugriff geändert hat. Auch hier garantiert Java das Ergebnis und kümmert sich auch um Prozessor- und Prozessorcacheeffekte.

Fazit

Nebenläufiger Java-Code ohne korrekte Synchronisierung kann sich extrem unerwartet verhalten. Ohne Synchronisation ist das Verhalten von Code nur innerhalb eines Threads spezifiziert, und für den Austausch von Daten zwischen Threads gibt es praktisch keine Garantien. Insbesondere darf sich die Verarbeitungsreihenfolge ändern, und wenn ein Thread Daten ändert, müssen diese Änderungen für andere Threads nicht sichtbar sein.
Zugriffe auf volatile-Variablen sind neben synchronized und den Klassen aus java.util.concurrent eine Möglichkeit, über Threadgrenzen hinweg die gleichen Sichtbarkeits- und Reihenfolgegarantien zu erzwingen, wie sie sonst nur innerhalb eines Threads gelten.

Aufmacherbild: Program code and computer keyboard von Shutterstock / Urheberrecht: isak55

Geschrieben von
Arno Haase
Arno Haase
Arno Haase ist freiberuflicher Softwareentwickler. Er programmiert Java aus Leidenschaft, arbeitet aber auch als Architekt, Coach und Berater. Seine Schwerpunkte sind modellgetriebene Softwareentwicklung, Persistenzlösungen mit oder ohne relationaler Datenbank und nebenläufige und verteilte Systeme. Arno spricht regelmäßig auf Konferenzen und ist Autor von Fachartikeln und Büchern. Er lebt mit seiner Frau und seinen drei Kindern in Braunschweig.
Kommentare

Hinterlasse einen Kommentar

Hinterlasse den ersten Kommentar!

avatar
400
  Subscribe  
Benachrichtige mich zu: