Guido Krüger - JAVA 1.1 lernen - Kapitel 10

Previous Page Previous Page TOC TOC Index Next Page Previous Page



Synchronisation

Synchronisationsprobleme

Wenn man sich mit Nebenläufigkeit beschäftigt, muß man sich in aller Regel auch mit Fragen der Synchronisation nebenläufiger Prozesse beschäftigen. In Java erfolgt die Kommunikation zweier Threads auf der Basis gemeinsamer Variablen, die von beiden Threads erreicht werden können. Führen beide Prozesse Änderungen auf den gemeinsamen Daten durch, so müssen sie synchronisiert werden, denn andernfalls können undefinierte Ergebnisse entstehen.

Beispiel

Wir wollen uns als einleitendes Beispiel ein kleines Programm ansehen, bei dem zwei Threads einen gemeinsamen Zähler hochzählen:

public class Example1007
extends Thread
{
   static int cnt = 0;

   public static void main(String[] args)
   {
      Thread t1 = new Example1007();
      Thread t2 = new Example1007();
      t1.start();
      t2.start();
   }

   public void run()
   {
      while (true) {
         System.out.println(cnt++);
      }
   }
}

Läßt man das Programm eine Weile laufen, könnte es beispielsweise zu folgender Ausgabe kommen:

0
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
33    <-- Nanu? Wo ist die 32?
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
32    <-- Ach so, hier!
58
59

Beide Prozesse greifen unsynchronisiert auf die gemeinsame Klassenvariable cnt zu. Da die Operation System.out.println(cnt++); nicht atomar ist, kommt es zu dem Fall, daß die Operation mitten in der Ausführung unterbrochen wird und der Scheduler mit dem anderen Thread fortfährt. Erst später, wenn der unterbrochene Prozeß wieder Rechenzeit erhält, kann er seinen vor der Unterbrechung errechneten Zählerwert von 32 ausgeben. Sein Pendant war in der Zwischenzeit allerdings bereits bis 56 fortgefahren. Um diese Art von Inkonsistenzen zu beseitigen, bedarf es der Synchronisation der beteiligten Prozesse.

Monitore

Zur Synchronisation nebenläufiger Prozesse hat Java das Konzept des Monitors implementiert. Eine Monitor ist die Kapselung eines kritischen Bereichs (also eines Programmteils, der nur von jeweils einem Prozeß zur Zeit durchlaufen werden darf) mit Hilfe einer automatisch verwalteten Sperre. Diese Sperre wird beim Betreten des Monitors gesetzt und beim Verlassen wieder zurückgenommen. Ist sie beim Eintritt in den Monitor bereits von einem anderen Prozeß gesetzt, so muß der aktuelle Prozeß warten, bis der Konkurrent die Sperre freigegeben und den Monitor verlassen hat.

Das Monitor-Konzept wird mit Hilfe des in die Sprache integrierten Schlüsselworts synchronized realisiert. Durch synchronized kann entweder eine komplette Methode oder ein Block innerhalb einer Methode geschützt werden. Der Eintritt in den so deklarierten Monitor wird durch das Setzen einer Sperre auf einer Objektvariablen erreicht. Bezieht sich synchronized auf eine komplette Methode, wird als Sperre der this-Pointer verwendet, andernfalls ist eine Objektvariable explizit anzugeben.

Anwendung von synchronized auf einen Block von Anweisungen

Beispiel

Wir wollen uns diese Art der Verwendung an einem Beispiel ansehen, welches das oben demonstrierte Synchronisationsproblem löst. Die naheliegende Lösung, die Anweisung System.out.println(cnt++); durch einen synchronized-Block auf der Variablen this zu synchronisieren, funktioniert leider nicht. Da der Zeiger this für jeden der beiden Threads, die ja unterschiedliche Instanzen repräsentieren, neu vergeben wird, wäre für jeden Thread der Eintritt in den Monitor grundsätzlich erlaubt.

