Threads III – Erzeuger, Verbraucher und ein neuer Zustand

(Fortsetzung von hier.) Im bisherigen Beispiel ging es um Toilettenbenutzer, die auf eine leere Toilette warten, sie dann auf jeden Fall benutzen können und auf jeden Fall wieder herausgehen, so dass die Toilette dann für den nächsten Besuch wieder frei ist. Wir hatten quasi Erzeuger, die den kritischen Bereich betreten, wo auf jeden Fall Platz zum, äh, Erzeugen ist, und dann gehen sie wieder. Das erzeugte Produkt bleibt nicht etwa in der Toilette, sondern verschwindet im Orkus.
Dafür reichen unsere bisherigen synchronisierten Methoden.

Erzeuger und Verbraucher

Wir erweiterten jetzt unsere Aufgabe zu etwas, das allgemein Erzeuger-Verbraucher-Problem heißt. Dazu stellen wir uns einen oder mehrere Erzeuger vor, die ein Produkt erstellen und das an einem Speicherort abstellen wollen, das aber nur können, wenn dort Platz ist. Dazu kommen noch ein oder mehrere Verbraucher, die regelmäßig diesen gemeinsamen Speicherort aufsuchen, um von dort ein Produkt abzuholen – das geht natürlich nur, wenn dort auch ein Produkt für sie liegt.

Damit kommt ein neuer Faktor hinzu. Nicht nur gibt es einen kritischen Bereich, der nur von einem (sagen wir mal) auf einmal betreten werden darf. Außerdem gibt es in diesem Bereich noch eine weitere Bedingung, die erfüllt sein muss: der Lieferant muss dort Platz für sein Produkt haben, und der Abholer muss dort eine Kiste zum Abholen vorfinden.

threads_erz-verb

Lösung 1 (keine gute Lösung)

In einer Schleife produziert der Erzeuger immer wieder ein Produkt (im Beispiel eine Kiste) und ruft danach die synchronisierte Methode hineinlegen(Kiste) des Speicher-Objekts (der ein Monitor ist) auf. Damit hat er ein Exklusivrecht auf die vom Monitor überwachten Methoden. Wenn dort kein Platz zum Ablegen der Kiste ist, dann hilft einfaches Warten (in einer Schleife etwa) auch nicht: Der Verbraucher kann ja nicht hinein, um die Kiste dort herauszuholen, solange der Erzeuger die Monitormethode benutzt.

Ebenso der Verbraucher: Wenn der die Methode herausholen() aufruft, die als Rückgabetyp eine Kiste hat, was soll dann passieren, wenn gar keine Kiste im Speicher ist? Einfaches Warten auf eine Kiste funktioniert ebenso wenig wie oben.

Eine Lösung ist: Nu, wenn keine Kiste da ist, dann gibt die Methode herausholen einfach null zurück statt einer Kiste. Das heißt aber, dass der Verbraucher komplizierter wird: Er kann nicht sichergehen, dass er nach dem Holen eine Kiste hat und muss, wenn er stattdessen null hat, diesen einen Kistenholversuch solange wiederholen, bis er doch eine Kiste hat. Dann kann er regulär weitermachen.
Ebenso der Erzeuger: Wenn der eine Kiste hineinlegen will, gibt es üblicherweise keinen Rückgabetyp (void). Man müsste stattdessen eine Rückgabe machen, boolean oder die Kiste selber, anhand derer der Erzeuger unterscheiden kann, ob der Hineinlegen-Versuch erfolgreich war oder nicht. Wenn nicht, wird der Versuch mit der ursprünglichen Kiste solange wiederholt, bis der Erzeuger sie doch los wird. Dann kann er regulär weitermachen.

Diese Lösung funktioniert. Aber zum einen ist der Code dann etwas umständlich, zum anderen führt das dazu, dass der Erzeuger möglicherweise immer und immer wieder versucht eine Kiste abzuliefern, auch wenn der Verbraucher noch so langsam arbeitet. (Oder umgekehrt.) Jeder Thread läuft ständig in einer Schleife, auch wenn gar nichts passiert. Das kostet Rechenzeit.

Deshalb macht man das anders.

Lösung 2: Ein neuer Zustand

Wenn ein Erzeuger-Thread die synchronisierte hineinlegen-Methode aufruft, dort aber keinen Platz findet, dann wird er ruhen geschickt. Damit gibt er, wichtig, seinen exklusiven Anspruch auf den Monitor vorläufig auf.

Wenn dann mal endlich ein Verbraucher die Kiste abholt und Platz im Speicher-Bereich schafft, muss der Speicher/Monitor danach dem Betriebssystem sagen, dass dies alle von ihm ruhen geschickten Threads aufwecken soll. Auch der ruhende Erzeuger wird so geweckt und versucht dann a) wieder exklusiven Anspruch auf den Monitor zu kriegen (falls das nicht klappt, ist er BLOCKED, siehe vorherigen Eintrag) und im Erfolgsfall b) macht er an genau der Stelle weiter, an der er ruhen geschickt wurde.

Dieser neue Zustand heißt WAITING:

Erzeuger und Verbraucher sehen im einfachsten Fall so aus:

public class Erzeuger extends Thread  {
  // Attribute
  Speicher speicher;
  int produktionsnummer = 0;
 
  //Konstruktor
  public Erzeuger(Speicher speicher) {
    this.speicher = speicher;
  }
 
  //Methoden
  public void run() {
    while (produktionsnummer<20) {
      Kiste k = new Kiste(produktionsnummer);
      produktionsnummer++;
      speicher.hineinlegen(k);
    }
  }
}
public class Verbraucher extends Thread{
    // Attribute
    Speicher speicher;
 
    //Konstruktor
    public Verbraucher(Speicher speicher) {
        this.speicher = speicher;
    }
 
    //Methoden
    public void run() {
        while (true) {
            Kiste k = speicher.herausholen();
        }
    }
}

Klar wird man noch ein sleep() einbauen, damit das nicht gar so schnell geht, und da und dort ein System.out.println(), damit man auch etwas mitkriegt davon, ob gerade eine Kiste produziert oder dem Speicher entnommen wurde, aber das ist eigentlich nur Dekoration.

Schwieriger finde ich die Programmierung des Monitors:

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
32
33
34
35
36
37
38
public class Speicher {
    // Attribute
    boolean istLeer = true;
    Kiste eingelagerteKiste = null;
 
    //Konstruktor
    public Speicher() {
    }
 
    //Methoden
    synchronized void hineinlegen(Kiste neueKiste) {        
        while (!istLeer) { 
            //exklusiver Zugang gelungen, aber fruchtlos
            try {
                wait(); //geh ruhen
            }
            catch (Exception e) { System.out.println(e); }
        }
        //exklusiver Zugang gelungen, und sinnvoll
        istLeer = false;
        eingelagerteKiste = neueKiste;
        notifyAll(); //Wecken der ruhenden Threads
    }
 
    synchronized Kiste herausholen() {
        while (istLeer) {
            //exklusiver Zugang gelungen, aber fruchtlos
            try {
                wait(); //geh ruhen
            }
            catch (Exception e) { System.out.println(e); }
        }
        //exklusiver Zugang gelungen und sinnvoll
        istLeer = true;
        notifyAll(); //Wecken der ruhenden Threads
        return eingelagerteKiste;
    }
}

Mit dem wait() in Zeile 15 und 29 wird der Thread, der diesen Befehl auslöst, in unseren neuen WAITING-Zustand versetzt. Das muss aus Java-Gründen in einem try-catch-Block geschehen. Das notifyAll() in Zeile 22 und 35 sagt dem Betriebssystem, dass wartende Threads jetzt geweckt werden sollen, auf dass sie erneut ihr Glück versuchen.

Erklären muss man jetzt nur die Schleifen um das wait() herum, siehe Zeilen 12 und 26. Warum reicht da nicht ein einfaches if?

Für diesen Fall:
Ein Erzeuger will die exklusive hineinlegen-Methode aufrufen. Der Monitor ist gerade frei, der Erzeuger also nicht BLOCKED, und trifft auf die Schleife, deren Bedingung wahr ist (der Speicher ist voll), so dass der Erzeuger in Winterschlaf versetzt wird (WAITING).
Ein Verbraucher holt dann die Kiste heraus und alle schlafenden Threads werden geweckt. Auch unser erster Thread wird geweckt (ist RUNNABLE), doch ebenso ein zweiter Erzeuger. Der zweite kommt zuerst dran (ist RUNNING), kriegt exklusiven Zugang zum Monitor, legt eine Kiste ein, und verlässt den kritischen Bereich. Jetzt erst kommt unser erster Thread an den Zug (RUNNING), er kriegt seinen exklusiven Zugang zum Monitor – dummerweise ist aber schon wieder kein Platz mehr für die Kiste! Also muss er wieder ruhen und auf das nächste Wecken warten.

Will heißen: Zum Zeitpunkt des Weckens ist die für den Erzeuger kritische Bedingung (dass Platz in der Ablage ist) zwar erfüllt, aber schon kurz danach, wenn der Erzeuger zum Zug kommt, kann sie schon wieder nicht mehr erfüllt sein.


Aufgaben:

  • Legen Sie ein Projekt an mit den Klassen Speicher, Erzeuger, Verbraucher und Starter und testen Sie es.
  • Testen Sie das mit mehreren Erzeugern und Verbrauchern.
  • Ändern Siecdie Klasse Speichervso, dass dort Platz für drei Kisten ist. Sie brauchen dazu ein Feld der Länge 3 und ein Attribut, das angibt, ob Platz für eine neue Kiste ist, und ein zweites, das angibt, ob eine Kiste zum Abholen da ist.

 


Bonus für die, die dabeigeblieben sind

Man kann die Zustände der laufenden Threads schön sichtbar machen. Die Klasse Thread (und damit ihre Unterklassen ebenso) hat in Java eine Methode getState(), die als Rückgabe eine Enum Thread.State hat, nämlich einen der folgenden Werte: NEW, RUNNABLE, TERMINATED, TIMED_WAITING, BLOCKED, WAITING. Nicht dabei ist RUNNING.
Ich habe mir eine Klasse ThreadObserver geschrieben, das sind einige ihre wichtigsten Methoden:

ThreadObserver_kurz

Man kann mit addToWatchlist bestehende Threads zu einer Liste beobachteter Threads hinzufügen. Falls diese Threads noch nicht laufen, kann man sie mit startAllThreads laufen lassen. Threads, die nicht im Zustand NEW sind, werden dabei ignoriert, die laufen oder liefen ja schon. Mit stopEverything werden sowohl alle Threads als auch der Beobachter selber abgebrochen. Wenn man das vergisst, kann es leicht passieren, dass der eine oder andere Thread unbemerkt weiterläuft. Und mit startWriting schaltet man das Protokollieren ein.

Das Protokollieren: Der ThreadObserver fragt regelmäßig alle beobachteten Threads nach ihrem Zustand. Wenn sich der ändert (und um das feststellen zu können, muss der vorhergehende Zustand gespeichert worden sein), dann wird diese Information zur Zeit an ein selbstgeschriebenes ThreadStateWindow und an einen ThreadStatePrinter übergeben; das erste gibt stellt die Änderung in einem Fenster dar, der zweite druckt sie auf die Konsole aus.

Natürlich ist dabei nicht gewährleistet, dass wirklich jede Änderung sofort bemerkt wird. Der ThreadObserver nutzt schließlich selber einen Thread, um die anderen zu beoachten, und wer weiß, wie oft und wann der drankommt. Aber im Prinzip funktioniert das gut.

Wenn man also seine Starterklasse von ThreadObserver erben lässt:

public class Starter extends ThreadObserver {
    // Attribute
    Speicher s;
    Erzeuger e0;
    Verbraucher v0;
 
    //Konstruktor
    public Starter() {
        s = new Speicher();
        e0 = new Erzeuger(s);
        v0 = new Verbraucher(s);
    }
 
    //Methoden
    public void starten() {
        e0.setName("erzeuger0");
        v0.setName("verbraucher0");        
 
        addToWatchlist(e0);
        addToWatchlist(v0);
        startWriting();
 
        e0.start();
        v0.start();        
    }
}

- dann kriegt man die aktuellen Zustände von Erzeuger und Verbraucher angezeigt:

ThreadObserver_window

Ein Problem ist allerdings, dass die Zustände RUNNABLE und BLOCKED so gut wie nie sichtbar werden. Klar: Wenn ein Erzeuger oder Verbraucher mal in den Speicher darf, ist seine Arbeit darin – es sind ja nur ein paar Zeilen Code – so schnell erledigt, dass er a) sofort danach wieder beim Erzeugen oder Verbrauchen ist (TIMED_WAITING) und b) ein eventuell geblockter Thread sofort wieder entblockt wird, laufen darf und dann wieder am Arbeiten ist (TIMED_WAITING).

Eine Lösung ist die Klasse Zeitschinder mit einer (der Einfachheit halber statischen) Methode zeitSchinden. Die wird innerhalb der Speicher/Monitor-Methoden aufgerufen. Darin wird mal eben die Fakultät von 20000 berechnet, so dass die synchronisierten Methoden nicht gar so schnell ablaufen. Dann sieht man auch mehr Zustände:

ThreadObserver_window


Aufgaben:

  • Warum ist immer nur höchstens ein Thread grün? Kann man das ändern?
  • Warum endet das ganze immer damit, dass ein paar Threads grau angezeigt werden und ein paar orange?
  • Was ist der Unterschied zwischen sleep() und wait()? Was der zwischen BLOCKED und WAITING?
  • Bauen Sie den ThreadObserver in eines Ihrer Projekte ein, lassen Sie Ihren Starter davon erben und nutzen Sie dann die Methoden zum Hinzufügen und Protokollieren von Threads. Arbeiten Sie, falls Sie wollen, mit Zeitschinder.zeitSchinden() (Download der beiden Java-Klassen).
  • Erklären Sie mir, wieso bei mir manchmal ein Thread als BLOCKED gemeldet wird, selbst wenn im ganzen Projekt keine einzige synchronisierte Methode (oder synchronisierter Anweisungsblock) verwendet wird. Das verstehe ich selber noch nicht.

 

Damit sind meine drei Blogeinträge zu Threads unter Java beendet, mit denen bin ich auch für den Flipped Classroom vorbereitet: Meine Schülerinnen und Schüler sollen sich das jeweils zu Hause durchlesen und die Aufgaben dazu dann in der Schule lösen. Ich lerne selber ja auch aus aus Texten besser als aus Videos. Natürlich gibt e zu Threads noch mehr zu sagen, und Java bietet noch weitere Möglichkeiten zur Synchronisation an, aber viel mehr weiß ich nicht und für die Schule reicht das eh. Leider ist das Thema Threads im neuen Lehrplan weitgehend gestrichen. Da wird nur noch modelliert, aber nicht mehr implementiert – kein Java mehr, keine echte Umsetzung. Nun, dauert ja noch acht Jahre, bis der in der Oberstufe angelangt ist.

Eine Antwort auf „Threads III – Erzeuger, Verbraucher und ein neuer Zustand“

Schreibe einen Kommentar

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