Steuerung von Threads

Parallele Programmierung (3IB)

Prof. Dr.-Ing. Sandro Leuchter
Hochschule Mannheim, Fakultät für Informatik
Wintersemester 2024/2025

 

Dieses Werk ist lizenziert unter einer Creative Commons „Namensnennung – Nicht-kommerziell – Weitergabe unter gleichen Bedingungen 4.0 International“ Lizenz.

Überblick

=> Grundlage: Hettel und Tran (2016, Kap. 5 und Abschnitt 8.1.3)

  • Verklemmung und andere Probleme im Zusammenhang mit Steuerung
  • Steuerung von Threads mit synchronized
    • Übungsaufgabe: “Dinierende Philosophen”
  • Steuerung von Threads mit synchronized und einem Warteraum
    • Übungsaufgabe: “Ringspeicher”
  • Steuerung von Threads mit mehreren Bedingungsvariablen
    • Übungsaufgabe: “Ringspeicher (mit Condition)”

Einführung in Steuerungsprobleme

Verklemmungen und andere Zustände

Definition Verklemmung (engl. “Deadlock”)

“Eine Menge von Prozessen befindet sich in einem Deadlock-Zustand, wenn jeder Prozess aus der Menge auf ein Ereignis wartet, das nur ein anderer Prozess aus der Menge auslösen kann.”1

Verhungern (engl. “Starvation”)

Ein Prozess “verhungert”, wenn er nie Zugriff auf eine Ressource erhält, die von mehreren Prozessen beansprucht wird.

Livelock (Spezialfall von Verhungern)

Eine Menge Prozessen befindet sich in einem Livelock-Zustand, wenn mindestens ein Prozess verhungert, weil die Kontrolle zwischen anderen Prozessen hin und her geht, ohne dass verhungernde Prozesse die Kontrolle jemals mehr erlangen können. (Kein Deadlock!)

Prioritätsinversion (Umkehr der Priorität)

Problemsituation

  • Prozess mit niedriger Priorität hat Betriebsmittel akquiriert.
  • Prozess mit hoher Priorität möchte dasselbe Betriebsmittel und wird daher blockiert.
  • Ein lange laufender Prozess mit mittlerer Priorität kannibalisiert Rechenzeit, so dass der niederpriorisierte Prozess nicht mehr drankommt.
  • Der hochpriorisierte Thread verhungert.

Lösung (eine von mehreren möglichen): Prioritätsvererbung

Beispiel: Pathfinder (NASA Mars-Fahrzeug, 1996)1

  • gemeinsame Ressource: Middleware zur Datenspeicherung
  • Prozess niederer Priorität: Geo/Met Datensammlung
  • Prozess hoher Priorität: Überwachung der Middleware (Watchdog)

Die Bedeutung im Java-Umfeld ist gering oder zumindest unklar, da kein spezifisches Scheduling Verfahren für JVM vorgegeben ist.

Steuerung von Threads mit synchronized

Steuerung durch gegenseitigen Ausschluss (synchronized)

  • gegenseitige Beeinflussung durch synchronized
  • kann auch verschachtelt sein
    • Gefahr von Verklemmung
    • kann verborgen sein (synchronized in privater Implementierung einer Methode in externer Klasse - z.B. Library)

Deadlock durch gegenseitiges Warten

static Object l1 = new Object();
static Object l2 = new Object();
static void m1() {
    synchronized (l1) {
        synchronized (l2) {
            /* ... */
        }
    }
}
static void m2() {
    synchronized (l2) {
        synchronized (l1) {
            /* ... */
        }
    }
}
public static void main(String... args) {
    var t1 = new Thread(Deadlock::m1); t1.start();
    var t2 = new Thread(Deadlock::m2); t2.start(); 
}

Laboraufgabe “Dinnierende Philosoph:innen”

  • Projekt: pp.03.01-SynchPhilosopher
  • Bearbeitungszeit: 15 Minuten
  • Musterlösung: 15 Minuten
  • Kompatibilität: mindestens Java SE 19

Der Lebenszyklus eines/r Philosoph:in ist:

  1. denken
  2. linkes Essstäbchen greifen
  3. rechtes Essstäbchen greifen
  4. essen
  5. rechtes Essstäbchen zurücklegen
  6. linkes Essstäbchen zurücklegen
  7. weiter bei 1.

Greifen von Esstäbchen ist kritischer Abschnitt

Butcher (2014, 16)

Laboraufgabe “Dinnierende Philosoph:innen”

Wenn alle Threads auf die Locks in derselben numerischen Reihenfolge zugreifen (in den synchronized-Block eintreten), dann kommt es nicht zu einem Deadlock (“global ordering rule”).

