Java und die Synchronisation

Aus Byte-Welt Wiki
Wechseln zu: Navigation, Suche

Autor: rom1v von www.javafr.com

Aus dem Französischen übersetzt von André Uhres, mit freundlicher Genehmigung des Autors

Einführung

Die Synchronisation ist ein wesentliches Element, sobald mehrere Threads benutzt werden (das heißt in fast allen Anwendungen). In der Tat, ohne Synchronisation ist es unmöglich, eine robuste Anwendung zu entwickeln, die funktioniert, ungeachtet der Verflechtungen ihrer Threads.


Beschreibung der allgemeinen Probleme

Sehen wir uns zunächst ein klassisches Synchronisationsproblem an. Mehrere Empfänger-Einheiten (Threads) erhalten Kundenaufträge. Sie können die Aufträge in einer Warteschlange ablegen. Die Arbeiter nehmen die abgelegten Arbeiten entgegen, erledigen sie, und liefern ein Ergebnis in einer zweiten Warteschlange. Der Sender holt sich diese Ergebnisse und kann sie dann an den Kunden senden.

Problem.jpg

Mehrere Synchronisationsprobleme stellen sich hier:

  1. Mehrere Empfänger können nicht gleichzeitig eine Arbeit in die Warteschlange der Arbeiten ablegen, andernfalls wären diese Aufträge in demselben Kasten;
  2. Wenn die Warteschlange der Arbeiten voll ist, müssen die Empfänger darauf warten, dass ein Kasten frei wird, um eine neue Arbeit abzulegen;
  3. Mehrere Arbeiter können nicht jeder gleichzeitig eine Arbeit nehmen, andernfalls hätten sie dieselbe Arbeit zu erledigen;
  4. Wenn die Warteschlange der Arbeiten leer ist, müssen die Arbeiter darauf warten, dass eine Arbeit abgelegt wird, um sie ausführen zu können;
  5. Mehrere Arbeiter können nicht gleichzeitig ein Arbeitsergebnis in die Warteschlange der Ergebnisse ablegen, andernfalls wären diese Ergebnisse in demselben Kasten;
  6. Wenn die Warteschlange der Ergebnisse voll ist, müssen die Arbeiter darauf warten, dass ein Kasten frei wird, um ein neues Ergebnis abzulegen;
  7. Wenn die Warteschlange der Ergebnisse leer ist, muss der Sender darauf warten, dass ein Ergebnis verfügbar wird.

Zielsetzungen

Das Ziel besteht darin, diese allgemeinen Probleme zu lösen, welche die meisten Fälle abdecken. Um sie richtig zu lösen, muss man folgendes sicherstellen:

  • Die Sicherheit: nichts Verkehrtes ereignet sich, ungeachtet der Verflechtung der Threads (zwei Arbeiter können nie dieselbe Arbeit nehmen);
  • Die Schnelligkeit: es wird schließlich etwas Richtiges geschehen (wenn die Warteschlange der Arbeiten nicht leer ist, wird ein Arbeiter schließlich die Arbeiten erledigen).

Man muss auch die möglichen Fälle berücksichtigen; zum Beispiel können zwei nacheinander abgelegte Arbeiten folgerichtig vom selben Arbeiter ausgeführt werden.


Gegenseitiger Ausschluss von kritischen Abschnitten

Der gegenseitige Ausschluss erlaubt, die Synchronisationsprobleme 1, 3 und 5 aus der Beschreibung der allgemeinen Probleme zu lösen. Hier ein Programmbeispiel, das die Sicherheit nicht respektiert, wenn mehrere Threads dort gleichzeitig Zugang haben :

class  StringList  {
 
     private  String[] list =  new  String[ 50 ];
     private  int  index =  0 ;
 
     void  add ( String s ) {
         list [ index ]  = s;
         index++;
     }
 
}


Dieses Programm, wie du es geraten haben wirst, erlaubt es, eine Liste von Strings zu verwalten, indem es ein Array benutzt.

