Nebenläufigkeit in Java Maurice Schoenmakers [email protected]−muenchen.de Inhaltsverzeichnis 1 Einleitung .................................................................................................................................... 2 2 Prozesse und Threads in Java..................................................................................................... 2 2.1 Die Klasse Thread ................................................................................................................. 3 2.2 Der aktuelle Thread................................................................................................................ 3 2.3 Mehrere Threads................................................................................................................... 4 2.4 Mehrere Threads mit gemeinsamen Variablen....................................................................... 5 3 Synchronisation paralleler Threads in Java.............................................................................. 7 3.1 Kritischer Bereich.................................................................................................................. 9 3.2 Bewachte kritische Bereiche ................................................................................................ 13 3.3 Das Monitor−Konzept in Java.............................................................................................. 15 3.4 Semaphore........................................................................................................................... 19 3.4.1 Ganzzahlige Semaphore ............................................................................................... 19 3.4.2 Boolesche Semaphore................................................................................................... 19 3.5 Verklemmung...................................................................................................................... 20 3.5.1 Koch−Beispiel.............................................................................................................. 20 3.5.2 Verschachteltes Monitor Problem................................................................................. 22 4 Zuteilungsstrategien in Java für Threads................................................................................ 23 4.1 Zeitscheibenstrategie............................................................................................................ 25 4.2 Sperre mit Warteschlange..................................................................................................... 26 5 Anmerkungen............................................................................................................................ 27 6 Literatur..................................................................................................................................... 28 1 Einleitung Ziel dieser Zusammenfassung ist es, die Programmierkonzepte in Java zur Erzeugung und Synchronisation von Nebenläufigkeit zu erläutern. Einfache Grundkentnisse in Java werden dabei vorausgesetzt. Die erläuterten Programmierkonzepte werden im Bezug gesetzt zu den Grundbegriffen zur Programmierung verteilter Systeme aus dem Kapitel 1 des Buches "Informatik, Eine grundlegende Einführung" [Bro98]. 2 Prozesse und Threads in Java Ein Java−Programm besitzt in der Regel eine Klasse mit einer statischen Methode main() . Bei jedem Start eines Java Programms erzeugt das Betriebsystem einen Prozeß und startet die virtuelle Maschine (VM). Die virtuelle Maschine erzeugt einen Haupt−Thread (engl.main thread) auch Programmfaden, Kontrollfluß oder leichtgewichtiger Prozeß genannt. Der Haupt−Thread führt daraufhin die Methode main() aus. Es wird also zwischen Java−Prozesse und −Threads (threads) unterschieden. Ein Java−Prozeß kann als Realisierung eines Prozesses des Prozeßmodells der Vorlesung gesehen werden. Ein Java−Thread entspricht einem sequentiellen Teilprozeß. Alle Teilprozesse zusammen formen den Gesamtprozeß. Eine Methode im Quelltext besteht aus eine Sequenz von Anweisungen. Beim Übersetzen des Quelltextes, wird jede Anweisung in mehrere elementare Teilanweisungen (byte codes) für die VM zerlegt. Ein Java−Threads führt diese einzelnen elementaren Anweisungen einer Methode und die Anweisungen der darin aufgerufenen Methoden sequentiell aus. Die elementaren Anweisungen sind als Aktionen des Teilprozesses zu sehen. Bei Applets startet der Internet−Browser ein Java−Programm. Für jedes Applet wird ein eigener Thread erzeugt, der dann die Methode init() ausführt. Mehrere Applets laufen immer gemeinsam innerhalb des vom Browser erzeugten Prozesses. Innerhalb eines Java−Programms ist es möglich mit Hilfe der Klasse Thread weitere nebenläufige Threads zu erzeugen. Mehrere Threads laufen parallel ab. Sie k önnen gleichzeitig auf gleiche Objekte und Daten zugreifen. Dadurch kann es zu der, in der Vorlesung erw ähnten Konflikte, kommen. Auf die Konflikte und wie sie g elöst werden, wird später detailliert eingegangen. Mehrere Threads in einem Programm werden gebraucht, um mehrere Aufgaben parallel zu erledigen. Beispiele: In einem Textprogramm wird ein Druckauftrag erteilt. Mit dem Editieren kann fortgefahren werden. Im Hintergrund wird die Aufbereitung des Textes f ür den Drucker erledigt. In einer Anwendung zum Erfassen und Pflegen von Daten kann in einer Maske eine Suche gestartet werden. In einer anderen Maske können währenddessen Daten weiter editiert werden. 2 / 28 Java Threads und das Betriebssystem Java−Prozesse werden auf Betriebssystem−Prozesse abgebildet. Sie stellen in Java streng getrennte Bereiche dar. So kann ein Thread eines Java−Prozesses nie auf Objekte in anderen Prozessen zugreifen. Eine Kommunikation ist nicht direkt, sondern nur über Verteilungstechnologien wie CORBA oder DCOM, möglich. Diese basieren indirekt auf Netzwerkdiensten des Betriebsystems. Die Java−Threads können von der virtuellen Maschine auf Betriebsystem−Threads abgebildet werden. Man spricht von einer VM, die native threads unterstützt. Dies ist jedoch nicht immer der Fall. Die meisten ersten Unix−Implementierungen der VM verwenden noch ein eigenes Betriebssystem unabhängiges Verfahren, häufig green threads genannt. Dabei werden Threads simuliert. Diese Simulation benutzt ein speziellen Scheduler, der die Reihenfolge der Ausführung festlegt , und oft zu einem unerwarteten und manchmal unerwünschten deterministischen Verhalten. (Siehe auch 4. Zuteilungsstrategien in Java) 2.1 Die Klasse Thread Java bietet eine Klasse Thread an, die einen Thread repräsentiert. Mit Hilfe dieser Klasse können Threads erzeugt werden. Es werden hier nur die wichtigsten Methoden besprochen. 2.2 Der aktuelle Thread Die statische Methode currentThread() liefert den Zugriff auf den aktuellen Thread, der die Methode gerade ausführt. Ein einfaches Programm, das den aktuellen Thread am Bildschirm ausgibt: public class ThreadTest { public static void main( String[] arguments ) { System.out.println("THREAD:" + Thread.currentThread() ); } } Am Ende der Methode main() ist auch der Thread und damit der Prozeß beendet. Ausgabe am Bildschirm: THREAD:Thread[main,5,main] Hinter dem Text THREAD: steht, das was die Methode toString() der Klasse Thread zurück liefert. Das sind der Name, die Priorität und die Thread−Gruppe. Auf die Priorit ät und Gruppe wird später unter 4. genauer eingegangen, sie sind zunächst unwichtig. 3 / 28 2.3 Mehrere Threads Jetzt fügen wir eine Klasse Counter hinzu mit einer Methode run(). Die Methode soll die Zahl 0 bis einschließlich 1000 ausgeben. Weiterhin erzeugen wir einen zweiten Thread, um zweimal parallel die Methode run() auszuführen. Die Klasse Counter implementiert die Schnittstelle Runnable um damit die Methode run() für Threads als die auszuführende Methode anzugeben. class Counter implements Runnable { public void run() { Thread thread = Thread.currentThread(); for( long count = 0 ; count <= 1000 ; ++count ) { System.out.println( thread + "COUNT:" + count ); } } } public class MultipleThreadTest { public static void main( String[] arguments ) { Counter counter = new Counter(); Thread secondThread = new Thread( counter, "second" ); secondThread.start(); counter.run(); } } Der Haupt−Thread legt in der Methode main() zunächst ein Objekt counter der Klasse Counter als Zähler an. Ein zweites Thread Objekt, secondThread, wird erzeugt und dabei wird der Z ähler mitgegeben. Mit start() startet der Haupt−Thread den zweiten Thread. Dieser ruft intern die run() Methode des Zählers auf. Der Haupt−Thread läuft parallel weiter und ruft selbst auch die run() Methode auf. Die Methode run() wird jetzt parallel von zwei Threads ausgeführt. Jeder Thread arbeitet seine eigene Schleife ab. Der Schleifen−Zähler, bzw. den Zählerstand, count, ist eine lokale Variable der Methode run() und wird auf dem Stack abgelegt. Jeder Thread besitzt sein eigenen Stack. Damit existiert der Zählerstand jetzt zweimal. Auszug aus einer möglichen Ausgabe: Thread[second,5,main]COUNT:95 Thread[second,5,main]COUNT:96 Thread[second,5,main]COUNT:97 Thread[main,5,main]COUNT:95 Thread[second,5,main]COUNT:98 Thread[main,5,main]COUNT:96 Thread[second,5,main]COUNT:99 4 / 28 Thread[main,5,main]COUNT:97 Thread[second,5,main]COUNT:100 Thread[main,5,main]COUNT:98 Thread[second,5,main]COUNT:101 Thread[main,5,main]COUNT:99 Thread[second,5,main]COUNT:102 Thread[main,5,main]COUNT:100 Thread[second,5,main]COUNT:103 Thread[main,5,main]COUNT:101 Thread[main,5,main]COUNT:102 Thread[main,5,main]COUNT:103 Bemerkung zu den Ausgaben Da ein regelmäßiges Abwechseln von Threads für eine Implementierung einer Java VM nicht vorgeschrieben ist, können die Ausgaben immer gleich sequentiell erscheinen. Verwenden Sie für ein weniger deterministisches Verhalten den RoundRobinScheduler aus 4.1 Zeitscheibenstrategie und schreiben Sie in die erste Zeile der Methode main() : new RoundRobinScheduler(10).start(); Bemerkung zur Methode run() Die Signatur der Methode public void run() wird durch die Schnittstelle Runnable vorgegeben. Diese Schnittstelle wird von der Klasse Thread vorausgesetzt. Man hat grundsätzlich die Wahl, von der Klasse Thread abzuleiten und dessen Methode run() zu überschreiben, oder die Schnittstelle Runnable zu implementieren und dann beim Erzeugen eines Threads das ausführbare Objekt anzugeben. Die Methode run() hat keine Parameter, daher müssen benötigte Daten anderweitig weitergereicht werden. Beispielsweise über Attribute des Objektes, das die Runnable Schnittstelle implementiert. Bemerkung zu JDK 1.2 Einige Methoden der Klasse Thread werden ab Java 1.2 nicht mehr unterstützt: Die Methode stop() und suspend() gaben keine Sperren frei und konnten somit einfach eine Verklemmung produzieren. Die Methode resume() machte ohne die Methode suspend() keinen Sinn mehr. Sperren und Verklemmung werden noch genauer besprochen. 2.4 Mehrere Threads mit gemeinsamen Variablen Wir ändern die Klasse Counter so ab, daß der Zählerstand count ein Attribut wird. Beide Threads versuchen ihrerseits den Zählerstand zu inkrementieren. Ziel ist, daß beide gemeinsam den Zähler von 0 auf 1000 hochzählen. 5 / 28 class Counter implements Runnable { private long count = 0; public void run() { Thread thread = Thread.currentThread(); for( ; count <= 1000 ; ++count ) { System.out.println( thread + "COUNT:" + count ); } } } Auszüge aus einer mögliche Ausgabe: ... Thread[main,5,main]COUNT:44 Thread[main,5,main]COUNT:45 Thread[main,5,main]COUNT:46 Thread[main,5,main]COUNT:47 Thread[main,5,main]COUNT:48 Thread[main,5,main]COUNT:49 Thread[second,5,main]COUNT:49 Thread[main,5,main]COUNT:50 Thread[second,5,main]COUNT:51 Thread[main,5,main]COUNT:52 ... Thread[second,5,main]COUNT:995 Thread[second,5,main]COUNT:996 Thread[second,5,main]COUNT:997 Thread[second,5,main]COUNT:998 Thread[second,5,main]COUNT:999 Thread[second,5,main]COUNT:1000 Thread[main,5,main]COUNT:1001 !!! !!!!! Diese Ausgaben sind, wenn man den Code sieht, nicht direkt plausibel. Was ist z.B. bei der letzten Ausgabe 1001 passiert ? Sei count = 999. Jetzt kann folgender Ablauf stattfinden: Der Haupt−Thread "main" prüft ob count <= 1000 ist. Dies ist der Fall. Jetzt kommt Thread "second" an dieser Stelle an und prüft ebenfalls ob count <= 1000 ist. Dies ist der Fall und count wird inkrementiert und ausgegeben. Der Haupt−Thread inkrementiert count ebenfalls und gibt ihn aus. Das Attribut count war inzwischen jedoch bereits von "second" inkrementiert worden, somit ist der Wert jetzt 1001 ! Offensichtlich ist es notwendig, beide Threads zu koordinieren. Sie müssen so synchronisiert werden, daß nur ein Thread das Prüfen der Bedingung und die Inkrementierung atomar, d.h. vollständig ohne Unterbrechung, ausführt. 6 / 28 Wie immer bei fehlender Synchronisation muß der im Beispiel gezeigte Fehler nicht zwingend auftreten und kann lange Zeit völlig unbemerkt bleiben. Je nachdem, wann die Threads aktiv sind bzw. bei einem Prozessor sich abwechseln, ergeben sich unterschiedliche Resultate. Dies macht es extrem schwer in einem fertigen System fehlerhafte Code−Stellen zu finden. Vor allem kann allein das Suchen eines Fehlers mit Hilfe des Debuggers, den Fehler verschwinden lassen. Durch die Beobachtung mit dem Debugger wird das Abwechseln der Threads beeinflußt. 3 Synchronisation paralleler Threads in Java Mehrere Threads können nur über gemeinsame Variablen kommunizieren. Java stellt keine anderen Kommunikationsmittel zur Verfügung. Folgende Konflikte können dabei auftauchen: Gleichzeitiges Lesen und Schreiben. Ein Thread liest und ein anderer ändert gerade das Objekt, ist aber noch nicht fertig. Gleichzeitiges Schreiben. Mehrere versuchen das Objekt gleichzeitig zu ändern. Gemeinsam benutzte Objekte sollten aus der Sicht eines Threads einen korrekten konsistenten Zustand haben. Der Zustand eines Objektes besteht aus den Werten seiner Attribute und den Zuständen der enthaltenen Objekte. Das Ausführen einer Methode kann den Zustand eines Objektes ändern. Eine Methode sollte das Objekt von einem stabilen korrekten Zustand in einen anderen stabilen korrekten Zustand überführen. Beim Übergang von einem Zustand in einem anderen Zustand können Zwischenzustände auftreten, die nach außen nicht sichtbar werden sollen. Sie sind in der Regel von der Implementierung der Methode abhängig. Rufen mehrere Threads Methoden gleichzeitig auf, dann kann ohne Synchronisation ein Zwischenzustand einer Methode den Ablauf einer anderen Methode beeinflussen. Um Konflikte zwischen Threads zu vermeiden gibt es verschiedene Möglichkeiten. Schreiben nicht zulassen Eine Klasse wird so definiert, daß eine Änderung seiner Attribute nach dem Erzeugen der Objekte nicht mehr möglich ist. ( engl. immutable class). Dies scheint eine drastische Einschränkung, kann aber in manchen Fällen durchaus sinnvoll sein. Vor allem, wenn ein Objekt einen Wert darstellt und die Identität nicht wesentlich ist, kann man sich die meist zeitintensive Synchronisation sparen. Bei Java können z.B. mehrere Threads problemlos auf ein gleiches Objekt der Klasse java.lang.String zugreifen, da ein String nie mehr geändert werden kann. Jede Methode der Klasse produziert bei einer Änderung des Wertes ein neues String Objekt. Für eine performante Änderung eines String steht die Klasse java.lang.StringBuffer zur Verfügung, diese ist aber nicht sicher für die gleichzeitige Verwendung durch mehrere Threads. ( engl. thread safe) 7 / 28 Lesen und Schreiben synchronisieren Für die meisten Objekte jedoch ist die Identität wichtig. Nur eine explizite Synchronisation, die nachfolgend erläutert wird, kann Konflikte vermeiden. Die Sperre Java verknüpft jede Instanz der Klasse java.lang.Object mit einer sogenannten Sperre ( engl. lock). Jedes Objekt ist in Java direkt oder indirekt von der Klasse java.lang.Object abgeleitet, somit ist mit jedem Objekt genau eine Sperre verknüpft. Diese Sperre entspricht einem Booleschen Semaphor. Eine Sperre wird einem einzigen Thread zugeteilt. Die anderen Threads müssen gegebenenfalls warten, bis der Thread die Sperre wieder freigibt. Mit diesem gegenseitigen Ausschluß (engl. mutually exclusive lock kurz mutex lock) lassen sich Zugriffe auf Objekte so koordinieren, daß Konflikte vermieden werden. Betrachten wir einen Bereich, in dem gemeinsame Variablen geschrieben oder gelesen werden. Benötigt jeder Thread eine Sperre bevor er diesen Bereich ausführt und gibt er danach die Sperre wieder frei, dann kann zu jedem Zeitpunkt nur ein einziger Thread in dem Bereich aktiv sein. Somit ist ein gleichzeitiges Lesen und Scheiben oder ein gleichzeitiges Schreiben der gemeinsamen Variablen in diesem Bereich unmöglich. In Java müssen alle Bereiche, in dem gemeinsame Variablen geschrieben oder gelesen werden, explizit mit dem Schlüsselwort synchronized versehen werden, um Konflikte zu vermeiden. Sei O ein beliebiger Ausdruck, der ein Java−Objekt liefert, und S eine Menge von Anweisungen, dann ist die Syntax: synchronized( O ) { S } Erreicht ein Thread die synchronized Anweisung, dann gelten folgende Regeln: Der Thread versucht den exklusiven Zugriff auf das Objekt O zu erhalten. Wenn kein anderer Thread den exklusiven Zugriff hat, gelingt dies. Besitzt ein anderer Thread den exklusiven Zugriff auf das Objekt, dann wartet der Thread, bis das Objekt O freigegeben wird. Es können mehrere Threads auf die Freigabe warten. (Siehe auch 3. Zuteilungstrategien). Erhält ein Thread den exklusiven Zugriff auf O, dann tritt er in den Anweisungsblock ein. Verläßt der Thread den Anweisungsblock, in dem er den exklusiven Zugriff erhalten hat, dann gibt er das Objekt O wieder frei. Die mit einem Objekt verknüpfte Sperre ist sonst nicht direkt zugänglich und kann nur mit dem Schlüsselwort synchronized gesetzt und freigegeben werden. 8 / 28 Ausnahmebehandlung Achtung: Wenn eine Ausnahme ( engl. exception) auftritt, die nicht innerhalb eines Anweisungsblocks abgefangen wird, dann wird wie üblich der Anweisungsblock automatisch verlassen und dabei auch die Sperre freigegeben. Daher sollte man auch nach einer Ausnahme einen konsistenten Zustand hinterlassen. Verschachtelte synchronized Anweisungsblöcke Verschachteln von synchronisierten Anweisungsblöcken ist möglich, auch wenn mehrmals mit dem gleichen Objekt synchronisiert wird: Wenn ein synchronisierter Anweisungsblock erreicht wird, kann es also sein, daß der Thread bereits den Zugriff auf das Objekt hat. Dann kann der Thread sofort in dem Anweisungsblock eintreten. Beim Verlassen dieses Anweisungsblocks, wird das Objekt noch nicht freigegeben. Erst beim Verlassen des äußeren Anweisungsblocks, dort wo er den Zugriff ursprünglich erhalten hat, wird das Objekt freigegeben. Damit wird verhindert, daß ein Thread auf sich selbst wartet. Mit einem synchronized Block wird also gewährleistet, daß nur ein Thread in dem Anweisungsblock zu einem Zeitpunkt aktiv sein kann. 3.1 Kritischer Bereich Wir können jetzt das synchronized Schlüsselwort einsetzen, um den vorher beim Zählerstand gezeigten Fehler zu beheben. Die Prüfung und die Inkrementierung stellen einen sogenannten kritischen Bereich dar. Dies ist ein Bereich, wo Konflikte zwischen Threads auftreten können. Wir ändern jetzt den Code so ab, daß die Prüfung und die Inkrementierung nur zusammen ausgeführt werden können. class Counter implements Runnable { private long count = 0 ; public void run() { Thread thread = Thread.currentThread(); boolean bContinue = true; while ( bContinue ) { System.out.println( thread + "COUNT:" + count ); synchronized( this ) { if( count < 1000 ) { ++count; } else bContinue = false; } } } } 9 / 28 Warum wurde this angegeben ? Das Attribut count kann man nicht angeben. Der Typ von count ist long. Dieser Typ ist ein sogenannter primitiver Typ, wie auch short, int, byte, char, float und double. Damit ist count kein Objekt. Allerdings ist count ein Attribut von der Klasse Counter. Folglich gehört zu jedem Attribut namens count genau ein Objekt der Klasse Counter. Daher ist es einfach das Attribut count mit dem zugehörigen Objekt zu synchronisieren. Das Objekt wird in der Methode mit this angegeben. Die for−Schleife wurde in eine while−Schleife geändert, damit die Prüfung und die Inkrementierung in einem Block innerhalb der Schleife stattfinden können. Was passiert, wenn der synchronisierte Anweisungsblock die gesamte for−Schleife umfassen würde ? Warum ist es Problematisch, daß die Ausgabe außerhalb des synchronized Bereiches gemacht wird ? Synchronisierte Methoden Wir ändern die Klasse Counter jetzt so, daß die Prüfung und das Inkrementieren in einer eigenen increment() Methode passieren. Der Rückgabewert gibt an, ob eine Inkrementierung möglich war. class Counter implements Runnable { private long count = 0 ; public void run() { Thread thread = Thread.currentThread(); boolean bContinue = true; while ( bContinue ) { System.out.println( thread + "COUNT:" + count ); bContinue = increment(); } } public boolean increment() { synchronized( this ) { if ( count < 1000 ) { ++count; return true; } return false; } } Jetzt ist die gesamte Implementierung der Methode increment() synchronisiert. Java erlaubt dafür folgende semantisch äquivalente Schreibweise: 10 / 28 Wir ändern die Methode increment() ab: public synchronized boolean increment() { if ( count <= 1000 ) { ++count; return true; } return false; } Dies wird eine synchronisierte Methode genannt. Es wird gewährleistet, daß nur ein Thread gleichzeitig in genau dieser Methode aktiv sein kann. Es wird nicht gewährleistet, daß kein anderer Thread gleichzeitig in einer anderen Methode aktiv ist. D.h. count kann in einer anderen Methode weiterhin gleichzeitig geändert werden. Lesende Methoden müssen synchronisiert werden Wir führen ein Methode getCount() ein, die den Zählerstand zurück liefert. public long getCount() { return count; } Nehmen wir an, ein Thread ruft irgendwann getCount() auf. Auf den ersten Blick ist nicht ersichtlich, daß hier ein Problem vorliegt. Die Methode getCount() kann ein fehlerhaftes Ergebnis haben und zwar aus zwei folgenden Gründen: getCount() kann ausgeführt werden, während irgendeine andere auch synchronisierte Methode z.B increment() oder eine andere Methode aus einer Subklasse ausgeführt wird. Diese andere Methode könnte count in ihrem Ablauf zwischenzeitlich auf den Wert −1 setzen und erst am Ende den korrekten Wert zuweisen. Die Methode getCount() könnte dann zwischenzeitlich den unsinnigen Wert −1 liefern. Auch wenn keiner den Wert zwischenzeitlich auf −1 setzt, kann getCount() einen falschen Wert liefern. Java garantiert, daß alle Operationen auf primitive Typen atomar ausgeführt werden, außer für double und long. D.h. Die Ausführung von ++count, könnte in 2 Schritten ablaufen. Zwischen diesen beiden Schritten kann ein Threadwechsel auftreten. Der Wert von count nach dem 1. Schritt ist nicht spezifiziert. Was wir eigentlich erreichen wollten ist eine immer konsistente Sicht auf Counter von außen. Daher muß auch getCount() synchronisiert werden: 11 / 28 public synchronized long getCount() { return count; } Damit ist auch klar, warum eine Methode getCount() unerläßlich ist, und count nicht einfach public gemacht werden kann. Aus der bisherigen Beschreibung ist ersichtlich, daß, um mehrere Threads zu unterstützen, wesentliche Codeänderungen notwendig waren. Vollständig synchronisierte Klassen Sind in einer Klasse alle nicht statischen Methoden über dasselbe Objekt synchronisiert und alle Attribute private, dann kann ein Objekt dieser Klasse nur von jeweils einem Thread bearbeitet werden. Dies entspricht dem Konzept des Monitors, bei dem nur eine Operation eines Monitors gleichzeitig ausgeführt werden kann. Die beim Monitor üblichen Benachrichtigungsmechanismen existieren auch in Java. Sie werden anschließend im Abschnitt 2.3 erläutert. Synchronisation statischer Methoden In Java gehört zu jeder Klasse eine, vom System erzeugte Instanz der Klasse java.lang.Class. Diese Instanz beschreibt den Aufbau der Klasse und macht diese zur Laufzeit verfügbar. Der Klassenname gefolgt von ¨.class¨ ,liefert diese Instanz. Wenn eine statische Methode als synchronized gekennzeichnet wird, dann entspricht dies einer Synchronisierung mit der zu dieser Klasse gehörenden Instanz der Klasse java.lang.Class. Beispiel: public class Counter { private static long maximum = 1000; public static synchronized long getMaximum() { return maximum; } … } Die Methode getMaximum() entspricht folgender Implementierung: public static long getMaximum() { synchronized( Counter.class ) { return maximum; } } 12 / 28 Synchronisierte Methoden und Vererbung. Die Synchronisation gehört in Java nur zur Implementierung und wird nicht vererbt ! Daher kann auch eine Schnittstellen−Definition nicht das Schlüsselwort synchronized enthalten. Beim überschreiben einer Methode, darf daher die Synchronisation bei der neuen Implementierung nicht vergessen werden. 3.2 Bewachte kritische Bereiche Bisher konnte ein Thread nur auf eine einzige Bedingung warten. Und zwar darauf, ob ein Zugriff zur Verfügung steht oder nicht. Manchmal jedoch möchte man komplexere zustandsabhängige Bedingungen formulieren auf deren Erfüllung man wartet. Man spricht dann von einem bewachten kritischen Bereich. Ein Thread wartet bis eine Bedingung erfüllt ist, bevor er in dem Bereich eintritt. ( engl. guarded suspension) In der Vorlesung wurde dies durch await E then S endwait zum Ausdruck gebracht. Wobei E einen booleschen Ausdruck und S eine Menge von Anweisungen ist. Der Ausdruck E stellt den Wächter ( engl.guard ) dar. Sobald E erfüllt ist , wird S exklusiv von einem einzigen Thread ausgeführt. Die exklusive Ausführung wird in Java durch das Schlüsselwort synchronized erreicht. Das Warten, hier auf eine Bedingung, wird durch ein Aufruf der Methode wait() eines Objektes eingeleitet: public class ClassWithGuardedSuspension { … private synchronized void awaitConditionE() { while( ! E ) { try { wait(); } catch( InterruptedException ex ){} } } public void synchronized guardedStatementsS() { awaitConditionE(); S } } 13 / 28 Wozu wird wait() gebraucht ? Würde man statt wait() eine Schleife wie while(!E){ … } verwenden, dann spricht man von busy waiting. Der Thread wertet die ganze Zeit, meistens unnötigerweise, die Bedingung aus. Weiterhin wird dabei die Sperre nicht abgegeben. Dadurch kann ein anderer Thread nie an die Reihe kommen, um den Zustand so zu ändern, daß die Bedingung jemals wahr wird ! Busy waiting ist folglich zu vermeiden. Ein wartender Thread mu ß auch benachrichtigt (engl. notified) werden, daß er weiter machen kann. Daher existieren folgende vom Laufzeitsystem vorgegeben Methoden der Klasse Object: wait() notify() notifyAll() Nur wenn ein Thread den exklusiven Zugriff auf ein Objekt hat, kann er dessen wait() , notify() oder notifyAll() Methode aufrufen. Ansonsten wird ein IllegalMonitorStateException vom Laufzeitsystem erzeugt. wait() notify() Der aktuelle Thread wird auf wartend gesetzt und in einem mit dem Objekt verknüpften Wartebereich gestellt Den exklusiven Zugriff, den der Thread hat, wird freigegeben. Was Voraussetzung daf ür ist, daß andere Threads überhaupt notify() oder notifyAll() aufrufen können. Wenn wartende Threads existieren , dann wird nichtdeterministisch einer ausgewählt und dieser aus dem Wartebereich entfernt. Der ausgewählte Thread versucht Zugriff auf das Objekt zu bekommen. Der ausgewählte Thread kann den Zugriff nur dann bekommen, wenn der Thread der notify() aufgerufen hat und noch im Besitz des Zugriffs, diesen freigibt und kein anderer Thread den Zugriff erhält. Erhält der ausgewählter Thread später den Zugriff, dann setzt er die Ausführung mit dem auf wait() folgendem Code fort. notifyAll() Verläuft wie notify() mit dem Unterschied, daß alle wartenden Threads aus dem Wartebereich entfernt werden und wieder versuchen die Sperre zu bekommen. Es existiert noch die Möglichkeit bei wait() die maximale Wartezeit anzugeben. Ist diese verstrichen, dann wird automatisch für diesen Thread wie bei notify() verfahren. Wenn ein Unterbrechung ( engl. interrupt) während eines wait() passiert ( z.B. durch Aufruf der interrupt() Methode der Klasse Thread), dann wird wie bei notify() verfahren. Die Ausführung wird aber fortgefahren mit dem Werfen einer Ausnahme der Klasse InterruptedException. Das Ziel ist es mit wait() auf ein Ereignis zu warten. Das Ereignis wird durch das Objekt oder einen Zustand des Objektes repräsentiert. Mit notify() wird dann das Eintreten des Ereignisses mitgeteilt. 14 / 28 Da die Auswahl der Threads beim notify() nichtdeterministisch ist, kann es zum Aushungern kommen. D.h. bestimmte Threads werden nie aus dem Wartbereich ausgewählt. Nur eine selbst programmierte Warteschlange kann hier Fairness garantieren. (siehe auch 2.6.2 ) Somit kann jedes Java−Objekt als Signal, wie beim Monitorkonzept in der Vorlesung definiert, verwendet werden. 3.3 Das Monitor−Konzept in Java Das Monitor−Konzept der Vorlesung läßt sich in Java realisieren durch eine Klasse, in der alle Attribute private sind, alle nicht statischen Methoden als synchronized gekennzeichnet sind und das Objekt selbst immer als Signal verwendet wird: Die Kennzeichnung aller Methoden als synchronized garantiert den gegenseitigen Ausschluß. Die Verwendung des Objektes selbst als Signal, sorgt für eine Freigabe der Sperre beim wait(), und ermöglicht damit die Ausführung der Methoden durch andere Threads. Bei der Verwendung als Signal, wird für ein Objekt o in der Regel angenommen, daß man beim Aufruf von o.wait() auf eine Zustandsänderung von o wartet. Bei o.notify() gibt man dann die Zustandsänderung bekannt. Diese Interpretation ist jedoch nicht zwingend. Beispiel für einen Monitor unter Verwendung von wait() und notifyAll(): Wir ändern jetzt increment(), so daß inkrementiert wird, sobald dies möglich ist. Der Rückgabewert ist damit überflüssig. Analog zu increment() führen wir eine Methode decrement() und ein Minimum ein. Die Methode decrement() versucht zu dekrementieren, sobald dies möglich ist. Dann lassen wir einen Thread hochzählen und einen anderen Thread runterzählen. public class Counter { private static long maximum = 1000; private static long minimum = 0; public static synchronized long getMaximum() { return maximum; } public static synchronized long getMinimum() { return minimum; } private long count = getMinimum(); private synchronized void awaitIncrementable() { while( !( count < getMaximum() ) ) { try { wait(); } 15 / 28 catch( InterruptedException e ) {} } } public synchronized void increment() { awaitIncrementable(); setCount( count +1 ); } private synchronized void awaitDecrementable() { while( !( count > getMinimum() ) ) { try { wait(); } catch( InterruptedException e ){} } } public synchronized void decrement() { awaitDecrementable(); setCount( count −1 ); } public synchronized void setCount( long count ) { this.count = count; System.out.println( Thread.currentThread() + "COUNT:" + count ); notifyAll(); } public synchronized long getCount() { return count; } } public class CounterTest { public static void main( String[] arguments ) { final Counter counter = new Counter(); Thread secondThread = new Thread( "second" ) { public void run() { while ( true ) { counter.increment(); } } }; secondThread.start(); while( true ) { counter.decrement(); } } } 16 / 28 Jedesmal wenn count sich ändert, wird setCount und damit notifyAll() aufgerufen. Ein Thread der z.B. bei einem awaitIncrementable wartet, wird dann geweckt. Ist der count < maximum, dann wird inkrementiert. Warum wird die Bedingung in einer Schleife geprüft ? Der notifyAll() deutet nur eine Änderung an. Nach einem notifyAll() ist keineswegs die Erfüllung der Bedingung gewährleistet. Warum wird notifyAll() verwendet ? Der Aufruf von notify() statt notifyAll(), ist nur dann zu empfehlen, wenn es nur eine Bedingung gibt und nachher garantiert ist, daß nur ein einziger Thread aktiv werden kann. Wie kann die Anzahl der notify() Aufrufe und der damit verbundene Synchronisationsaufwand reduziert werden? Hinweis: Wenn der Zustandsraum aufgeteilt wird, ergeben sich nur bestimmte Situationen, wo ein notifyAll() sinnvoll ist. Bemerkungen Unsere Klasse Counter ist ein Monitor im Sinne der Vorlesung. Wenn in einer synchronisierten Methode ein wait() aufgerufen wird, dann kann die Operation nicht mehr als atomar betrachtet werden! Beim wait() wird die Sperre freigegeben und andere Threads können den Zustand ändern. Daher sollte das Objekt in einen konsistenten Zustand sein bevor wait() aufgerufen wird. Am einfachsten ist dies dadurch zu erreichen, indem vor einem wait() keine Änderungen am Objekt gemacht werden. Wird der Zustand so geändert, daß ein anderer Thread weitermachen könnte, ruft man notifyAll() oder notify() auf. Danach sollte der Thread, solange er den Zugriff hat, den Zustand nicht mehr ändern. Die geweckten Threads können erst nach der Freigabe der Sperre aktiv werden und nicht schon unmittelbar nach dem notifyAll() Aufruf. Ein geweckter Thread findet dann vielleicht einen Zustand vor, in dem die Bedingung eventuell nicht mehr erfüllt ist. Am einfachsten ist es, wenn ein notify() Aufruf als möglichst letzter Aufruf vor dem Verlassen der Methode verwendet wird. Eine Nachrichtenschlange in Java Ein Sender und Empfänger kommunizieren über eine Nachrichtenschlange. Der Empfänger soll so lange warten bis ein Objekt vorliegt. public class MessageQueue { private Vector queue = new Vector(); public synchronized void send( Object object ) { queue.addElement( object ); notifyAll(); } 17 / 28 public synchronized Object receive() { while( queue.size() == 0 ) { try { wait(); } catch( InterruptedException e ) {} } Object object = queue.elementAt(0); queue.removeElementAt(0); return object; } } // send a string character by character in an endless loop public class Sender extends Thread { private String text; private MessageQueue messageQueue; public Sender( String text, MessageQueue messageQueue ) { super( "sender of " + text ); this.text = text; this.messageQueue = messageQueue; } public void run() { while ( true ) { for( int i = 0 ; i < text.length() ; ++i ) { messageQueue.send( text.substring( i,i+1 ) ); } } } } public class MessageQueueTest { public static void main( String[] arguments ) { MessageQueue messageQueue = new MessageQueue(); Thread sosThread = new Sender( "S.O.S!!!", messageQueue ); sosThread.start(); Thread helloThread = new Sender( "Hello??", messageQueue ); helloThread.start(); while ( true ) { System.out.print( messageQueue.receive() ); }; } } Beachte die Analogie der Klasse MessageQueue und der Klasse Counter aus dem vorangehenden Beispiel. Erweiteren Sie die Klasse MessageQueue so, daß die Nachrichtenschlange nur eine maximale Anzahl an Elementen beinhalten kann. 18 / 28 3.4 Semaphore Mit bewachten kritischen Bereiche können, analog zu den Definitionen der Vorlesung, auch Semaphore in Java umgesetzt werden. In der Regel ist eine direkte Verwendung der bestehenden Synchronisationsmechanismen jedoch einfacher. 3.4.1 Ganzzahlige Semaphore Die ganzzahligen Semaphore lassen sich wie folgt realisieren: public final class CountingSemaphore { private int count = 0; public CountingSemaphore( int initialCount ) { count = initialCount; } public synchronized void P() { while( count <= 0 ) { try { wait(); } catch( InterruptedException ex ) {} } −−count; } public synchronized void V() { ++count; notify(); } } Der Aufruf von notify() statt notifyAll() ist ausreichend, da nur ein einziger Thread nach der Freigabe aktiv werden kann: Dieser nimmt sofort die frei gewordene Ressource und die anderen müssen weiterhin warten. 3.4.2 Boolesche Semaphore Die boolesche Semaphore werden analog zu den ganzzahligen Semaphoren implementiert: public final class BooleanSemaphore { private boolean isFree; public BooleanSemaphore( boolean isFree ) { this.isFree = isFree; } 19 / 28 public synchronized void P() { while( !isFree ) { try { wait(); }catch( InterruptedException ex ) {} } isFree = false; } public synchronized void V() { isFree = true; notify(); } } 3.5 Verklemmung 3.5.1 Koch−Beispiel Eine Verklemmung kann leicht auftreten, falls mehrere Threads die gleichen Objekte als Ressourcen in einer verschiedenen Reihenfolge verwenden. Nehmen wir an, es stehen in einer Küche nur eine Schüssel und nur ein Maßbecher sowie weitere übliche Utensilien zur Verfügung. Nehmen wir weiterhin an, ein Koch kann mehrere Omelettes machen: Er nimmt eine Schüssel, füllt die Schüssel mit aufgeschlagenen Eiern und rührt diese. Mit einem Maßbecher mißt er ab, wieviel er für jedes Omelett braucht. Er backt die einzelnen Omeletts in der Pfanne. Ein Koch kann auch Kekse machen: Er nimmt einen Maßbecher, um die Menge an Mehl zu bestimmen. Er gibt den Inhalt des Maßbechers in eine Schüssel und rührt die Ingredienzen zusammen. Anschließend formt er die Kekse und backt sie im Backofen. Das ganze in pseudo Java−Code: 20 / 28 class Koch { static static static static ... Schuessel Massbecher Mehl Ei[] schuessel; massbecher; mehl; eier; void macheOmeletts( int anzahl ) { synchronized( schuessel ) { schuessel.fuelleMit( brecheEier( eier ) ); schuessel.ruehren(); synchronized( massbecher ) { massbecher.fuelleMit( schuessel.inhalt() ); massbecher.messeAnteil(anzahl); ... } } } void macheKekse() { synchronized( massbecher ) { massbecher.messe( mehl ); synchronized( schuessel ) { schuessel.fuelle( massbecher ); schuessel.ruehren(); ... } } } } Nehmen wir an es gibt zwei Köche, Hugo und Fritz. Koch Hugo macht Omeletts und Koch Fritz macht Kekse. Wenn zwei Köche sich gleichzeitig in der Küche aufhalten, dann muß eine Koordination stattfinden. Die allererste selbstverständliche Koordination, die uns einfällt ist, daß die Schüssel nicht von mehreren Köchen gleichzeitig verwendet wird, sondern nur exklusiv von einem Koch. Analoges gilt für den Maßbecher. Ansonsten könnte es passieren, daß die Ingredienzen der Kekse und der Omeletts unkontrolliert vermischt werden. Ein Koch wartet sozusagen als Thread auf eine Freigabe der Schüssel. Dennoch ist diese Koordination nicht ausreichend, die Reihenfolge ist entscheidend. Folgende Situation kann auftreten: Hugo nimmt eine Schüssel, füllt diese mit Eiern. Fritz nimmt gleichzeitig den Maßbecher und mißt Mehl ab. Hugo möchte jetzt abmessen wieviel er für jedes Omelett braucht. Er braucht den Maßbecher, der ist leider noch in Gebrauch. Fritz möchte jetzt die Schüssel nehmen, um das Mehl rein zu geben, die ist leider noch in Gebrauch. 21 / 28 Beide warten endlos aufeinander. Ein Verklemmung ist aufgetreten. (engl. deadlock) Mögliche Lösungen sind: Es darf nur ein Koch gleichzeitig in die Küche. Damit erhöht man die Sperr−Granularität. Dies verringert die Nebenläufigkeit. Oder die Köche reservieren die benötigten Utensilien immer in der gleichen Reihenfolge. Dadurch wird implizit eine Sperren−Hierarchie vorgeschrieben. Bei einer Verklemmung wird von einer übergeordneten Instanz die Verklemmung aufgelöst. Ein Koch muß die Zwischenergebnisse verwerfen und neu anfangen. Diese Möglichkeit wird von Java selbst nicht unterstützt. 3.5.2 Verschachteltes Monitor Problem Eine Klasse wie die MessageQueue ist ein Monitor. Beinhaltet ein Monitor ein weiteren Monitor und ist dieser nur über den äußeren Monitor zugänglich, dann kann es ebenfalls leicht zu einer Verklemmung (engl. deadlock) kommen. Wir ändern jetzt die Klasse MessageQueue so ab, daß das Signal ’’Die Nachrichtenschlange ist nicht leer’’, durch eine Boolesche Semaphore dargestellt wird. public class MessageQueue { private Vector queue = new Vector(); private BooleanSemaphore nonEmpty = new BooleanSemaphore( false ); public synchronized void send( Object object ) { queue.addElement( object ); nonEmpty.V(); } public synchronized Object receive() { nonEmpty.P(); Object object = queue.elementAt(0); queue.removeElementAt(0); return object; } } Wenn ein Thread receive() aufruft und wenn die Schlange leer ist, dann wird beim wait(); in der Methode P() nur die Sperre für die Semaphore freigegeben, nicht die Sperre der MessageQueue. Dadurch kann ein anderer Thread nicht in send() eintreten, d.h. nie mehr eine Nachricht in die Schlange stellen und den Thread, der in P() wartet, nie mehr benachrichtigen. Der Empfänger sperrt somit den Sender aus ( engl. lock out ) Das Problem läßt sich lösen indem: entweder synchronized beim äußeren Objekt entfernt wird, falls dies möglich ist. Oder indem man dem inneren aggregierten Objekt einen Verweis auf das Äußere mitgibt. Somit könnte das innere Objekt das äußere Objekt für die Synchronisation verwenden. Dies bedingt aber eine Codeänderung der Klasse BooleanSemaphore ! 22 / 28 4 Zuteilungsstrategien in Java für Threads Bisher wurde nur von mehreren gleichzeitig aktiven oder wartenden Threads ausgegangen. Thread benötigen für ihre Ausführung einen Prozessor. Viele Java−Implementierungen unterstützen bisher nur einen Prozessor. Bei nur einem Prozessor, kann nur ein Thread aktiv sein. Der Thread wird dem Prozessor zugeteilt. Man spricht auch von Scheduling (engl.). Die Zuteilungsstrategie beeinflußt wesentlich das Verhalten des Systems bei mehreren Threads. Um die Zuteilungsstrategien (engl. scheduling strategies) besser zu verstehen, unterscheiden wir die folgenden Zustände eines Threads: Initialzustand: Der Thread wurde erzeugt, führt aber noch keine Methode aus. d.h. start() wurde noch nicht aufgerufen. Lauffähig: Ein Thread geht vom Initialzustand mit start() in den Zustand ¨Lauffähig¨ über. Aktiv: Ein Thread wird aktiv, wenn er von Javas virtueller Maschine (JVM) dazu aus der Menge der lauffähigen Threads ausgewählt wird. In diesem Zustand führt der Thread tatsächlich Code aus. Der Thread wird vom der JVM dann entweder in den Zustand lauffähig zurück gesetzt oder er kommt in den Zustand ¨Blockiert¨. Blockiert: Ein Thread ist blockiert, wenn er auf den exklusiven Zugriff auf eines bei synchronized angegeben Objektes oder auf ein Ereignis mittels wait() wartet. Ist ein Thread blockiert, dann geht er in den Zustand ¨Lauffähig¨ über, falls ein anderer Thread die Sperre freigibt oder falls ein anderer Thread mit einem notify() oder notifyAll() das Ereignis meldet und daraufhin die JVM den blockierten Thread auswählt. Eine andere Möglichkeit blockiert zu werden , ist der Aufruf der Methode void sleep( long milliseconds ) der Klasse Thread. Wenn die Zeit abgelaufen ist, wird die JVM den Thread wieder in den Zustand ¨Lauffähig¨ setzen. Sperren werden in der Zwischenzeit nicht freigegeben. Endzustand: Ein Thread ist in seinem Endzustand, falls die run() Methode ausgeführt wurde, oder mit einer Ausnahme beendet wurde. 23 / 28 Auswahl durch JVM nach notify start() Intial Lauffähig Auswahl durch JVM Blockiert notify() notifyAll() Aktiv run() , sleep() oder in synchronized() warten auf den Zugriff Ende Ende von run() oder unbehandelte Ausnahme Dieser Automat zeigt die Übergänge zwischen den Zuständen eines Threads Die entscheidende Frage ist, wie das Java Laufzeitsystem einen Thread zum aktiven Thread bestimmt. Prioritäten Threads können unterschiedliche Prioritäten haben. Diese werden nur vom Programmierer festgelegt. Die Java virtuelle Maschine garantiert, daß der Thread mit der höchsten Priorität, immer der aktive Thread ist. D.h. wird ein Thread lauffähig und hat dieser eine höhere Priorität als der derzeit aktive Thread, dann wird der derzeit aktive Thread unterbrochen und in den Zustand lauffähig gesetzt. Der Thread mit der höheren Priorität wird aktiv gesetzt. Damit besteht in Java die Gefahr des Aushungerns (engl. starvation). Ein Thread niedriger Priorität läuft Gefahr niemals aktiv zu werden, wenn immer mindestens ein Thread mit einer höheren Priorität lauffähig ist. Existieren nur lauffähige Threads gleicher Priorität, dann wird nichtdeterministisch ein aktiver Thread gewählt. Auch bei notify() wird nichtdeterministisch ein Thread gewählt, der in den Zustand lauffähig übergeht. Daher können die Bildschirmausgaben in den Beispielen bei jedem Durchlauf variieren. Thread−Wechsel Die virtuelle Maschine muß einen neuen Thread immer nur dann als aktiv auswählen wenn: ein Thread blockiert, oder ein Thread höherer Priorität, als der aktive Thread, lauffähig wird. Folglich: Ein notify() bedingt nicht notwendigerweise einen Threadwechsel. Eine Freigabe von Sperren bedingt nicht notwendigerweise einen Threadwechsel. 24 / 28 Die virtuelle Maschine darf, aber muß niemals, neue Threads aktiv setzen aufgrund von anderen Ereignissen. z.B. Unterbrechungen (engl. interrupts) oder Zeiteinheiten. Dies hängt von der jeweiligen Implementierung der VM ab. Java garantiert insbesondere nicht, daß alle lauffähigen Threads nacheinander für eine feste Zeiteinheit (Zeitscheiben) aktiviert werden. (engl. round−robin−strategy) Ohne eine explizite Angabe, haben alle erzeugten Threads die gleiche Priorität. Viele Unix−Implementierungen wechseln die Threads nur dann, wenn sie müssen. Die Windows−Implementierungen jedoch meistens auch zu anderen nicht genauer definierten Zeitpunkten. Folglich ist es durchaus möglich, daß bei den Beispielen die Threads nur nacheinander aktiv werden. Dies ist an sich nicht schlimm, da semantisch die gleiche Aufgabe erledigt wird. Dadurch können aber Synchronisationsfehler unentdeckt bleiben. Hintergrund−Thread Java kennt neben normalen Benutzer−Threads (engl.user threads) sogenannte Hintergrund− Threads (engl. daemon threads). In Java endet ein Prozeß sobald alle Benutzer−Threads zu Ende sind. Dann werden auch alle Hintergrund−Threads automatisch beendet. Mit den Methoden void setDaemon( boolean on ) und boolean isDaemon() der Klasse Thread kann diese Eigenschaft eines Threads gesetzt und abgefragt werden. Beispielsweise gibt es in einem Java−Prozeß immer ein Hintergrund−Thread, der nicht mehr referenzierte Objekte aufräumt. (engl. garbage collection thread) 4.1 Zeitscheibenstrategie Um Abhilfe gegen die sequentielle Ausführung der Threads zu schaffen und das Verhalten der Testprogrammen besser zu illustrieren, können wir eine Zeitscheibenstrategie leicht implementieren. class RoundRobinScheduler extends Thread { int timeslice; public RoundRobinScheduler( int timeslice ) { this.timeslice = timeslice; setPriority( Thread.MAX_PRIORITY ); setDaemon(true); } public void run() { while( true ) { try { sleep( timeslice ); } catch( Exception e ){} } } } 25 / 28 Jedesmal, wenn dieser Thread lauffähig wird, wird er aktiviert, da er die höchste Priorität hat. Anschließend blockiert er mit sleep() für einige Millisekunden. Ein anderer Thread kommt an die Reihe usw. Eine Zeitscheiben−Strategie alleine garantiert nicht, daß alle Threads gleichmäßig an die Reihe kommen. Nehmen wir unsere Klasse Counter mit zwei Threads gleicher Priorität; folgender Ablauf ist denkbar und um so wahrscheinlicher, je mehr Threads das gleiche Counter Objekt verwenden: Beide Threads arbeiten in einer Schleife und der Haupt−Thread hat den Zugriff auf den Zähler. Die Zeitscheibe ist vorbei, ein Threadwechsel wird erzwungen. Der zweite Thread wird aktiv, wird jedoch gleich blockiert, da der Haupt−Thread den Zugriff hat. Der Haupt−Thread wird wieder aktiv. Der Zugriff wird freigegeben. Der zweite Thread wird lauffähig aber nicht automatisch aktiv. Der Haupt−Thread erhält wieder den Zugriff. Die Zeitscheibe ist vorbei, ein Threadwechsel wird erzwungen. Der zweiter Thread wird jetzt aktiv aber blockiert sofort wieder. usw... Nur wenn eine Unterbrechung stattfindet während der Haupt−Thread den Zugriff freigegeben hat, dann kann der zweite Thread an die Reihe kommen und den Zugriff erhalten. Erst eine Warteschlange bei der Vergabe des Zugriffs würde Abhilfe schaffen. D.h. Die Wartenden reihen sich ein. Nur der Erste in der Schlange kann den Zugriff nach einer Freigabe erhalten. 4.2 Sperre mit Warteschlange Im folgenden implementieren wir eine Sperr, die eine Warteschlange beinhaltet mit den Standard− Java−Synchronisationsmechanismen. Ein Thread soll warten, bis er als erster in der Warteschlange steht. Die Schnittstelle lautet: interface QueuedLock { aquire() release(); } Damit läßt sich synchronized( o ) { ... } 26 / 28 durch o.lock.aquire(); { ... } o.lock.release(); ersetzen. Wobei lock ein öffentliches Attribut von o ist. Das Attribut lock ist dann vom Typ QueuedLock . Achtung: Eine Schachtelung von o.lock.aquire(){...}o.lock.release(); Bereiche, ohne sich selbst aus zu sperren, wie bei synchronized , sollte möglich sein. Mit dem Standard−Mechanismus synchronized muß der Thread immer warten, falls er den Zugriff nicht enthalten kann. Manchmal gäbe es aber die Möglichkeit eine Alternative auszuführen. Erweitern Sie QueuedLock um eine Methode boolean tryAquire(). 5 Anmerkungen Sicherheit Threads werden von der virtuellen Maschine hierarchisch gruppiert. Zugang zu dieser Struktur bietet die Klasse ThreadGroup. Alle Applets werden in einem Prozeß ausgeführt. Jedes Applet hat einen eigenen ThreadGroup. Applets sollten nicht die Threads von anderen Applets beeinflussen können, es sei denn, es wird explizit erlaubt. Daher werden Aufrufe f ür Threads aus einer anderen ThreadGroup als der eigenen geprüft. Der Sicherheitsmanager, d.h. die Instanz der Klasse SecurityManager, wird jedesmal gefragt, ob der Aufruf erlaubt ist. Richtlinien Die Programmierung für mehrere Threads ist nicht trivial. Nachträglich sequentiellen Code für mehrere Threads tauglich (engl. thread safe) zu machen, liefert nur selten befriedigende Ergebnisse. Vor allem die Strategie der Sperrenvergabe ist vorher zu überlegen: Die Sperren−Granularität sollte so grob wie möglich gewählt werden. Dies schränkt zwar die Nebenläufigkeit ein, verringert jedoch die Anzahl der Konfliktsituationen. Solange der Ablauf fair ist entsteht auch ein sinnvolles Ergebnis. Es ist immer abzuwägen, ob nur das Endergebnis zählt, oder ob auch Zwischenergebnisse wichtig sind. Wenn nur das Endergebnis zählt, ist eine geringere Nebenläufigkeit tragbar. Da die meisten virtuellen Maschinen bisher nur einen Prozessor unterstützen, verlangsamt ein häufiger Wechsel der Threads die Berechnung eher. Nur wenn Zwischenergebnisse gebraucht werden, sollte die Granularität verfeinert werden. Die Anzahl der gemeinsamen Variablen sollte so klein wie möglich bleiben. Z.B. bei der Druckaufbereitung in einem eigenem Thread ist es erheblich einfacher den gesamten Text im Speicher zu kopieren und dann zu formatieren, als die Zugriffe auf dem Text zu synchronisieren. 27 / 28 Fehler Ein unbedachter Einsatz von Nebenläufigkeit in Java führt auf eine tückische Fehlerquelle. Fehler tauchen in bereits getesteten Programmen unvermittelt, je nach Javaimplementierung, Rechner, Ablaufgeschwindigkeit, Betriebssystem usw. auf. Die Fehlersuche ist aufwendig. die Fehler sind schwer reproduzierbar. Eine Suche mit einem Debugger kann den Ablauf stark beeinflussen. Dadurch können Fehler "verschwinden". Auch die Ausgabe von Meldungen kann die Synchronisation beeinflussen. Wiederverwendung Synchronisation bedeutet ein erhebliches mehr an Code bzw. Code−Änderungen. Synchronisations−Code legt eine Verwendungsart fest. Z.B. kann in einer Methode gewartet werden, bis eine Bedingung eintritt oder einfach eine Fehlermeldung zurückgeben werden. Beide Verhaltensweisen können je nach Kontext sinnvoll sein. Es kann sinnvoll sein, den Synchronisations−Code und den funktionalen Kern zu trennen. Der Synchronisations−Code wird dann in eine Adapter−Klasse verlagert, die den funktionalen Kern aufruft. Den funktionalen Kern kann man dann auch in einem sequentiellen Kontext einsetzen, wo eine Synchronisation nicht erwünscht ist. Denn Synchronisations−Code beansprucht auch erhebliche Rechenzeit. 6 Literatur [Bro98] M. Broy, Informatik Eine grundlegende Einf ührung Band 2, Springer Verlag, 1998 [Dou97] Doug Lea, Concurrent Programming in Java, Design Principles and Patterns, Addison Wesley, 1997 [LiF97] Tim Lindholm & Frank Yellin, The Java Virtual Maschine Specification, Addision Wesley, 1997 [ScH97] Scott Oaks & Henry Wong, Java Threads, O´Reilly & Associates, 1997 28 / 28