Threads II – Java und ein wenig Nebenläufigkeit

(Fortsetzung von hier.) Stehengeblieben waren wir bei einer Klasse Toilettenbenutzer (eine Unterklasse von Thread), deren Objekte in einer Schleife immer und immer wieder auf die Toilette gehen. Und weil es Threads sind, können die das alle gleichzeitig und unabhängig von einander. Schwierig wird es nur, wenn alle dieselbe Toilette benutzen sollen, und das möglichst nicht gleichzeitig, sondern nacheinander…

Nebenläufigkeit: Die Notwendigkeit für Synchronisation

Wenn alle nur eine Toilette benutzen sollen, dann brauchen wir erst einmal eine solche Toilette:

public class Toilette {   
    public void benutzen(Toilettenbenutzer t) {
        System.out.println(t.getName()+" betritt Toilette.");
        System.out.println(t.getName()+" benutzt Toilette.");
        System.out.println(t.getName()+" spuelt.");
        System.out.println(t.getName()+" waescht Haende.");
        System.out.println(t.getName()+" verlaesst Toilette.");
        System.out.println();
    }    
}

Die Toilette hat eine Methode benutzen, der als Argument ein Toilettenbenutzer übergeben wird – eigentlich nur deshalb, damit wir seinen Namen erfragen und ausgeben könnnen, wer jetzt im Moment gerade die Toilette benutzt. Wir müssen jetzt nur noch unsere Toilettenbenutzer anpassen, damit die auch wirklich die Toilette benutzen. Dazu kriegen sie ein Referenzattribut vom Typ Toilette, das im Konstruktor gleich belegt wird:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class Toilettenbenutzer extends Thread {
  Toilette toilette;
  int anzahl = 0;
  public Toilettenbenutzer(Toilette t) {
    toilette = t;
  }
  public void run() {
    while (anzahl < 20) {
      toilette.benutzen(this);  //"this" bezieht sich auf das aktuelle Objekt
      anzahl = anzahl + 1;
      try {
        sleep( (int) Math.random()*1000+500 );
      } catch (Exception e) { System.out.println(e); }
    }
  }
}

(Natürlich müssen wir auch im Starter eine Toilette anlegen und den Konstruktoren übergeben.)

Wenn wir jetzt mit unserem Starter-Objekt eine Reihe von Toilettenbenutzer-Threads anlegen und starten, dann wird das ein heilloses Durcheinander:

Thread-2 betritt Toilette.
Thread-2 benutzt Toilette.
Thread-5 betritt Toilette.
Thread-3 betritt Toilette.
Thread-4 betritt Toilette.
Thread-3 benutzt Toilette.
Thread-5 benutzt Toilette.
Thread-5 spuelt.
Thread-5 waescht Haende.
Thread-5 verlaesst Toilette.
Thread-2 spuelt.

Thread-3 spuelt.
Thread-4 benutzt Toilette.
Thread-3 waescht Haende.
Thread-2 waescht Haende.
Thread-3 verlaesst Toilette.

Wir müssen irgendwie sicherstellen, dass nur einer auf einmal die Toilette benutzen kann. Solche Wünsche gibt es viele: Beim Durchführen von Kontotransaktionen, beim Buchen von Reisen, beim Öffnen und Schreiben auf Dokumenten möchten wir häufig, dass nur einer gleichzeitig heran darf. Der gemeinsam genutzte und zu beobachtende Bereich heißt kritischer Bereich. Wenn es solche Bereiche gibt, wenn sich also parallele Prozesse absprechen müssen, spricht man von Nebenläufigkeit.

Lösungsversuch: Semaphore

Ein Semaphor ist ein Zeichen, das etwa vor einer kritischen Eisenbahnstrecke signalisiert, ob sie gerade befahren werden kann oder nicht. Oder eben ein Besetztzeichen bei einer Toilette. Man sollte meinen, dass man sich so etwas einfach selber programmieren kann:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class Toilette {   
    int anzahlBenutzer = 0;
    public void benutzen(Toilettenbenutzer t) {
        while (anzahlBenutzer>0) {
            //t muss halt warten
        }
        anzahlBenutzer = anzahlBenutzer+1;
        System.out.println(t.getName()+" betritt Toilette.");
        System.out.println(t.getName()+" benutzt Toilette.");
        System.out.println(t.getName()+" spuelt.");
        System.out.println(t.getName()+" waescht Haende.");
        System.out.println(t.getName()+" verlaesst Toilette.");
        System.out.println();
        anzahlBenutzer = anzahlBenutzer-1;
    }
}