synchronized (left) {
    synchronized (right) {
        //…
    }
}

Die meisten Philosophen folgen diesem Schema: left und right sind Chopstick-Objekte. Philosoph \(i\) und Philosoph \(i+1\) teilen ein Chopstick-Objekt. Für Philosoph \(i\) ist das das rechte, auf das als Zweites zugegriffen wird, für Philosoph \(i+1\) ist das das linke, auf das als Erstes zugegriffen wird.

Nur im Fall des letzten bzw. 0. Philosophs wird diese Reihenfolge umgekehrt, daher kann es zum Deadlock kommen.

Erkenntnis aus dieser Übungsaufgabe

“To my mind, what makes multithreaded programming difficult is not that writing it is hard, but that testing it is hard. It’s not the pitfalls that you can fall into; it’s the fact that you don’t necessarily know whether you’ve fallen into one of them.”1

Steuerung von Threads mit synchronized und einem Warteraum

Steuerung über Monitor (wait + notify/notifyAll)

Manchmal ist es erforderlich Threads über Ereignisse zu steuern.

Dazu dient ein der Monitor eines “Vermittlerobjekts”. Es muss lediglich von Object erben. Die beteiligten Threads kommunizieren über den Monitor dieses Vermittlers miteinander.

Ein Thread muss im Besitz des Monitors des Vermittlers sein, um die Methoden wait(), notify() und notifyAll() aufrufen zu dürfen, sonst wird eine IllegalMonitorStateException geworfen.

Diese Methoden können somit nur innerhalb von synchronized-Blöcken bzw. -Methoden verwendet werden.

Alle wartenden Threads sind in der condition queue des Locks, auch condition variable oder Warteraum genannt.

Monitor: Auf Bedingung warten

obj.wait():

  • Der aufrufende Thread t1 prüft das Vorhandensein einer Wartebedingung (ist in der Regel kritischer Abschnitt, daher in synchronized-Block).
  • Der aufrufende Thread t1 muss spätestens jetzt den Monitor (“Schloss”) des vermittelnden Lock-Objekts obj belegt haben.
synchronized(obj){
    ... obj.wait(); ...
}
  • Ist die Wartebedingung erfüllt, trägt sich der aufrufende Thread t1 in die Warteliste des vermittelnden Lock-Objekts obj ein.
  • Der aufrufende Thread t1 gibt den Monitor von obj wieder frei.
  • t1 wechselt in den WAITING-Zustand und bleibt so lange darin, bis ihn ein anderer Thread (im Folgenden t2) durch obj.notify() oder obj.notifyAll() weckt oder er ein t1.interrupt() erhält.

Monitor: Signal zum Aufwachen

obj.notifyAll():

  • Der Signal sendende Thread t2 muss den Monitor des vermittelnden Lock-Objekts obj belegt haben.
synchronized(obj){
    ... obj.notifyAll(); ...
}
  • Der Scheduler weckt alle Threads auf, die im Warteraum von obj warten (neuer Zustand RUNNABLE).
  • Ein wieder aktivierter Thread schließt zunächst das Schloss obj wieder, bevor er mit den Anweisungen nach wait() fortfährt. Daher kann nur einer der wartenden Threads fortfahren, obwohl alle aufgeweckt wurden.

Monitor: Signal zum Aufwachen

obj.notify():

  • Der Signal sendende Thread t2 muss den Monitor des vermittelnden Lock-Objekts obj belegt haben.
synchronized(obj){
    ... obj.notify(); ...
}
  • Der Scheduler wählt einen Thread aus, der im Warteraum von obj wartet, und weckt ihn auf (neuer Zustand RUNNABLE).
  • Dieser aktivierter Thread schließt zunächst das Schloss obj wieder, bevor er mit den Anweisungen nach wait() fortfährt.

Benutzungsmuster (idiomatischer Gebrauch von wait + notify/notifyAll)

synchronized(obj) {
    while (/* «Wartebedingung» */) {
        obj.wait();
    }
    /* «Benutzung geteilter Ressourcen» */
    obj.notifyAll();
}
  • while: Falls in der Zwischenzeit die Bedingung wieder eingetreten ist oder der Thread “fehlerhaft” geweckt wurde; if statt while wäre hier ein Fehler!
  • notifyAll: Falls mehrere Threads auf unterschiedliche Bedingungen an obj warten, muss zum Signalisieren immer notifyAll() benutzt werden!

Anwendungsbeispiel Ringpuffer

Start:

items.length 8 Kapazität von items
count 0 aktuelle Anzahl Elemente
takeptr 0 Index nächstes take()
putptr 0 Index nächstes put()
  • Ringpuffer ist “leer” (count == 0)
  • Solange dies so ist, müssen take()-Aufrufe warten.

