Zurück zum Inhaltsverzeichnis des Manuskripts verteilte Systeme

1.3.3 Threadsicherheit

Schreibkonflikte

Nebenläufig arbeitende Threads können zu schweren Programmfehlern führen, von denen hier beispielhaft lediglich die Schreibkonflikte genannt werden sollen. Eine umfassendere Behandlung dieses Themas muss aus Zeitgründen einer Vertiefungsveranstaltung vorbehalten bleiben.

Das folgende Programmbeispiel LostUpdate.java zeigt einen Programmfehler, der als verloren gegangene Änderung bekannt ist. Programmläufe mit diesem Programm können zu Situationen führen, bei denen die Änderung, die ein Thread an einer Variablen vornimmt, verloren geht. In dem Programm konkurrieren zwei Threads um den Zugriff auf eine gemeinsame int-Variable miteinander. Die Variable wird mit dem Wert 1 vorgelegt, und beide Threads erhöhen ihren Wert jeweils um 1.

LostUpdate.java

Programmerläuterung

Programmläufe mit dem Programm LostUpdate werden nach dem Ende der beiden miteinander konkurrierenden Threads als Wert der gemeinsamen Variablen zwar oft das Ergebnis 3 zeigen, aber ebenfalls oft den Wert 2. Das liegt daran, dass Java-Befehle wie i++ zwar atomar aussehen, dies aber nicht sind. Als atomar wird ein Befehl bezeichnet, wenn er immer ohne jede Unterbrechung durchgeführt wird. Der Scheduler der JVM arbeitet allerdings nicht mit Java-Quellprogrammen, sondern mit Bytecode, und ein Java-Befehl wie i++ besteht aus mehr als einem Bytecode-Befehl. Sinngemäß besteht er aus den folgenden drei Befehlen:

  1. Hole den Wert des Speicherplatzes i in ein Register des Prozessors.
  2. Addiere zu diesem Register den Wert 1.
  3. Schreibe den Registerinhalt auf den Speicherplatz i zurück.

Die drei Bytecode-Befehle einzeln sind atomar. Innerhalb eines solchen Befehls unterbricht der JVM-Scheduler den Thread nicht, aber zwischen ihnen kann er ihn unterbrechen. Angenommen, eine int-Variable i sei mit dem Wert 1 vorbelegt und werde von zwei Threads t1 und t2 bearbeitet, die beide den Befehl i++ ausführen. Dann kann sich folgender Programmlauf ergeben:

Der Thread t1 ist im Zustand Running (er hat Rechenzeit erhalten) und führt die drei Bytecode-Befehle zu i++ aus:

  1. Holen des Wertes von i (das ist der Wert 1) in eines seiner Prozessorregister.
  2. Erhöhen des Registerwerts auf 2.

Jetzt wird t1 vom Scheduler unterbrochen und der Thread t2 bekommt Rechenzeit, d.h. er bekommt einen Prozessor und damit einen eigenen Registersatz. Der Thread t2 ist dann im Zustand Running und führt ebenfalls die drei Bytecode-Befehle zu i++ aus:

  1. Holen des Wertes von i (der ist nach wie vor 1) in eines seiner Prozessorregister.
  2. Erhöhen des Registerwerts auf 2.

Jetzt wird t2 vom Scheduler unterbrochen und t1 bekommt wieder Rechenzeit. Der Thread t1 macht dort weiter, wo er unterbrochen worden ist und führt aus:

  1. Zurückschreiben des Registerinhalts (das ist der Wert 2) auf den Speicherplatz i.

Damit hat t2 den Java-Befehl i++ abgearbeitet und in i steht der Wert 2. Früher oder später bekommt der Thread t2 wieder Rechenzeit und macht ebenfalls dort weiter, wo er unterbrochen worden ist, d.h. er führt aus:

  1. Zurückschreiben des Registerinhalts (das ist der Wert 2) auf den Speicherplatz i.

Der Thread t2 hat seinen Java-Befehl i++ abgearbeitet und den Wert 2 auf den Speicherplatz i geschrieben. Bei der geschilderten nebenläufigen Durchführung des Java-Befehls i++ durch die beiden Threads hat es ein nicht vorhersagbares ungünstiges zeitliches Verhalten gegeben, das zu einem Schreibkonflikt geführt hat, durch den eine der beiden Additionen verloren gegangen ist.

Umgang mit möglichen Schreibkonflikten

Drohen in einem Prozess wegen nebenläufiger Schreibvorgänge seiner Threads Schreibkonflikte, dann sind folgende prinzipielle Vorgehensweisen möglich: Man kann