Statt dessen verwenden wir die aus Kapitel 7 bekannte Methode getClass, die uns ein Klassenobjekt beschafft (ein und dasselbe für alle Instanzen), mit dem wir die Klassenvariable cnt schützen können:

public class Example1008
extends Thread
{
   static int cnt = 0;

   public static void main(String[] args)
   {
      Thread t1 = new Example1008();
      Thread t2 = new Example1008();
      t1.start();
      t2.start();
   }

   public void run()
   {
      while (true) {
         synchronized (getClass()) {
            System.out.println(cnt++);
         }
      }
   }
}

Nun werden alle Zählerwerte in aufsteigender Reihenfolge ausgegeben.

Anwendung von synchronized auf eine Methode

Ein anderer Fall ist der, bei dem der Zugriff auf ein Objekt selbst synchronisiert werden muß, weil damit zu rechnen ist, daß mehr als ein Thread zur gleichen Zeit das Objekt verwenden will.

Beispiel

Im folgenden werden die potentiellen Probleme am Beispiel eines Zählerobjekts erläutert, dessen Aufgabe es ist, einen internen Zähler zu kapseln, auf Anforderung den aktuellen Zählerstand zu liefern und den internen Zähler zu inkrementieren. Hierbei handelt es sich um eine Aufgabe, die beispielsweise in der Datenbankprogrammierung sehr häufig vorkommt, um Schlüsselnummern zu generieren.

Typischerweise wird das Synchronisationsproblem dadurch verschärft, daß die Verwendung des Zählers einige vergleichsweise langsame Festplattenzugriffe erforderlich macht. In unserem Beispiel wird der Zähler von fünf Threads verwendet. Die Langsamkeit und damit die Wahrscheinlichkeit, daß der Scheduler die Zugriffsoperation unterbricht, wird in unserem Beispiel durch eine Sequenz eingestreuter Fließkommaoperationen erhöht:

class Counter1009
{
   int cnt;

   public Counter1009(int cnt)
   {
      this.cnt = cnt;
   }

   public int nextNumber()
   {
      int ret = cnt;
      //Hier erfolgen ein paar zeitaufwendige Berechnungen, um
      //so zu tun, als sei das Errechnen des Nachfolgezählers
      //eine langwierige Operation, die leicht durch den
      //Scheduler unterbrochen werden kann.
      double x = 1.0, y, z;
      for (int i= 0; i < 1000; ++i) {
         x = Math.sin((x*i%35)*1.13);
         y = Math.log(x+10.0);
         z = Math.sqrt(x+y);
      }
      //Jetzt ist der Wert gefunden
      cnt++;
      return ret;
   }
}

public class Example1009
extends Thread
{
   private String name;
   private Counter1009 counter;

   public Example1009(String name, Counter1009 counter)
   {
      this.name = name;
      this.counter = counter;
   }

   public static void main(String[] args)
   {
      Thread t[] = new Thread[5];
      Counter1009 cnt = new Counter1009(10);

      for (int i = 0; i < 5; ++i) {
         t[i] = new Example1009("Thread-"+i,cnt);
         t[i].start();
      }
   }

   public void run()
   {
      while (true) {
         System.out.println(counter.nextNumber()+" for "+name);
      }
   }
}

Das Ergebnis des Programms ist - wie nicht anders zu erwarten - schlecht, denn es werden sehr viele doppelte Schlüssel produziert. Ein Beispiellauf brachte bereits in den ersten 15 Aufrufen 6 doppelte Zählerwerte:

10 for Thread-2
11 for Thread-4
10 for Thread-0
10 for Thread-1
11 for Thread-2
11 for Thread-3
12 for Thread-4
13 for Thread-0
14 for Thread-1
15 for Thread-2
16 for Thread-3
17 for Thread-4
18 for Thread-0
19 for Thread-1
20 for Thread-2