Hettel und Tran (2016, 65 (modifiziert))

Anwendungsbeispiel Ringpuffer

put(42):

items.length 8 Kapazität von items
count 1 aktuelle Anzahl Elemente
takeptr 0 Index nächstes take()
putptr 1 Index nächstes put()
  • 42 ist an Index 0 von items
  • im Moment einziges Element in items

Hettel und Tran (2016, 65 (modifiziert))

Anwendungsbeispiel Ringpuffer

put(99):

items.length 8 Kapazität von items
count 2 aktuelle Anzahl Elemente
takeptr 0 Index nächstes take()
putptr 2 Index nächstes put()
  • 99 wurde als nächstes gespeichert
  • es wurde noch kein Element entfernt

Hettel und Tran (2016, 65 (modifiziert))

Anwendungsbeispiel Ringpuffer

put(80):

items.length 8 Kapazität von items
count 3 aktuelle Anzahl Elemente
takeptr 0 Index nächstes take()
putptr 3 Index nächstes put()
  • 80 wurde als nächstes gespeichert
  • es wurde noch kein Element entfernt

Hettel und Tran (2016, 65 (modifiziert))

Anwendungsbeispiel Ringpuffer

put(12):

items.length 8 Kapazität von items
count 4 aktuelle Anzahl Elemente
takeptr 0 Index nächstes take()
putptr 4 Index nächstes put()
  • 12 wurde als nächstes gespeichert
  • es wurde noch kein Element entfernt

Hettel und Tran (2016, 65 (modifiziert))

Anwendungsbeispiel Ringpuffer

take() \(\to\) 42:

items.length 8 Kapazität von items
count 3 aktuelle Anzahl Elemente
takeptr 1 Index nächstes take()
putptr 4 Index nächstes put()
  • das erste Element wird gelöscht
  • und als Ergebnis zurückgeliefert

Hettel und Tran (2016, 65 (modifiziert))

Anwendungsbeispiel Ringpuffer

put(20):

items.length 8 Kapazität von items
count 4 aktuelle Anzahl Elemente
takeptr 1 Index nächstes take()
putptr 5 Index nächstes put()
  • 20 wird gespeichert und putptr wird weiterbewegt
  • takeptr bleibt unverändert

Hettel und Tran (2016, 65 (modifiziert))

Anwendungsbeispiel Ringpuffer

put(14):

items.length 8 Kapazität von items
count 5 aktuelle Anzahl Elemente
takeptr 1 Index nächstes take()
putptr 6 Index nächstes put()
  • 14 wird gespeichert und putptr wird weiterbewegt
  • takeptr bleibt unverändert

Hettel und Tran (2016, 65 (modifiziert))

Anwendungsbeispiel Ringpuffer

put(44):

items.length 8 Kapazität von items
count 6 aktuelle Anzahl Elemente
takeptr 1 Index nächstes take()
putptr 7 Index nächstes put()
  • 44 wird gespeichert und putptr wird weiterbewegt
  • takeptr bleibt unverändert

Hettel und Tran (2016, 65 (modifiziert))

Anwendungsbeispiel Ringpuffer

put(75):

items.length 8 Kapazität von items
count 7 aktuelle Anzahl Elemente
takeptr 1 Index nächstes take()
putptr 0 Index nächstes put()
  • 75 wird gespeichert und putptr wird weiterbewegt
  • takeptr bleibt unverändert

Hettel und Tran (2016, 65 (modifiziert))

Anwendungsbeispiel Ringpuffer

put(13):

items.length 8 Kapazität von items
count 8 aktuelle Anzahl Elemente
takeptr 1 Index nächstes take()
putptr 1 Index nächstes put()
  • Ringpufer ist voll (items.length == count)
  • weitere put()-Operationen müssen warten

Hettel und Tran (2016, 65 (modifiziert))

Anwendungsbeispiel Ringpuffer

take() \(\to\) 99:

items.length 8 Kapazität von items
count 7 aktuelle Anzahl Elemente
takeptr 2 Index nächstes take()
putptr 1 Index nächstes put()
  • take() entfernt Element an Index 1
  • schafft Platz (items.length > count)

Hettel und Tran (2016, 65 (modifiziert))

Anwendungsbeispiel Ringpuffer

put(22):

items.length 8 Kapazität von items
count 8 aktuelle Anzahl Elemente
takeptr 2 Index nächstes take()
putptr 2 Index nächstes put()
  • 22 wird im gerade frei gewordenen Element des Ringpuffers gespeichert
  • Ringpuffer ist nun wieder voll
  • für den Ringpuffer gibt es zwei Bedingungen:
    • “Ringpuffer voll”
      \(\to\) count == items.length
      \(\to\) put() muss warten
    • “Ringpuffer leer”
      \(\to\) count == 0
      \(\to\) take() muss warten

Hettel und Tran (2016, 65 (modifiziert))

Laboraufgabe “wait/notifyAll für Ringpuffer”

  • Projekt: pp.03.02-BoundedQueueWaitNotify
  • Bearbeitungszeit: 15 Minuten
  • Musterlösung: 15 Minuten
  • Kompatibilität: mindestens Java SE 10

Hettel und Tran (2016, 66 (modifiziert))

Steuerung von Threads mit mehreren Bedingungsvariablen

Bedingungsvariablen

  • Eine Bedingungsvariable ist immer implizit bei synchronized vorhanden, auf der wait()/notify() bzw. notifyAll() ausgeführt werden kann.
  • Es gibt aber Anwendungen mit mehreren voneinander getrennten Bedingungen (z.B. “leer” und “voll” bei der Ringspeicher-Beispielaufgabe). Wenn immer alle Threads mit notifyAll() aufgeweckt werden, kann es sein, dass Verarbeitungskapazizät vergeudet wird. Mit Condition können mehrere getrennte “Warteräume” etabliert werden.
  • Deshalb ist es seit dem JDK 1.5 möglich, mehrere Bedingungsvariablen (Interface Condition aus dem Package java.util.concurrent.locks) in einem “Monitor” zu verwenden.
  • Statt synchronized muss dann aber das Interface Lock aus dem Package java.util.concurrent.locks benutzt werden, um den Monitor zu begrenzen.

Benutzungsmuster (idiomatischer Gebrauch von Lock)

import java.util.concurrent.locks.ReentrantLock;


ReentrantLock lock = new ReentrantLock();

lock.lock();
try {



    /* «Benutzung geteilter Ressourcen» */
} finally { 
    lock.unlock(); 
}

Benutzungsmuster (idiomatischer Gebrauch von Condition)

import java.util.concurrent.locks.ReentrantLock;
import java.util.concurrent.locks.Condition;

ReentrantLock lock = new ReentrantLock();
Condition condition = lock.newCondition();
lock.lock();
try {
    while (/* «Wartebedingung» */) {
        condition.await();
    }
    /* «Benutzung geteilter Ressourcen» */
} finally { 
    lock.unlock(); 
}

Benutzungsmuster (idiomatischer Gebrauch von Condition)

Wenn die wartenden Threads nun “bedingungsgenau” in getrennten Warteräumen (für mehrere Bedingungsvariablen) verweilen, kann signal() statt signalAll() zum Aufwecken benutzt werden, da sichergestellt ist, dass ein zufällig ausgewählter aufgeweckter Thread eine Situation vorfindet, in der die Bedingung, auf die gewartet wurde, erfüllt ist.

Würden mehrere Threads auf unterschiedliche inhaltliche Bedingungen in demselben Warteraum warten, könnte es sein, dass ein Thread geweckt wird, der auf eine Bedingung wartet, die noch nicht eingetreten ist – er ruft daher await() für sich auf. Damit wären alle Threads dann im Zustand WAITING und es gäbe keine Möglichkeit, dass einer andere wecken könnte.

Das Interface Condition aus dem Package java.util.concurrent.locks

import java.util.Date;
import java.util.concurrent.TimeUnit;

public interface Condition {
    public void await()                       // analog wait() 
            throws InterruptedException;
    public void awaitUninterruptibly();
    public long awaitNanos(long nanos) 
            throws InterruptedException;
    public boolean await(long time, TimeUnit unit) 
            throws InterruptedException;
    public boolean awaitUntil(Date deadline) 
            throws InterruptedException;

    public void signal();                     // analog notify()
    public void signalAll();                  // analog notifyAll()
}

Laboraufgabe “await/signal für Ringpuffer”

  • Projekt: pp.03.03-BoundedQueueAwaitSignal
  • Bearbeitungszeit: 15 Minuten
  • Musterlösung: 15 Minuten
  • Kompatibilität: mindestens Java SE 10
final Lock lock = new ReentrantLock();
final Condition notFull = this.lock.newCondition();
final Condition notEmpty = this.lock.newCondition();

Referenzen

Butcher, Paul. 2014. Seven Concurrency Models in Seven Weeks. When Threads Unravel. Dallas: The Pragmatic Programmers.
Hettel, Jörg und Manh Tien Tran. 2016. Nebenläufige Programmierung mit Java. Konzepte und Programmiermodelle für Multicore-Systeme. Heildelberg: dpunkt.verlag.
Tanenbaum, Andrew S. und Herbert Bos. 2016. Moderne Betriebssysteme. 4., aktualisierte Auflage. Pearson Studium.