Vermeidung von Schreibkonflikten

Ein Programm so zu konzipieren, dass Schreibkonflikte gar nicht erst entstehen, ist nicht immer möglich. Manche Aufgabenstellungen lassen ein solches Vorgehen nicht zu. Ein Beispiel dafür ist das bekannte Produzenten-Konsumenten-Problem, bei dem zwei Partner miteinander konkurrierend auf ein gemeinsames Lager zugreifen müssen.

Bei der ersten Übungsaufgabe zur Lehrveranstaltung (Abschnitt 7.1 (Nebenläufigkeit mit Threads)) ist dagegen eine Vermeidung von Schreibkonflikten möglich. Die Aufgabenstellung verlangt, dass zwei vom main-Thread aus gestartete Threads unabhängig voneinander Suchvorgänge durchführen und ihre Ergebnisse dem main-Thread übergeben. Legt dieser vor dem Start der beiden Suchthreads eine einzige Datenstruktur, zum Beispiel eine ArrayList, an, in die beide Suchthreads ihre Ergebnisse eintragen, dann sind Schreibkonflikte vorprogrammiert. Legt der main-Thread hingegen zwei Datenstrukturen für die Ergebnisse an, und ordnet jedem Suchthread seine eigene Datenstruktur für das Schreiben der Ergebnisse zu, dann können keine Schreibkonflikte entstehen.

Behandlung von Schreibkonflikten

Sollen oder müssen Schreibkonflikte behandelt werden, dann stellt Java dafür einen Synchronisationsmechanismus zur Verfügung. Viele Java-Datenstrukturen, allerdings nicht alle, verwenden diesen Mechanismus, ohne dass dies äußerlich sichtbar ist. Darüber hinaus ist es möglich, irgendeine Java-Datenstruktur zu verwenden und dem Zugriff auf sie den Synchronisationsmechanismus aufzuprägen.

Ist eine Java-Datenstruktur mit dem Synchronisationsmechanismus ausgestattet, dann nennt man sie threadsicher oder synchronized. Eine solche Datenstruktur verhindert, dass zu irgendeinem Zeitpunkt mehr als ein Thread auf sie zugreifen kann. Instanzen von Klassen wie Zaehler im Beispiel LostUpdate.java werden nicht threadsicher genannt. Bei ihrer Verwendung können Schreibkonflikte auftreten.

Threadsichere und nicht threadsichere Java-Datenstrukturen

Bei den Klassen der Standard-Java-Bibliothek ist in der Regel angegeben, ob sie threadsicher sind. So steht in der Java-Dokumentation beispielsweise bei der Klasse StringBuffer:

A thread-safe, mutable sequence of characters.

Das ist demnach eine threadsichere, veränderliche Folge von Zeichen. Hingegen ist bei der Klasse ArrayList angegeben:

Note that this implementation is not synchronized.

Beim nebenläufigen Beschreiben einer ArrayList ist mit Schreibkonflikten zu rechnen, denn diese Datenstruktur ist nicht threadsicher.

Synchronisierte Blöcke

Der Synchronisationsmechanismus, den Java einsetzt, um Threadsicherheit zu erreichen, steht der Programmierung auch als Sprachmittel zur Verfügung. In der Klasse Object ist festgelegt, dass diese Klasse und jede ihrer Instanzen jeweils eine Sperre besitzen. Da jede andere Klasse von Object abgeleitet ist, hat demnach jede Klasse und jede Instanz einer Klasse eine Sperre. Beispielsweise stehen mit der Anweisung

String abc = "Hallo";

zwei Sperren zur Verfügung:

  1. die Sperre der Klasse String und
  2. die Sperre der Stringinstanz abc.

Die Konstruktionen