Auch hier gibt es eine einfache Lösung für das Synchronisationsproblem. Eine einfache Markierung der Methode nextNumber als synchronized macht diese zu einem Monitor und sorgt dafür, daß der komplette Code innerhalb der Methode als atomares Programmfragment behandelt wird. Eine Unterbrechung des kritischen Abschnitts durch einen anderen Thread ist dann nicht mehr möglich:

public synchronized int nextNumber()
{
   int ret = cnt;
   //Hier erfolgen ein paar zeitaufwendige Berechnungen, um
   //so zu tun, als sei das Errechnen des Nachfolgezählerstandes
   //eine langwierige Operation, die leicht durch den
   //Scheduler unterbrochen werden kann.
   double x = 1.0, y, z;
   for (int i= 0; i < 1000; ++i) {
      x = Math.sin((x*i%35)*1.13);
      y = Math.log(x+10.0);
      z = Math.sqrt(x+y);
   }
   //Jetzt ist der Wert gefunden
   cnt++;
   return ret;
}

Durch das synchronized-Attribut wird beim Aufruf der Methode die Instanzvariable this gesperrt und damit der Zugriff für andere Threads unmöglich gemacht. Erst nach Verlassen der Methode und Entsperren von this kann nextNumber wieder von anderen Threads aufgerufen werden.

Hinweis

Diese Art des Zugriffschutzes wird in Java von vielen Klassen verwendet, um ihre Methoden thread-sicher zu machen. Nach Aussage der Sprachspezifikation kann davon ausgegangen werden, daß die gesamte Java-Klassenbibliothek in diesem Sinne thread-sicher ist.

wait und notify

Neben dem Monitorkonzept stehen mit den Methoden wait und notify der Klasse Object noch weitere Synchronisationsprimitve zur Verfügung. Zusätzlich zu der bereits erwähnten Sperre, die einem Objekt zugeordnet ist, besitzt ein Objekt nämlich auch noch eine Warteliste. Dabei handelt es sich um eine (möglicherweise leere) Menge von Threads, die vom Scheduler unterbrochen wurden und auf ein Ereignis warten, um fortgesetzt werden zu können.

Sowohl wait als auch notify dürfen nur aufgerufen werden, wenn das Objekt bereits gesperrt ist, also nur innerhalb eines synchronized-Blocks für dieses Objekt. Ein Aufruf von wait nimmt die bereits gewährten Sperren (temporär) zurück und stellt den Prozeß, der den Aufruf von wait verursachte, in die Warteliste des Objekts. Dadurch wird er unterbrochen und im Scheduler als wartend markiert. Ein Aufruf von notify entfernt einen (beliebigen) Prozeß aus der Warteliste des Objekts, stellt die (temporär) aufgehobenen Sperren wieder her und führt ihn dem normalen Scheduling zu. wait und notify sind damit für elementare Synchronisationsaufgeben geeignet, bei denen es weniger auf die Kommunikation, als auf die Steuerung der zeitlichen Abläufe ankommt.

Beispiel

Das folgende Beispiel demonstriert den Einsatz von wait und notify an einem Producer/Consumer-Beispiel. Ein Prozeß arbeitet dabei als Produzent, der Fließkommazahlen "herstellt", und ein anderer als Konsument, der die produzierten Daten verbraucht. Die Kommunikation zwischen beiden erfolgt über ein gemeinsam verwendetes Vector-Objekt, das die produzierten Elemente zwischenspeichert und als Medium für die wait-/notify-Aufrufe dient:

import java.util.*;

class Producer1010
extends Thread
{
   private Vector v;

   public Producer1010(Vector v)
   {
      this.v = v;
   }

   public void run()
   {
      String s;

      while (true) {
         synchronized (v) {
            s = "Wert "+Math.random();
            v.addElement(s);
            System.out.println("Produzent erzeugte "+s);
            v.notify();
         }
         try {
            Thread.sleep((int)(100*Math.random()));
         } catch (InterruptedException e) {
            //nichts
         }
      }
   }
}