void  add ( String s ) {         void  add ( String s ) {
     list[index] = s;  //(a1)       list[index] = s;  //(b1)
     index++;        //(a2)         index++;        //(b2)
}


Stellen wir uns jetzt zwei Threads vor, T1 und T2, die parallel (oder pseudoparallel auf einem Monoprozessor) auf derselben StringList die Funktion „add(String)“ ausführen. Ihre Aktionen können so verflochten werden, dass mehrere Ausführungen möglich sind. Zum Beispiel:

  • (a1) (a2) (b1) (b2) ist eine mögliche, zusammenhängende Ausführung.
  • (b1) (b2) (a1) (a2) ist eine mögliche, zusammenhängende Ausführung.
  • (a1) (b1) (b2) (a2) ist eine mögliche, aber zusammenhanglose Ausführung: die Tabelle enthält die durch T1 hinzugefügte Zeichenfolge nicht, und ein Kasten der Liste ist leer.

Mehrere andere Ausführungen können zu anderen Ergebnissen führen, von denen einige zusammenhanglos sind. Dieses rückläufige Problem kann durch den gegenseitigen Ausschluss gelöst werden. Die Funktion add(String) wird kritischer Abschnitt genannt. Mehrere abhängige kritische Abschnitte dürfen ihren Code nie gleichzeitig ausführen (durch mehrere verschiedene Threads): man sagt, dass sie in gegenseitigem Ausschluss sind. Im vorhergehenden Beispiel ist add(String) in gegenseitigem Ausschluss mit sich selbst, aber man kann sich sicher gut vorstellen, dass sie auch in gegenseitigem Ausschluss mit der Funktion „remove“ ist...

Um den gegenseitigen Ausschluss zu schaffen, muss man Locks (Riegel) benutzen. Wenn ein Thread auf einen kritischen Abschnitt zukommt, verlangt er den Lock. Wenn er ihn erhält, kann er den Code ausführen. Wenn er ihn nicht erhält, weil ein anderer Thread ihn bereits genommen hat, dann wird er blockiert, indem er darauf wartet, ihn zu erhalten. Es ist möglich, eine potentiell unendliche Anzahl von Locks zu benutzen, und demnach präzise gegenseitige Ausschlüsse zu verwirklichen: zum Beispiel muss a() in gegenseitigem Ausschluss mit sich selbst und mit b() sein, während c() in gegenseitigem Ausschluss mit sich selbst und mit d()sein muss, aber nicht mit a() und b()...

Ein kritischer Abschnitt wird von einem anderen kritischen Abschnitt, der denselben Lock benutzt, als eine atomare Operation angesehen (eine einzige unteilbare Operation).


Das Schlüsselwort synchronized

Um dies in Java zu verwirklichen, besteht die einfachste Methode darin, das Schlüsselwort synchronized zu benutzen. Hier sieht man, wie es benutzt wird:

synchronized ( einObjekt ) {
     //kritischer Abschnitt
}


„einObjekt“ stellt einen Lock (Riegel) dar, welcher irgendein Java Objekt sein kann. Aber Vorsicht, es ist besser, Referenzen zu benutzen, die als „final“ deklariert wurden, um sicher zu sein, dass die Referenz auf das Objekt nicht geändert wird; in der Tat, der nachfolgende Code funktioniert nicht:

synchronized (meineListe) {
     meineListe =  new  ArrayList<String>() ;
}


Wenn mehrere Threads im synchronisierten Block ankommen, referenziert die Variable "meineListe" nicht immer dasselbe Objekt und stellt also nicht immer denselben Lock dar.

Ein besonderer Fall: Wenn das Objekt, das als Lock für die Synchronisation dient, this ist und den ganzen Code einer Methode einschließt, kann man das Schlüsselwort synchronized auch in den Methodenkopf setzen. Die zwei nachstehenden Codes sind somit absolut gleichwertig:

void  methode () {
     synchronized ( this ) {         
          //kritischer Abschnitt
     }   
}
synchronized  void  methode () {
     //kritischer Abschnitt
}

Das Paket java.util.concurrent.locks

Eine andere Methode, um Locks zu verwirklichen, ist erstmals in Java 1.5. erschienen. Sie kann auf den ersten Blick schwieriger scheinen, aber sie ist machtvoller, da sie erlaubt, Sachen zu machen, die das Schlüsselwort synchronized nicht erlaubt (wir werden später darauf zurückkommen). Hier siehst du, wie sie zu benutzen ist:

    Lock l;
...
        l = new ReentrantLock();
...
        l.lock();
        try {
            //kritischer Abschnitt
        } finally {
            l.unlock();
        }


Um diese Methode mit der vorhergehenden zu vergleichen: l.lock () und l.unlock () entsprechen dem Beginn bzw. Schluss des synchronized Blocks.

Jedoch, wenn du einen dermaßen einfachen gegenseitigen Ausschluss machen willst, wie jene der hier vorgestellten Beispiele, rate ich dir synchronized zu benutzen und die Methode des Pakets java.util.concurrent.locks für recht besondere Fälle zu reservieren, die wir später sehen werden.

Gemeinsame Synchronisation

Die gemeinsame Synchronisation erlaubt es uns, die Synchronisationsprobleme 2, 4, 6 und 7 aus obiger Beschreibung der allgemeinen Probleme zu lösen.


Methoden der Klasse Object

Benutzung

Die grundlegendste Art der Synchronisation zwischen mehreren Java Threads ist die Benutzung der Methoden wait(), notify() und eventuell notifyAll(), die in der Object-Klasse definiert sind. Um ihre Funktionsweise zu begreifen, vervollständigen wir unser Beispiel von soeben:

class  StringList  {
 
     private  String []  list =  new  String [ 50 ] ;
     private  int  index =  0 ;
 
     synchronized void  add(String s) {
         list [ index ]  = s;
         index++;
         notify();//<-------------------------------------------------------
         System.out.println("notify() ausgeführt");
     }
 
     synchronized  String getFirstBlockingElement () {
         //while the list is empty
         while ( index ==  0 ) {
             try  {
                 //wait passively
                 wait();//<-------------------------------------------------------
             }  catch ( InterruptedException ie ) {
                 ie.printStackTrace () ;
             }
         }
         return  list [ 0 ] ;
     }
 
}


Die Methode getFirstBlockingElement gibt das erste Element der Liste zurück; wenn die Liste leer ist, wartet sie darauf, dass ein Element hinzugefügt wird. Du bist vielleicht überrascht wegen der while Schleife der Methode getFirstBlockingElement, anstelle eines einfachen if. Wir werden im einzelnen sehen, was sich dort abspielt.

Nehmen wir an, dass der Thread T1 add(..) ausführt, und dass der Thread T2 getFirstBlockingElement ausführt.

Nehmen wir weiter an, dass T2 zuerst die Hand hat, er nimmt den Lock (die Methode wurde als synchronized deklariert), er findet index == 0 wahr, also führt er wait() aus. Dieses wait() ist blockierend so lange wie es nicht durch ein notify() auf demselben Objekt freigegeben wird.

Bemerkung: Wenn mehrere Threads einObjekt.wait() ausführen, dann wird jedes einObjekt.notify() einen blockierten Thread freigeben, in einer unbestimmten Reihenfolge.

Jetzt nimmt T1 die Hand. Er führt add(..) aus, und verlangt also den Lock. Aber du wirst mir sagen, er wird ihn nicht erhalten, denn es ist T2, der den Lock hat! Und dennoch wird er ihn erhalten, denn T2 hat den Lock in dem Moment losgelassen, wo er die wait() Methode aufgerufen hat!

Bemerkung: Der Aufruf der Methode wait() gibt den Lock nur frei, weil das Objekt, auf dem wait() angewendet worden ist, dasselbe ist wie der Lock (hier this).

T1 kann also den Code der Methode add(..) ausführen. Wenn er notify() ausführt, gibt er T2 frei (asynchron). Aber jetzt hat T2 natürlich noch nicht den Lock, da T1 ihn noch braucht um System.out.println(...) auszuführen. T2 ist also blockiert mit dem Warten auf den Lock, und andere Threads (stellen wir uns vor T3, T4...), können ihn vor ihm erhalten. Nehmen wir an, dass T3 alle Elemente der Liste entfernt (durch eine mögliche clear() Methode), und dass danach T2 die Hand nimmt. T2 kommt dann aus dem wait() heraus. Er muss dann also noch einmal die Bedingung index == 0 prüfen, andernfalls würde er versuchen, das Element am Index 0 des Arrays wiederzugewinnen, das leer ist. Das ist der Grund, weswegen man die while Schleife benutzen muss!

Bemerkung: Man darf vor allem diese while Schleife nicht mit einem aktiven Warten verwechseln, das ständig prüft, ob der Index 0 ist. Hier schauen wir ob der Index 0 ist, wenn es der Fall ist, setzen wir den Thread in einen passiven Wartezustand, der nur geweckt wird, wenn ein Element hinzugefügt wird. Wenn er einmal geweckt ist, muss er aber die Bedingung neu prüfen (aus dem Grund, den wir soeben gesehen haben).

Diese Art vorzugehen ist für einfache Synchronisierungsprobleme praktisch; für komplexere Probleme ist es besser, andere Methoden zu benutzen. Im vorliegenden Fall ist die blockierende Liste, die Gegenstand unseres Beispiels war, gänzlich in der Java 1.5 API codiert, wir werden noch darauf zurückkommen.

Grenzen

Abgesehen von den Einrichtungsschwierigkeiten für komplexe Probleme erreicht diese Lösung ihre Grenzen, sobald ein kritischer Abschnitt mit mehreren Locks synchronisiert werden muss.

class  EineKlasse  {
 
     private final  Object lock1 =  new  Object () ;
     private final  Object lock2 =  new  Object () ;
 
     void  methode () {
          synchronized ( lock1 ) {
             synchronized ( lock2 ) {
                 while ( bedingung ) {
                    lock2.wait () ;
                 }
             }
         }
     }
 
     void  benachrichtigen() {
         lock2.notify () ;
     }
 
}


In der Tat, um das wait() anzuwenden sind wir gezwungen, einen der zwei Locks zu wählen, und demnach wird lediglich ein einziger der beiden Locks während des Wartens freigegeben. Im vorliegenden Fall verriegelt der Aufruf methode() gänzlich den lock1, und zwar so lange wie lock2.wait() nicht zu Ende gekommen ist.

Das Paket java.util.concurrent.locks

Die Lösung, die wir oben bereits gesehen haben, um den gegenseitigen Ausschluss unter Benutzung dieses Pakets zu verwirklichen, erlaubt es uns auch, über die Grenzen der Methoden der Object-Klasse hinauszugehen. Dabei besteht die Grundidee darin, nur einen Lock zu benutzen, aber mit mehreren Bedingungsvariablen, auf denen wir die Operationen durchführen können, die ähnlich sind wie wait() und notify().

class  EineKlasse  {
 
     private final  Lock lock =  new  ReentrantLock () ;
     private final  Condition cond1 = lock.newCondition () ;
     private final  Condition cond2 = lock.newCondition () ;
 
     void  methode1 ()  throws  InterruptedException  {
         lock.lock () ;
         try  {
             cond2.await () ;
             cond1.signal () ;
         }  finally  {
             lock.unlock () ;
         }
     }
 
     void  methode2 ()  throws  InterruptedException  {
         lock.lock () ;
         try  {
             cond1.await () ;
             cond2.signal () ;
         }  finally  {
             lock.unlock () ;
         }
     }
 
}

In methode1() zum Beispiel gibt der Aufruf von cond2.await() den lock frei. Eine ReadWriteLock-Schnittstelle ist in diesem Paket auch verfügbar, die erlaubt, die Lesen-Schreiben Probleme zu lösen (jederzeit entweder irgendeine Anzahl von Lesern, oder einen einzigen Schreiber). Ich lasse dich die Dokumentation für seine Benutzung konsultieren. Aber es genügt vorläufig zu wissen, dass das besteht.

Threads

Bis jetzt bezeichnete das Wort „Thread“ einen parallelen Vorgang. In diesem Abschnitt wird das Wort „Thread“ die Thread-Klasse von Java bedeuten. Die Beziehung, die zwischen den zwei besteht, ist, dass der Aufruf der Methode start () der Thread-Klasse einen neuen Thread schafft, in dem der Code ausgeführt wird.

Dies ist kein Artikel über die Thread-Klasse, sondern hier werden lediglich einige Methoden angeführt, die man kennen sollte:

static Thread currentThread (): Erlaubt, den laufenden Thread zu erhalten.

static void yield (): Gibt den anderen Threads eine Chance, sich auszuführen.

static void sleep(long ms) throws InterruptedException: legt den aufrufenden Thread während der angegebenen Zeit schlafen (dies bitte nicht zum Zweck der Synchronisation benutzen!).

void interrupt (): verursacht entweder das Werfen der InterruptedException wenn die Aktivität auf einer Synchronisationsoperation blockiert, oder das Setzen eines interrupted Indikators.

void join (): Blockierendes Warten auf den Abschluss eines Threads, also bis die mit dem Thread verbundene run() Methode beendet ist.

Semaphoren

Es ist wohl unmöglich ein Tutorial über die Synchronisation zu schreiben, ohne über die Semaphoren zu sprechen. In einer theoretischeren Beschreibung der Synchronisationsprobleme würden die Semaphoren vor den zwei vorhergehenden Lösungen beschrieben werden; du wirst jedoch die Semaphoren in Java wohl kaum benutzen müssen. Aber ich glaube, dass das Verständnis ihrer Funktionsweise wichtig ist.

Die Semaphore-Klasse ist in Java 1.5. erschienen.

Definition

Ein Semaphor verkapselt einen strikt positiven Integer und zwei atomare (also unteilbare) Hoch- und Runterzähloperationen.

  • Integer (stets positiv oder null).
  • Operation acquire(): zählt den Zähler runter, wenn er strikt positiv ist; blockiert, wenn er gleich null ist, indem sie darauf wartet, runterzählen zu können.
  • Operation release(): zählt den Zähler hoch.

Man kann ein Semaphor als eine Sammlung von Münzmarken mit zwei Operationen sehen:

  • Eine Münzmarke nehmen, indem man wenn nötig darauf wartet, dass welche vorhanden sind;
  • Eine Münzmarke ablegen.

Bemerkung: Die abgelegten Münzmarken sind nicht notgedrungen jene, die genommen worden sind.

Benutzung

Hier ein sehr besonderes Beispiel eines Semaphors mit nur einer Münzmarke:

Semaphore sem =  new  Semaphore ( 1 ) ;
try  {
     sem.acquire () ;
     //kritischer Abschnitt
     sem.release () ;
}  catch ( InterruptedException e ) {
     e.printStackTrace () ;
}

Ein Semaphor mit nur einer Münzmarke ist einem Lock sehr ähnlich. Jedoch darf man die beiden Begriffe nicht miteinander verwechseln! In der Tat, wenn man mehrere Mal die Operation release() durchführt, behält ein Semaphor diese Anträge im Gedächtnis indem er den Integer hochzählt, den er benutzt; für einen Lock ist die Durchführung von mehreren Entriegelungen absolut gleichbedeutend mit der Durchführung von nur einer einzigen Entriegelung. So könnte man mehrere aufeinanderfolgende release() Operationen auf einem Semaphor als „verzögerte“ notify() Operationen auf einem Lock ansehen.


In der API 1.5 eingerichtete Lösungen

Version 1.5 von Java und sein Paket java.util.concurrent (sowie seine Unterpakete) liefern Synchronisationswerkzeuge von höherem Niveau.

Zum Beispiel wird das Beispiel, das wir benutzt haben, um eine blockierende Liste zu simulieren, durch BlockingQueue (nicht begrenzte blockierende Liste) und ArrayBlockingQueue (blockierende Liste mit begrenztem Puffer) verwirklicht.

Ein anderes sehr nützliches Werkzeug ist Executor. Es handelt sich um eine blockierende Warteliste von durchzuführenden Aktionen. Man findet genau denselben Mechanismus wieder, wenn man den Thread benutzt, welcher der graphischen Anzeige von Swing gewidmet ist (Event Dispatch Thread): SwingUtilities.invokeLater(Runnable) stellt eine Aktion in die Warteschlange, die in dem Thread durchzuführen ist, welcher der graphischen Anzeige gewidmet ist.

Hier ist ein Beispiel der Benutzung von Executor:

Executor executor = Executors.newSingleThreadExecutor () ;
executor.execute ( new  Runnable () {
     public  void  run () {
         System.out.println ( "asynchroner Aufruf 1" ) ;
     }
}) ;
executor.execute ( new  Runnable () {
     public  void  run () {
         System.out.println ( "asynchroner Aufruf 2" ) ;
     }
}) ;


Dieses Beispiel führt Runnables asynchron gegenüber dem laufenden Thread aus, aber gewährleistet, dass sie in der Aufrufreihenfolge nacheinander durchgeführt werden.

Andere Werkzeuge sind ebenfalls verfügbar, konsultiere dazu bitte die Dokumentation.

Gegenseitiges Blockieren

Das bekannteste Synchronisationsproblem bei der Entwicklung ist das gegenseitige Blockieren. Ein gegenseitiges Blockieren (oder Deadlock), ist ein Blockieren, das erfolgt, wenn zum Beispiel Thread A auf Thread B wartet, während gleichzeitig B auf A wartet.

Hier ein sehr einfaches Beispiel des gegenseitigen Blockierens:

class  DeadLock  {
 
     private final  Object lock1 =  new  Object () ;
     private final  Object lock2 =  new  Object () ;
 
     void  a ()  throws  InterruptedException  {
         lock1.wait () ;
         lock2.notify () ;
     }
 
     void  b ()  throws  InterruptedException  {
         lock2.wait () ;
         lock1.notify () ;
     }
 
}


Wenn Thread T1 a() ausführt, und Thread T2 b() ausführt, gibt es gegenseitiges Blockieren. In der Tat erwartet T1 das notify() von T2, aber, damit T2 das notify() aufrufen kann, ist es zuerst notwendig, dass T1 sein notify() ausführt...

Man muss unbedingt darauf achten, nie ein gegenseitiges Blockieren zu verursachen.


Unnötige Synchronisation vermeiden

Die Synchronisation ist in vielen Situationen etwas Wesentliches. Jedoch ist sie teuer an Prozessormitteln: man sollte daher nicht unbedingt alles synchronisieren. Wenn eine Methode nur von einem einzigen Thread aufgerufen werden kann, synchronisiere sie nicht. Die Methoden von Swing Listeners werden notgedrungen im EDT aufgerufen, es ist also unnötig, irgendwas zu synchronisieren (außer, wenn du von dort aus neue Threads anlegst).

Wenn du viel Synchronisation hast, versuch zu schauen, ob das Modell einer einzigen Warteschlange sich nicht besser eignet (Executor), das wird viel von deinem Code vereinfachen...

Vermeide die alten Collections, die von Java 1.0 kommen (Vector, Hashtable...) und von Haus aus synchronisiert sind. Benutze dagegen die neuen, die ab Java 1.2 gekommen sind (ArrayList, HashMap...) und nicht synchronisiert werden (bessere Leistungen). Um eine synchronisierte Sicht einer Collection wiederzugewinnen reicht es aus, Collections.synchronizedCollection (Collection) aufzurufen.