synchronized(abc) { // Befehle; }

und

synchronized(abc.getClass()) { // Befehle; }

zeigen das Ansprechen beider Sperren im Programmcode. Sie heißen synchronisierte Blöcke. Im Parameter der synchronized()-Anweisung wird eine Referenz auf eine Instanz oder auf eine Klasse angegeben, deren Sperre verwendet werden soll. Zu dem Parameter der zweiten Konstruktion ist zu anzumerken, dass es in Java weitere Möglichkeiten gibt, mit denen eine Referenz auf eine Klasse gebildet werden kann.

Kommt die Abarbeitung eines Programms an einen synchronisierten Block, dann wird durch die synchronized()-Anweisung zunächst geprüft, ob die angesprochene Sperre bereits gesetzt ist. Ist dies der Fall, dann wird mit der Abarbeitung des Blocks so lange gewartet, bis die Sperre wieder freigegeben worden ist. Anderenfalls wird die Sperre gesetzt und der Block wird abgearbeitet. Beim Verlassen des Blocks gibt die synchronized()-Anweisung die Sperre wieder frei.

Man beachte, dass eine Sperre nur dann wirkt, wenn sie respektiert wird. Angenommen, ein Thread t1 hat die Anweisung synchronized(abc) mit einer Stringinstanz abc erfolgreich ausgeführt und bearbeitet in dem synchronisierten Block bestimmte Variablen. Wird er jetzt vom Time Sharing der JVM unterbrochen und ein Thread t2 ohne eine entsprechende synchronized()-Anweisung bekommt Rechenzeit, dann beachtet t2 die Sperre nicht. Er kann ungehindert und konkurrierend mit t1 dieselben Variablen manipulieren wie dieser.

Angenommen, ein Thread t1 hat eine Sperre gesetzt und ein Thread t2 will jetzt dieselbe Sperre setzen, dann wird t2 durch die synchronized()-Anweisung in einen Wartezustand (Blocked) versetzt. Wollen jetzt noch weiterere Threads ebenfalls diese Sperre setzen, dann kommen auch diese in einen Wartezustand. Ist schließlich t1 mit der Abarbeitung des synchronisierten Blocks fertig, wird die Sperre freigegeben, was zur Folge hat, dass alle, an der Sperre wartetenden Threads miteinander um das Setzen der Sperre konkurrieren. Derjenige dieser Threads, der vom Scheduler als erster Rechenzeit bekommt, kann die Sperre setzen, alle anderen müssen weiter warten.

Synchronisierte Methoden

Eine Sperre kann nur wirken, wenn alle miteinander konkurrierenden Threads exakt dieselbe Sperre benutzen. Zwei Threads beispielsweise, die beide die Anweisung

String abc = "Hallo";

ausführen und die Sperre von abc benutzen, haben zwei verschiedene Sperren, weil jeder eine eigene Instanz der Stringklasse benutzt. Um diese Problematik einzuschränken, sind synchronisierte Methoden eingeführt worden. Sie benutzen die Sperre der Instanz this bzw. die der Klasse, die durch this.getClass() referenziert wird.

Bei den folgenden Sprachkonstruktionen ist als Methodentyp void angegeben, aber sie gelten für jeden Typ. Synchronisierte Methoden entstehen in Java dadurch, dass eine Methode der Form

void f() { synchronized(this) { // Befehle; } }

kurz als

synchronized void f() { // Befehle; }

geschrieben werden darf. Analog darf für

static void f() { synchronized(this.getClass()) { // Befehle; } }

die folgende Kurzschreibweise verwendet werden:

static synchronized void f() { // Befehle; }

Lösung des Problems der verloren gegangenen Änderung

In dem Programm LostUpdate.java greifen die beiden miteinander konkurrierenden Threads mit Hilfe der update()-Methode auf eine gemeinsame int-Variable zu. Wird diese Methode als synchronized erklärt, ist das Problem der verloren gegangenen Änderung gelöst:

NoLostUpdate.java

Bei Problemen, wie bei dem am Anfang dieses Abschnitts angesprochenen Produzenten-Konsumenten-Problem, gelangen Threads während sie die Befehle eines synchronisierten Blocks (oder einer synchronisierten Methode) abarbeiten in eine Wartesituation, aus der sie nur durch Aktionen eines anderen Threads befreit werden können. Wenn dieser andere Thread aber dafür auf Variablen zugreifen muss, die durch die Sperre geschützt sind, dann muss er ebenfalls warten. Um mit solchen Situationen umgehen zu können, bedarf es Hilfsmittel, die über synchronisierte Blöcke und Methoden hinausgehen. Sie vorzustellen bleibt einer Vertiefungsveranstaltung vorbehalten.

Sperren am Thread-Ende

Wird ein Java-Thread beendet, werden - auch im Fehlerfall - alle Sperren, die er gesetzt hat, frei gegeben.

Wiedereintrittsfähigkeit

Es ist in Java zulässig, dass ein Thread, der eine Sperre gesetzt hat, eine weitere Sperre setzen (eine synchronized()-Methode aufrufen) kann. Man sagt dazu, Java-Sperren seien reentrant (wiedereintrittsfähig). Hat ein Thread eine Sperre gesetzt und setzt sie erneut ohne vorherige Freigabe, dann bleibt dieses Vorgehen wirkungslos und führt auch nicht zu einer Fehlermeldung.



Zurück zum Inhaltsverzeichnis des Manuskripts verteilte Systeme