class Consumer1010
extends Thread
{
   private Vector v;

   public Consumer1010(Vector v)
   {
      this.v = v;
   }

   public void run()
   {
      while (true) {
         synchronized (v) {
            if (v.size() < 1) {
               try {
                  v.wait();
               } catch (InterruptedException e) {
                  //nichts
               }
            }
            System.out.print(
              " Konsument fand "+(String)v.elementAt(0)
            );
            v.removeElementAt(0);
            System.out.println(" (verbleiben: "+v.size()+")");
         }
         try {
            Thread.sleep((int)(100*Math.random()));
         } catch (InterruptedException e) {
            //nichts
         }
      }
   }
}

public class Example1010 {
   public static void main(String[] args)
   {
      Vector v = new Vector();

      Producer1010 p = new Producer1010(v);
      Consumer1010 c = new Consumer1010(v);
      p.start();
      c.start();
   }
}

Um die Arbeitsverteilung zwischen den Prozessen etwas interessanter zu gestalten, werden beide gezwungen, nach jedem Schritt eine kleine Pause einzulegen. Da die Wartezeit zufällig ausgewählt wird, kann es durchaus dazu kommen, daß der Produzent eine größere Anzahl an Elementen anhäuft, die der Konsument noch nicht abgeholt hat. Der umgekehrte Fall ist natürlich nicht möglich, da der Konsument warten muß, wenn keine Elemente verfügbar sind. Eine Beispielsitzung könnte etwa so aussehen:

Produzent erzeugte Wert 0.09100924684649958
 Konsument fand Wert 0.09100924684649958 (verbleiben: 0)
Produzent erzeugte Wert 0.5429652807455857
 Konsument fand Wert 0.5429652807455857 (verbleiben: 0)
Produzent erzeugte Wert 0.6548096532111007
 Konsument fand Wert 0.6548096532111007 (verbleiben: 0)
Produzent erzeugte Wert 0.02311095955845288
 Konsument fand Wert 0.02311095955845288 (verbleiben: 0)
Produzent erzeugte Wert 0.6277057416210464
 Konsument fand Wert 0.6277057416210464 (verbleiben: 0)
Produzent erzeugte Wert 0.6965546173953919
Produzent erzeugte Wert 0.6990053250441516
Produzent erzeugte Wert 0.9874467815778902
Produzent erzeugte Wert 0.12110075531692543
Produzent erzeugte Wert 0.5957795111549329
 Konsument fand Wert 0.6965546173953919 (verbleiben: 4)
Produzent erzeugte Wert 0.019655027417308846
 Konsument fand Wert 0.6990053250441516 (verbleiben: 4)
 Konsument fand Wert 0.9874467815778902 (verbleiben: 3)
Produzent erzeugte Wert 0.14247583735074354
 Konsument fand Wert 0.12110075531692543 (verbleiben: 3)

Hinweis

Durch eine konstante Pause nach jedem produzierten Element könnte der Produzent bewußt langsamer gemacht werden. Der schnellere Konsument würde dann einen Großteil seiner Zeit damit verbringen, festzustellen, daß keine Elemente verfügbar sind. Zwar würde das Beispiel (in leicht modifizierter Form) auch ohne den Einsatz von wait/notify funktionieren. Durch ihre Verwendung aber ist der Konsumentenprozeß nicht gezwungen, aktiv zu warten, sondern wird vom Produzenten benachrichtigt, wenn ein neues Element verfügbar ist. Der Rechenzeitbedarf reduziert sich dadurch auf einen Bruchteil dessen, was andernfalls benötigt würde.


Previous Page Previous Page Page Top TOC Index Next Page Previous Page

(C) 1997 Guido Krueger, "Java 1.1 lernen", Addison-Wesley, Bonn, 1997