Neu ist das Attribut anzahlBenutzer, initialisiert mit dem Wert 0. Das ist unser Besetztzeichen: Wenn die Anzahl 0 ist, ist die Toilette frei, wenn die Anzahl 1 ist, ist sie besetzt. (Das könnte man auch mit einem Booleschen Attribut machen, aber so lässt sich das später leichter erweitern, wenn man einen kritischen Bereich hat, der von zwei oder mehre Threads auf einmal betreten werden darf, aber nicht beliebig vielen.)

Wenn ein Thread die benutzen-Methode aufruft, und die Toilette ist besetzt, dann landet er in einer Warteschleife (Zeilen 4–6). Aus der kommt er erst wieder heraus, wenn die Anzahl der Benutzer wieder auf 0 ist. Dann, also ab Zeile 7, wird das Warnsignal um 1 erhöht, das Geschäft verrichtet, und am Ende wird das Warnsignal wieder um 1 vermindert, hat also wieder den Wert 0.

Leider funktioniert das aber nicht. Also, wenn man Glück hat, dann schon. Aber wenn man Pech hat, dann fragt Thread 1 gerade, ob die Toilette frei ist und kriegt ein Ja. Und bevor Thread 1 dann den Semaphor auf besetzt schalten kann, fragt Thread 2 an, ob die Toilette frei ist, und kriegt ebenfalls ein Ja. Schon haben wir den Salat. Dazu kommt noch, dass dann möglicherweise der eine Thread gerade die Anzahl um 1 erhöht und der andere Thread mittenreingrätscht und deshalb eine falsche Anzahl gespeichert wird.

Wenn man zum Beispiel vier der Toilettenbenutzer oben und eine Toilette anlegt und die Toilettenbenutzer startet, dann gibt es folgende Möglichkeiten:

  • alles läuft bestens und niemand kommt sich in die Quere
  • zwei oder mehr Benutzer sind mal gleichzeitig in der Toilette, aber die Anzahl der Toilettenbesucher stimmt (sie ist zwischendurch vielleicht mal auf 2, aber ganz am Ende wieder auf 0)
  • zwei oder mehr Benutzer sind mal gleichzeitig in der Toilette und stören sich beim Erhöhen oder Erniedrigen der Anzahl der Toilettenbesucher. Dann ist die Anzahl zum Beispiel ‑1 (wenn eine der Erhöhungen beim Speichern durch einen anderen Speicherübergang überschrieben wird und deshalb nicht zählt), und das wiederum heißt, dass niemand mehr die Toilette betreten kann und alle weiteren Benutzer für immer in der while-Schleife gefangen sind.

Am besten lässt man zum Ausprobieren jeden Toilettengänger einige hundert Male die Toilette benutzen, und kommentiert die Ausgabezeilen aus, und lässt das sleep() weg, dann geht das schneller. Die benutzen-Methode ist dann so kurz, dass sich zwei Threads leicht in die Quere kommen können:

1
2
3
4
while (anzahlBenutzer>0) { }
  anzahlBenutzer = anzahlBenutzer+1;
  anzahlBenutzer = anzahlBenutzer-1;
}

Wenn die Benutzerzahl 0 am Anfang 0 ist, überspringt Thread 1 die Schleife, dann überspringt Thread 2 die Schleife, dann errechnet Thread 1 den neuen Wert des Semaphoren (nämlich 1), dann errechnet Thread 2 den neuen Wert des Semaphoren (nämlich 2), dann speichert Thread 2 den errechneten Wert (2), dann speichert Thread 1 den errechneten Wert (1), danach reduzieren beide nacheinander den Semaphor, so dass der auf ‑1 steht. Blöd gelaufen.

Das Grundproblem ist nämlich das hier: Bei einem Semaphor muss die Überprüfung seines Zustands (“Ist das Klo frei?”) und die Änderung seines Zustands (“Ich setze das Zeichen auf besetzt.”) gleichzeitig und jedenfalls ununterbrechbar stattfinden. Und das kann man nicht so einfach selber programmieren. Das muss die Programmiersprache dem Programmierer bereits zur Verfügung stellen.

Bei Java geschieht das in Form von Monitoren, die intern mit Semaphoren arbeiten, aber das braucht uns nicht zu interessieren.

Monitore

