Java, Data Races und Sequential Consistency

von Hubert Schmid vom 2013-09-01

Race-Conditions und schwer auf‌findbare Programmfehler: Diese Stichworte verbinden viele Java-Entwickler mit Parallel-Programmierung – entweder weil sie bereits entsprechende Erfahrungen gesammelt haben, oder weil ihnen diese Vorstellung vermittelt worden ist. In vielen Fällen führe ich diese Berührungsängste allerdings auf ein ungeeignetes Bild für die Semantik des gemeinsam genutzten Speichers zurück. Aus diesem Grund gehe ich zum wiederholten Male auf die wesentlichen Eigenschaften das Java-Memory-Model ein. Denn mit der richtigen Sichtweise ist alles ganz einfach.

Motivation

Viele Probleme bei der Parallelprogrammierung sind darauf zurückzuführen, dass Entwickler besonders clever sein wollen. Ein typisches Beispiel dafür ist das sogenannte Double-checked Locking, das im folgenden Listing zu sehen ist.

Die Implementierung ist scheinbar in Ordnung: Lese- und Schreibzugriffe auf Referenzvariablen sind in Java bekanntlich atomar, und diese Eigenschaft wird verwendet, damit nur beim ersten Zugriff eine teure Synchronisation notwendig ist. Betrachtet man schließlich die parallele Ausführung der Methode getInstance in mehreren Threads, so wird man feststellen, dass bei jeder möglichen Verzahnung die Optimierung korrekt funktioniert.

public class Singleton { private static Singleton instance = null; public static Singleton getInstance() { if (instance == null) { synchronized (Singleton.class) { if (instance == null) { instance = new Singleton(); } } } return instance; } private int magic; public Singleton() { magic = 42; } public int getMagic() { return magic; } }

Trotzdem ist der Code falsch: Der Ausdruck Singleton.getInstance().getMagic() kann auch einen Wert ungleich 42 liefern. Schaut man sich die Begründungen dazu an, so stößt man häufig auf Dinge wie Caches, Umordnungen, Memory-Barriers und verzögerte Sichtbarkeit. Ich habe schon viele Vorträge gesehen, in denen versucht wird, diese Konzepte zu erklären. Aus meiner Sicht ist das allerdings der falsche Weg. Denn es macht die Sache viel zu kompliziert.

Vergleich

Ich versuche mich an einem Vergleich mit einem anderen Konstrukt, das möglicherweise ein wenig weit hergeholt erscheint. Mir geht es allerdings darum, ein bestimmtes Bild zu prägen.

Was ist 50000 * 50000? Die Antwort ist -1794967296.

Offensichtlich kann das Ergebnis nicht 2 500 000 000 sein. Denn 50000 ist ein int, das Produkt zweier ints ist ein int, aber 2 500 000 000 liegt nicht im Wertebereich von int. Bei der Operation kommt es also zu einem Überlauf, und damit zu diesem merkwürdigen Ergebnis.

So merkwürdig ist das Ergebnis allerdings gar nicht. Die Java-Sprachspezifikation definiert genau, was bei einem Überlauf passiert, und dass -1794967296 tatsächlich das Ergebnis obigen Ausdrucks ist. Doch hilft uns das weiter? Eigentlich nicht! Denn es ist einfacher Überläufe zu vermeiden statt mit Überläufen zu arbeiten.

So ähnlich ist es auch mit der Parallelprogrammierung: Wichtig ist nicht, was in obigem Beispiel passiert. Wichtig ist, dass wir Code schreiben, der nicht so merkwürdig wie in obigem Beispiel ausgeführt wird.

Sequential Consistency

Die wichtigste Anforderung von Entwicklern an parallele Ausführungen ist, dass die Reihenfolge der Operationen im Quelltext eingehalten wird – beziehungsweise dass sich die Ausführung so verhält, als ob die Reihenfolge eingehalten würde. Diese vereinfacht beschriebene Eigenschaft wird Sequential Consistency genannt. Die Bedeutung zeigt sich an der Selbstverständlichkeit, mit der viele Entwickler sie voraussetzen. Doch um nochmal auf obiges Beispiel zurückzukommen: Wenn Sequential Consistency in obigem Beispiel gegeben wäre, dann gäbe es kein Problem.

Wie erreicht man also Sequential Consistency? Ganz einfach indem man Data Races verhindert. Denn solange das Programm keine Data Races enthält, ist Sequential Consistency garantiert. Dabei versteht man unter einer Data Race, dass zwei Threads gleichzeitig auf die selbe (nicht mit volatile deklarierte) Variable zugreifen, wobei mindestens eine der beiden Zugriffe eine schreibende Operation ist. Dabei ist es unwichtig, ob dieser Fall zur Laufzeit tatsächlich eintritt. Wichtig ist nur, ob er bei irgendeiner Ausführung unter Berücksichtigung der Reihenfolge im Quelltext eintreten könnte.

In obigem Beispiel gibt es eine Data Race. Die Variable instance wird in der ersten if-Bedingung gelesen und innerhalb des synchronized-Blocks geschrieben. Nichts verhindert den gleichzeitigen Zugriff durch zwei unterschiedliche Threads. Damit enthält die Klasse eine Data Race, und die Garantie für Sequential Consistency verfällt. Nochmals: Für die Garantie ist unbedeutend, ob es passiert, sondern nur ob es passieren könnte.

Daher gibt es auch eine sehr einfache Lösung: Deklariert man die Variable instance mit volatile, so gibt es keine Data Race mehr, Sequential Consistency ist garantiert und die Klasse offensichtlich korrekt.

Fazit

Die wichtige Aussage ist: Ohne Data Races ist Sequential Consistency garantiert, und mit Sequential Consistency verhält sich die Ausführung so, als ob die Reihenfolge der Operationen im Quelltext eingehalten würde. Um Data Races zu verhindern benötigt man synchronized, volatile, Klassen aus java.util.concurrent.atomic oder andere Klassen zur Synchronisierung mehrerer Threads. Daran führt kein Weg vorbei. Also: Konsequent Synchronisation verwenden!