9.10 Synchronisation über kritische Abschnitte
Wenn Threads in Java ein eigenständiges Leben führen, so ist dieser Lebensstil nicht immer unproblematisch für andere Threads, insbesondere beim Zugriff auf gemeinsam genutzte Ressourcen. In den folgenden Abschnitten werden wir mehr über gemeinsam genutzte Daten und Schutzmaßnahmen beim konkurrierenden Zugriff durch mehrere Threads lernen.
9.10.1 Gemeinsam genutzte Daten
Ein Thread besitzt zum einen seine eigenen Variablen, etwa die Objektvariablen, kann aber auch statische Variablen nutzen, wie das folgende Beispiel zeigt:
class T extends Thread
{
static int result;
public void run() { ... }
}
In diesem Fall können verschiedene Exemplare der Klasse T, die jeweils einen Thread bilden, Daten austauschen, indem sie die Informationen in result ablegen oder daraus entnehmen. Threads können aber auch an einer zentralen Stelle eine Datenstruktur erfragen und dort Informationen entnehmen oder Zugriff auf gemeinsame Objekte über eine Referenz bekommen. Es gibt also viele Möglichkeiten, wie Threads - und damit potenziell parallel ablaufende Aktivitäten - Daten austauschen können.
9.10.2 Probleme beim gemeinsamen Zugriff und kritische Abschnitte
Wenn Threads ihre eigenen Daten verwalten, ist es kein Problem, da ein Thread dabei anderen Threads nicht in die Quere kommt. Auch wenn mehrere Threads gemeinsame Daten nur lesen, macht dies keine Sorgen; doch Schreiboperationen sind kritisch. Wenn sich zehn Nutzer einen Drucker teilen, der die Ausdrucke nicht als unteilbare Einheit bündelt, dann lässt sich leicht ausmalen, wie das Ergebnis aussieht. Seiten, Zeilen oder gar einzelne Zeichen aus verschiedenen Druckaufträgen werden bunt gemischt ausgedruckt.
Die Probleme haben ihren Ursprung in der Art und Weise, wie die Threads umgeschaltet werden. Der Scheduler unterbricht zu einem für uns unbekannten Zeitpunkt die Abarbeitung eines Threads und lässt den nächsten arbeiten. Wenn nun der erste Thread gerade Programmzeilen abarbeitet, die zusammengehören, und der zweite Thread beginnt parallel auf diesen Daten zu arbeiten, ist der Ärger vorprogrammiert. Wir müssen also Folgendes ausdrücken können: Wenn ich den Job mache, dann möchte ich der Einzige sein, der die Ressource nutzt, wie etwa einen Drucker. Erst nachdem der Drucker den Auftrag eines Benutzers fertig gestellt hat, darf er den nächsten in Angriff nehmen.
Kritische Abschnitte
Zusammenhängende Programmblöcke, die nicht unterbrochen werden dürfen und besonders geschützt werden müssen, nennen sich kritische Abschnitte. Wenn lediglich ein Thread den Programmteil abarbeitet, dann nennen wir dies gegenseitigen Ausschluss oder atomar. Wir könnten das etwas lockerer sehen, wenn wir wissen, dass innerhalb der Programmblöcke nur von den Daten gelesen wird. Sobald aber nur ein Thread Änderungen vornehmen möchte, ist ein Schutz nötig. Wir werden uns nun Beispiele für kritische Abschnitte anschauen und dann sehen, wie wir diese in Java realisieren können.
9.10.3 Punkte parallel initialisieren
Nehmen wir an, ein Thread T1 belegt das vorher mit (0,0) belegte Point-Objekt p mit den Werten (1,2) und ein Thread T2 gleichzeitig mit den Werten (2,1). Das heißt, T1 führt die Anweisungen
p.x = 1; p.y = 2;
durch und T2 die Anweisungen
p.x = 2; p.y = 1;
Jetzt kann es passieren, dass T1 mit der Arbeit beginnt und x = 1 setzt. Jetzt wird T1 unterbrochen, und T2 kommt an die Reihe. Dieser überschreibt x = 2 und setzt auch y = 1. Jetzt darf T1 weitermachen und vervollständigt y = 2. Wir erkennen das nicht beabsichtige Ergebnis (2,2), es könnte aber auch (1,1) sein, wenn wir das gleiche Szenario beginnend mit T2 durchführen. Je nach zuerst abgearbeitetem Thread hätten wir jedoch entweder (1,2) oder (2,1) erwartet. Die Threads müssen ihre Arbeit also atomar erledigen, und die Zuweisung bildet einen kritischen Abschnitt, der geschützt werden muss.
9.10.4 i++ sieht atomar aus, ist es aber nicht
Das obere Beispiel ist plastisch und einleuchtend, da zwischen Anweisungen unterbrochen werden kann. Das Problem liegt aber noch tiefer. Schon einfache Anweisung wie i++ müssen geschützt werden. Um dies zu sehen, wollen wir einen Blick auf folgende Zeilen werfen:
Listing 9.11 IPlusPlus.java
public class IPlusPlus
{
static int i;
static void foo()
{
i++;
}
}
Die Objektmethode foo() erhöht die statische Variable i. Um zu erkennen, dass i++ ein kritischer Abschnitt ist, sehen wir uns den dazu generierten Bytecode1 für die Methode foo() an:
0 getstatic #19 <Field int i>
3 iconst_1
4 iadd
5 putstatic #19 <Field int i>
8 return
Die einfach aussehende Operation i++ ist also etwas komplizierter. Zuerst wird i gelesen und auf dem Stack abgelegt. Danach wird die Konstante 1 auf den Stack gelegt, und anschließend addiert iadd beide Werte. Das Ergebnis steht wiederum auf dem Stack und wird von putstatic zurück in i geschrieben.
Wenn jetzt auf die Variable i von zwei Threads A und B gleichzeitig zugegriffen wird, kann folgende Situation eintreten:
|
Thread A holt sich den Wert von i in den internen Speicher, wird dann aber unterbrochen. Er kann das um 1 erhöhte Resultat nicht wieder i zuweisen. |
|
Nach der Unterbrechung von A kommt Thread B an die Reihe. Auch er besorgt sich i, kann aber i + 1 berechnen und das Ergebnis in i ablegen. Dann ist B beendet, und der Scheduler beachtet Thread A. |
|
Jetzt steht in i das von Thread B um 1 erhöhte i. Thread A addiert nun 1 auf den gespeicherten alten Wert von i und schreibt dann nochmals denselben Wert wie Thread B zuvor. Insgesamt wurde die Variable i nur um 1 erhöht, obwohl zweimal inkrementiert werden sollte. Jeder Thread hat für sich gesehen das korrekte Ergebnis berechnet. |
Wenn wir die Methode foo() atomar ausführen, dann haben wir das Problem nicht mehr, da das Lesen aus i und das Schreiben zusammen einen unteilbaren, kritischen Abschnitt bilden.
9.10.5 Abschnitte mit synchronized schützen
Soll ein Programmabschnitt oder eine Objekt- oder Klassenmethode atomar ablaufen, so existiert dafür in Java ein Sprachkonstrukt. Wir setzen vor die Methode das Schlüsselwort synchronized.
Das oben genannte Problem mit foo() lässt sich mit synchronized einfach lösen:
synchronized void foo() { i++; }
Bei einem Konflikt (mehrere Threads rufen foo() auf) verhindert synchronized, dass sich mehr als ein Thread gleichzeitig im kritischen Abschnitt, dem Rumpf der Methode foo(), befinden kann. Dies bezieht sich nur auf mehrere Aufrufe von foo() für dasselbe Objekt. Zwei verschiedene Threads können durchaus parallel die Methode foo() für unterschiedliche Objekte ausführen.
Hier klicken, um das Bild zu Vergrößern
Bei einem komplizierten Wort wie synchronized ist es praktisch, dass Eclipse auch Schlüsselwörter vervollständigt. Hier reicht ein Tippen von sy und (Strg)+(_____) für einen Dialog. synchr und (Strg)+(_____) vervollständigt gleich zu synchronized, da die Auswahl präzise genug ist.
Hier klicken, um das Bild zu Vergrößern
Tritt ein Thread in den atomaren Block ein, so können wir uns vorstellen, dass die virtuelle Maschine die Methode wie eine Tür abschließt. Erst wenn die Methode wieder beendet wird, schließt die JVM die Tür wieder auf und ein anderer Thread kann die Methode betreten. Das Ein- und Austreten wird von der Java-Maschine übernommen, und wir müssen das nicht kontrollieren. Kommt ein zweiter Thread zu der abgeschlossenen Methode (das Objekt war verriegelt), muss er warten und wird erst dann hineingelassen, wenn die Markierung gelöscht ist. So ist die Abarbeitung über mehrere Threads einfach synchronisiert. Ein anschauliches Alltagsbeispiel ist nicht schwer zu finden, wenn wir schon einmal bei dem Vergleich mit der Tür sind. Gehen wir aufs Klo, um unsern Job zu machen, schließen wir auch die Tür hinter uns. Möchte jemand anderes auf die Toilette, so muss er warten. Es kann sich vor dem Klo dann auch eine Schlange bilden. Genauso wartet eine Methode, genauer gesagt ein Thread, wegen eines belegten Objekts auf seinen Eintritt in den synchronisierten Block wie ein Wartender vor dem Klo, auch wenn der auf der Toilette Sitzende nach einer langen Nacht einnickt. Mit dem Abschließen und Aufschließen werden wir uns noch intensiver in den folgenden Abschnitten beschäftigen. Wir werden auch sehen, dass bei synchronisierten Methoden alle Türen des Objekts abgeschlossen werden.
Dann machen wir doch gleich alles synchronized ...
In nebenläufigen Programmen kann es schnell zu unerwünschten Nebeneffekten kommen. Das ist auch der Grund, warum threadlastige Programme schwer zu debuggen sind. Warum sollten wir dann nicht also alle Methoden synchronisieren? Wäre dann nicht das Problem aus der Welt? Prinzipiell würde das einige Probleme lösen, doch wir hätten uns damit andere Nachteile eingefangen.
|
Methoden, die synchronisiert sind, müssen von der JVM besonders bedacht werden, damit keine zwei Threads die Methode für das gleiche Objekt ausführen. Wenn also ein zweiter Thread in die Methode eintreten möchte, kann er das nicht einfach machen, sondern muss vielleicht erst neben vielen anderen Threads warten. Es muss also eine Datenstruktur geben, in der wartende Threads eingetragen und ausgewählt werden. Das kostet zusätzlich Zeit und ist im Vergleich zu einem normalen Methodenaufruf viel teurer. |
|
Zusätzlich kommt als Problem hinzu, wenn eine nicht notwendigerweise, also überflüssige, synchronisierte Methode eine Endlosschleife oder lange Operationen durchführt. Dann warten alle anderen Threads auf die Freigabe, und das kann im Fall der Endlosschleife ewig sein. Auch bei Multiprozessorsystemen profitieren wir nicht von dieser Programmiertechnik. Das synchronized macht die Vorteile von Mehrprozessormaschinen zunichte. |
|
Wenn alle Methoden synchronisiert sind, dann steigt auch die Gefahr eines unnötigen Deadlocks. In den nachfolgenden Abschnitten erfahren wir etwas mehr über Deadlocks. |
9.10.6 Monitore
Bisher waren wir mit der Beschreibung noch etwas lasch und haben wahrgenommen, dass die Laufzeitumgebung nur einen Thread in einen Block lässt. Wir wollen nun vertiefen, wie die Java-Maschine dies macht. Dazu definiert jedes Java-Objekt einen so genannten Monitor. Der Begriff geht auf C. A. R. Hoare zurück, der im Aufsatz »Communicating Sequential Processes« von 1978 erstmals dieses Konzept veröffentlichte.
Wenn wir nichts synchronisieren, dann benutzen wir den Monitor auch nicht. Er kann auch nicht direkt genutzt werden, sondern ist eine interne Eigenschaft. Mit dem Monitor - wir nennen ihn auch Lock (leicht zu merken durch einen Schlüssel, der die Tür abschließt) - ist der serielle Zugriff gewährleistet. Wir setzen dann vor die Methode das Schlüsselwort synchronized. Ein betretender Thread setzt dann diesen binären Monitor des aufrufenden Objekts, und andere ankommende Threads müssen warten, wenn dieser gesetzt ist. Wir werden später sehen, dass dieser Monitor kurzfristig über die Methode wait() freigegeben werden kann.
9.10.7 Synchronized-Methode am Beispiel der Klasse StringBuffer
Wir wollen uns noch an ein paar Beispielen ansehen, an welchen Objekten der Monitor beziehungsweise Lock gespeichert wird. Zunächst betrachten wir die Methode charAt() der Klasse StringBuffer und versuchen zu verstehen, warum die Methode synchronized ist.
public synchronized char charAt( int index )
{
if ((index < 0) || (index >= count))
throw new StringIndexOutOfBoundsException(index);
return value[index];
}
Neben charAt() sind noch eine ganze Reihe anderer Methoden synchronisiert, etwa getChars(), setCharAt() und append(). Bei einer synchronized-Methode wird also der Lock bei einem konkreten StringBuffer-Objekt gespeichert. Wäre die Methode charAt() nicht atomar, dann könnte es passieren, dass durch Multithreading zwei Threads das gleiche StringBuffer-Objekt bearbeiten. Probleme kann es zum Beispiel dadurch geben, dass ein Thread gerade den String verkleinert und gleichzeitig charAt() aufgerufen wird. Und wenn zuerst charAt() einen gültigen Index feststellt, dann aber der StringBuffer verkleinert wird, gibt es ein Problem. Dann wäre nämlich der Index ungültig und value[index] fehlerhaft. Da aber charAt() synchronisiert ist, kann kein anderer Thread dasselbe StringBuffer-Objekt über synchronisierte Methoden modifizieren.
Beispiel Das StringBuffer-Objekt sb1 wird von zwei Threads T1 und T2 bearbeitet, indem synchronisierte Methoden genutzt werden. Bearbeitet der Thread T1 den StringBuffer sb1 mit einer synchronisierten Methode, dann kann T2 erst dann eine synchronisierte Methode für sb1 aufrufen, wenn T1 die Methode abgearbeitet hat. Denn T1 setzt bei sb1 die Sperre, die T2 warten lässt. Gleichzeitig kann aber T2 synchronisierte Methoden für ein anderes StringBuffer-Objekt sb2 aufrufen, da sb2 einen ganz eigenen Monitor besitzt. Das macht noch einmal deutlich, dass die Locks zu einem Objekt gehören und nicht zu der synchronisierten Methode.
|
9.10.8 Synchronisierte Blöcke
In manchen Fällen ist das Synchronisieren einer gesamten Funktion etwas viel, und nur ein oder zwei Anweisungen bilden den kritischen Abschnitt. Dann kann eine allgemeinere Variante in Java eingesetzt werden, die nur einen Block synchronisiert. Dazu schreiben wir in Java Folgendes:
synchronized ( objektMitDemMonitor )
{
...
}
Der Block wird in die geforderten geschweiften Klammern gesetzt, und hinter dem Schlüsselwort in Klammern muss ein Objekt stehen, das den zu verwendenden Monitor besitzt. Die Konsequenz ist, dass über einen beliebigen Monitor synchronisiert werden kann und nicht unbedingt über den Monitor des Objekts für das die synchronisierte Methode aufgerufen wurde, so wie es bei synchronisierten Objektmethoden normal ist.
Hinweis Eine synchronisierte Objektmethode ist nichts anderes als eine Variante von:
synchronized( this )
{
// Code der Methode.
}
|
Statisch synchronisierte Blöcke
Nicht nur Objektmethoden, auch Klassenmethoden können sychronized sein. Doch die Nachbildung in einem Block sieht etwas anders aus, da es keine this-Referenz gibt. Hier kann ein Object-Exemplar für ein Lock herhalten, das extra für die Klasse angelegt wird. Dies ist eines der seltenen Beispiele, in dem ein Exemplar der Klasse Object Sinn macht.
Listing 9.12 StaticSync.java
class StaticSync
{
static private Object o = new Object();
static void staticFoo()
{
synchronized( o )
{
// ...
}
}
}
Alternativ könnten wir auch das zugehörige Class-Objekt einsetzen. Wir müssen das entsprechende Klassenobjekt dann nur mittels StaticSync.class erfragen.
9.10.9 Vor- und Nachteile von synchronisierten Blöcken und Methoden
Der Vorteil bei der Schreibweise ist, wie schon festgestellt, dass der zwingend synchronisierbare Teil in einen kleinen Block gesetzt werden kann und der kritische Abschnitt damit kleiner bleibt. Die JVM kann die anderen Teile parallel abarbeiten, und andere Threads dürfen die anderen Teile betreten. Als Resultat ergibt sich eine verbesserte Geschwindigkeit.
Ein Nachteil dieser Variante ist die Lesbarkeit und Wartungsfähigkeit. Einer Methode ist leicht anzusehen, dass sie synchronisiert arbeitet, entweder im Quellcode oder in der API-Dokumentation. Wenn wir fremde Software nutzen, dann bleibt uns nur die Dokumentation und sonst nichts. Wenn jetzt diese Methode intern einen Block synchronisiert, ist das nicht erkenntlich, was ein Nachteil sein kann. Die Software ist schwerer zu warten, wenn an fremden, aus der API-Dokumentation nicht hervorgehenden Objekten synchronisiert wird. In eigenen Programmen sollten wir daher synchronisierte Methoden wegen der besseren Erkennbarkeit bevorzugen.
Und natürlich macht eine Mischung von synchronisierten Objektmethoden mit synchronisierten Klassenmethoden das Programm auch nicht leichter pflegbar ...
9.10.10 Nachträglich synchronisieren
Obwohl viele Java-Funktionen in der Standardbibliothek zur Sicherheit synchronisiert sind, gibt es immer noch einige Methoden, bei denen die Entwickler auf eine Synchronisierung verzichtet haben. Dies kann aus Versehen geschehen sein oder mit der Absicht, keine Performanceprobleme zu verursachen. Denn wie schon geschrieben, führt die Synchronisierung zu erheblichen Geschwindigkeitsverlusten.
Beispiel Im ersten motivierenden Beispiel haben wir die Initialisierung eines Point-Objekts betrachtet. Obwohl der Zugriff auf die Variablen nicht geschützt ist, brächte uns auch setLocation() nicht weiter. Diese Funktion ist nicht synchronisiert, und der Thread könnte in dieser Methode unterbrochen werden. Wollen wir nachträglich sichergehen, dass setLocation() atomar ist, dann besorgen wir uns einen Monitor auf das Point-Objekt und synchronisieren über diesen.
Point p = new Point();
synchronized( p )
{
p.setLocation( 1, 2 );
}
Auf diese Weise kann jeder Aufruf einer nicht synchronisierten Methode nachträglich synchronisiert werden. Jedoch muss dann jeder Zugriff wiederum mit einem synchronized-Block geschützt sein, sonst besteht keine Sicherheit, da setLocation() selbst auf keinen Monitor achtet.
|
9.10.11 Monitore sind reentrant, gut für die Geschwindigkeit
Betritt das Programm eine synchronisierte Methode, bekommt es den Monitor des aufrufenden Objekts. Wenn diese Methode eine andere aufruft, die am gleichen Objekt synchronisiert ist, dann kann sie sofort eintreten und muss nicht warten. Diese Eigenschaft nennt sich reentrant. Sie kann geschwindigkeitssteigernd eingesetzt werden, wenn viele synchronisierte Methoden hintereinander aufgerufen werden sollen.
Wenn das Programm den synchronisierten Block betritt, dann reserviert er den Monitor und kann alle synchronisierten Methoden ohne weitere Überprüfungen ausführen. Im Allgemeinen reduziert diese Technik aber auch die Parallelität, da der kritische Abschnitt künstlich vergrößert wird.
Beispiel In StringBuffer sind viele Methoden synchronisiert. Das heißt, dass bei jedem Aufruf einer Methode der Monitor reserviert werden muss. Das kostet natürlich eine Kleinigkeit, und als Lösung bietet sich an, die Aufrufe in einem eigenen synchronisierten Block zu bündeln.
|
StringBuffer sb = new StringBuffer();
synchronized( sb )
{
sb.append( "Transpirations-" );
sb.append( "Illustration" );
sb.append( "\t" );
sb.append( "Röstreizstoffe" );
}
|
9.10.12 Deadlocks
Ein Deadlock (zu Deutsch etwa tödliche Umarmung) kommt beispielsweise dann vor, wenn ein Thread A eine Ressource belegt, die ein anderer Thread B haben möchte. Dieser Thread B belegt aber eine Ressource, die A gerne bekommen möchte. In dieser Situation können beide nicht vor und zurück und befinden sich in einem dauernden Wartezustand. Deadlocks können in Java-Programmen nicht erkannt und verhindert werden. Uns fällt also die Aufgabe zu, diesen ungünstigen Zustand gar nicht erst herbeizuführen.
Hier klicken, um das Bild zu Vergrößern
Beispiel Zwei Threads schlagen sich um die Objekte a und b. Es kommt dabei zu einem Deadlock, da sie einen Lock besetzen, den der jeweils andere zum Weiterarbeiten braucht.
|
Listing 9.13 Deadlock.java
class Deadlock
{
static Object a = new Object(),
b = new Object();
static class T1 extends Thread
{
public void run()
{
synchronized( a ) {
System.out.println( "T1: Lock auf a bekommen" );
warte();
synchronized( b ) {
System.out.println( "T1: Lock auf b bekommen" );
}
}
}
private void warte() {
try {
Thread.sleep( 1000 );
} catch ( InterruptedException e ) { }
}
}
static class T2 extends Thread
{
public void run()
{
synchronized( b ) {
System.out.println( "T2: Lock auf b bekommen" );
synchronized( a ) {
System.out.println( "T2: Lock auf a bekommen" );
}
}
}
}
public static void main( String args[] )
{
new T1().start();
new T2().start();
}
}
In der Ausgabe sehen wir nur zwei Zeilen, und dann hängt das gesamte Programm.
T1: Lock auf a bekommen
T2: Lock auf b bekommen
Eine Lösung des Problems wäre, bei geschachteltem Synchronisieren auf mehrere Objekte diese immer in der gleichen Reihenfolge zu belegen, also etwa immer erst a, dann b. Bei unbekannten, dynamisch wechselnden Objekten muss dann unter Umständen eine willkürliche Ordnung festgelegt werden.
9.10.13 Erkennen von Deadlocks
Die neue Sun Java HotSpot VM besitzt eine eingebaute Deadlock-Erkennung, die auf der Konsole aktiviert werden kann. Dazu ist unter Windows die Tastenkombination Ctrl-Break zu drücken und unter Linux oder Solaris Ctrl+\. Für das obige Programm ergibt sich unter den letzten Ausgabezeilen:
Java stack information for the threads listed above:
===================================================
"Thread-2":
at Deadlock$T2.run(Deadlock.java:36)
- waiting to lock <02A2F0E8> (a java.lang.Object)
- locked <02A2F0F0> (a java.lang.Object)
"Thread-1":
at Deadlock$T1.run(Deadlock.java:16)
- waiting to lock <02A2F0F0> (a java.lang.Object)
- locked <02A2F0E8> (a java.lang.Object)
Found 1 deadlock.
Das ist genau die Meldung, die bei gegenseitigem Blockieren hilft. Zwar findet das Tool nicht alle Deadlocks, insbesondere nicht die, in der Threads mit wait() auf Monitore warten, aber sonst kann es insbesondere bei grafischen Programmen eine gute Hilfe sein.
1 Machbar zum Beispiel mit dem jeder Java-Distribution beiliegenden Dienstprogramm javap -c.
|