“Monitor” heißt ursprünglich nicht “Bildschirm”, sondern “Aufseher, Aufpasser” oder in der Schule “Klassenordner”. In Java kann man jedes Objekt zu einem Monitor machen, indem man dem Objekt eine oder mehrere Methoden gibt, denen das Schlüsselwort synchronized vorangestellt ist. Für alle synchronisierten Methoden eines Monitorobjekts gilt: nur eine synchronisierte Methode kann gleichzeitig von irgendwem aufgerufen werden. Wenn eine zweite Methode oder die erste Methode noch einmal aufgerufen wird, wird der aufrufende Thread in einen neuen Zustand versetzt: BLOCKED. Und der bleibt solange in diesem Zustand, bis die zuvor aufgerufene synchronisierte Monitor-Methode beendet wurde und der Monitor dafür sorgt, dass die blockierte Methode wieder RUNNABLE wird. Wir haben also einen neuen Zustand für Threads:

Threads_4

Der Code für unsere Toilette sieht dafür wieder ganz einfach aus, da wir uns nicht selber um Semaphoren kümmern müssen:

public class Toilette {   
    public synchronized void benutzen(Toilettenbenutzer t) {
        System.out.println(t.getName()+" betritt Toilette."+besetzt);
        System.out.println(t.getName()+" benutzt Toilette."+besetzt);
        System.out.println(t.getName()+" spuelt."+besetzt);
        System.out.println(t.getName()+" waescht Haende."+besetzt);
        System.out.println(t.getName()+" verlaesst Toilette."+besetzt);
        System.out.println();
    }
}

Man kann das anzahlBenutzer-Attribut und dessen Änderungen beibehalten, dann lässt sich leicht überprüfen, dass diesmal die Zählerei stets stimmt. Und die Ausgaben sind auch nicht mehr gemischt:

Thread-2 betritt Toilette.
Thread-2 benutzt Toilette.
Thread-2 spuelt.
Thread-2 waescht Haende.
Thread-2 verlaesst Toilette.

Thread-3 betritt Toilette.
Thread-3 benutzt Toilette.
Thread-3 spuelt.
Thread-3 waescht Haende.
Thread-3 verlaesst Toilette.

Thread-2 betritt Toilette.
Thread-2 benutzt Toilette.
Thread-2 spuelt.
Thread-2 waescht Haende.
Thread-2 verlaesst Toilette.

Die Toilettenbenutzer besuchen die Toilette nicht unbedingt abwechselnd (auf die Reihenfolge der Toilettenbenutzer hat man erst einmal keinen Einfluss), aber jedenfalls sicher strikt hintereinander und nie gleichzeitig.

(Fortsetzung folgt. Denn etwas recht Schwieriges und ein weiterer Zustand für Threads kommen noch. Korrekturen bitte gerne als Kommentar.)

Aufgaben:

  • Legen Sie ein Projekt mit den Klassen Starter, Toilette und Toilettenbenutzer an und testen Sie es. Testen Sie es einmal mit einem Monitor (mit synchronisierten Methoden) und einmal ohne das Schlüsselwort synchronized.
  • Legen Sie ein neues Projekt an mit den Klassen Starter, Bankkonto, Einzahler und Abheber an.
    Das Konto erhält ein Attribut int kontostand mit dem Startwert 0 und die (synchronisierten) Methoden einzahlen(int) und auszahlen(int). Diesen Methoden wird jeweils eine positive Ganzzahl übergeben und der Wert des Attributs kontostand entsprechend erhöht oder vermindert.
    Einzahler und Abheber erhalten ein Referenzattribut vom Typ Bankkonto, das wie im Beispiel oben im Konstruktor initialisiert wird. Jeder Einzahler soll zwnazigmal 10 einzahlen, jeder Abheber soll zwanzig 10 abheben.
    Die Starter-Klasse soll für Sie ein Konto, einen Abheber, einen Verbraucher anlegen und starten.
    Am Schluss müsste der Kontostand wieder bei 0 sein. Testen Sie das. Testen Sie das auch mit jeweils zehntausend statt mit zehn Abbuchvorvorgängen.
  • Testen Sie das einige Male ohne das Schlüsselwort synchronized, und schauen Sie, ob der Kontostand am Ende wieder 0 ist. Dazu sollten Sie mit zwanzigtausend statt mit zwanzig Buchungen arbeiten. (Wenn Sie mit sleep() arbeiten, sollten Sie das dazu auskommentieren. Sonst dauert das zu lange.)
  • Testen Sie das alles mit zwei Einzahler- und zwei Abheber-Objekten.
  • Denken Sie sich ein eigenes Beispiel aus, das notwendigerweise mit einem Monitor/synchronisierten Methoden arbeitet, und implementieren Sie es.

Eine Antwort auf „Threads II – Java und ein wenig Nebenläufigkeit“

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert.