Subsections of Multi-Threading: Parallelisierung von Programmen
Einführung in die nebenläufige Programmierung mit Threads
Threads sind weitere Kontrollflussfäden, die von der Java-VM (oder (selten) vom OS) verwaltet werden. Damit ist sind sie leichtgewichtiger als der Start neuer Prozesse direkt auf Betriebssystem-Ebene.
Beim Start eines Java-Programms wird die main()
-Methode automatisch in einem
(Haupt-) Thread ausgeführt. Alle Anweisungen in einem Thread werden sequentiell
ausgeführt.
Um einen neuen Thread zu erzeugen, leitet man von Thread
ab oder implementiert
das Interface Runnable
. Von diesen eigenen Klassen kann man wie üblich ein neues
Objekt anlegen. Die Methode run()
enthält dabei den im Thread auszuführenden
Code. Um einen Thread als neuen parallelen Kontrollfluss zu starten, muss man die
geerbte Methode start()
auf dem Objekt aufrufen. Im Fall der Implementierung von
Runnable
muss man das Objekt zuvor noch in den Konstruktor von Thread
stecken
und so ein neues Thread
-Objekt erzeugen, auf dem man dann start()
aufrufen kann.
Threads haben einen Lebenszyklus: Nach dem Erzeugen der Objekte mit new
wird
der Thread noch nicht ausgeführt. Durch den Aufruf der Methode start()
gelangt
der Thread in einen Zustand "ausführungsbereit". Sobald er vom Scheduler eine
Zeitscheibe zugeteilt bekommt, wechselt er in den Zustand "rechnend". Von hier kann
er nach Ablauf der Zeitscheibe durch den Scheduler wieder nach "ausführungsbereit"
zurück überführt werden. Dieses Wechselspiel passiert automatisch und i.d.R. schnell,
so dass selbst auf Maschinen mit nur einem Prozessor/Kern der Eindruck einer parallelen
Verarbeitung entsteht. Nach Abarbeitung der run()
-Methode wird der Thread beendet
und kann nicht wieder neu gestartet werden. Bei Zugriff auf gesperrte Ressourcen
oder durch sleep()
oder join()
kann ein Thread blockiert werden. Aus diesem
Zustand gelangt er durch Interrupts oder nach Ablauf der Schlafzeit oder durch notify
wieder zurück nach "ausführungsbereit".
Die Thread-Objekte sind normale Java-Objekte. Man kann hier Attribute und Methoden haben und diese entsprechend zugreifen/aufrufen. Das klappt auch, wenn der Thread noch nicht gestartet wurde oder bereits abgearbeitet wurde.
- (K2) Grundsätzlicher Unterschied zw. Threads und Prozessen
- (K2) Lebenszyklus von Threads
- (K3) Erzeugen und Starten von Threads
- (K3) Kommunikation mit Objekten
42
Einführung in nebenläufige Programmierung
Traditionelle Programmierung
- Aufruf einer Methode verlagert Kontrollfluss in diese Methode
- Code hinter Methodenaufruf wird erst nach Beendigung der Methode ausgeführt
public class Traditional {
public static void main(String... args) {
Traditional x = new Traditional();
System.out.println("main(): vor run()");
x.run();
System.out.println("main(): nach run()");
}
public void run() {
IntStream.range(0, 10).mapToObj(i -> "in run()").forEach(System.out::println);
}
}
Nebenläufige Programmierung
- Erzeugung eines neuen Kontrollflussfadens (Thread)
- Läuft (quasi-) parallel zu bisherigem Kontrollfluss
- Threads können unabhängig von einander arbeiten
- Zustandsverwaltung durch Java-VM (oder Unterstützung durch Betriebssystem)
- Aufruf einer bestimmten Methode erzeugt neuen Kontrollflussfaden
- Der neue Thread arbeitet "parallel" zum bisherigen Thread
- Kontrolle kehrt sofort wieder zurück: Code hinter dem Methodenaufruf wird ausgeführt ohne auf die Beendigung der aufgerufenen Methode zu warten
- Verteilung der Threads auf die vorhandenen Prozessorkerne abhängig von der Java-VM
public class Threaded extends Thread {
public static void main(String... args) {
Threaded x = new Threaded();
System.out.println("main(): vor run()");
x.start();
System.out.println("main(): nach run()");
}
@Override
public void run() {
IntStream.range(0, 10).mapToObj(i -> "in run()").forEach(System.out::println);
}
}
Erzeugen von Threads
-
Ableiten von
Thread
oder Implementierung vonRunnable
-
Methode
run()
implementieren, aber nicht aufrufen -
Methode
start()
aufrufen, aber (i.d.R.) nicht implementieren
Ableiten von Thread
start()
startet den Thread und sorgt für Ausführung vonrun()
start()
nur einmal aufrufen
Implementierung von Runnable
- Ebenfalls
run()
implementieren - Neues
Thread
-Objekt erzeugen, Konstruktor das eigene Runnable übergeben - Für Thread-Objekt die Methode
start()
aufrufen- Startet den Thread (das Runnable) und sorgt für Ausführung von
run()
- Startet den Thread (das Runnable) und sorgt für Ausführung von
Vorteil von Runnable
: Ist ein Interface, d.h. man kann noch von einer anderen Klasse erben
Zustandsmodell von Threads (vereinfacht)
Threads haben einen Lebenszyklus: Nach dem Erzeugen der Objekte mit new
wird
der Thread noch nicht ausgeführt. Er ist sozusagen in einem Zustand "erzeugt".
Man kann bereits mit dem Objekt interagieren, also auf Attribute zugreifen und
Methoden aufrufen.
Durch den Aufruf der Methode start()
gelangt der Thread in einen Zustand
"ausführungsbereit", er läuft also aus Nutzersicht. Allerdings hat er noch keine
Ressourcen zugeteilt (CPU, ...), so dass er tatsächlich noch nicht rechnet. Sobald
er vom Scheduler eine Zeitscheibe zugeteilt bekommt, wechselt er in den Zustand
"rechnend" und führt den Inhalt der run()
-Methode aus. Von hier kann er nach
Ablauf der Zeitscheibe durch den Scheduler wieder nach "ausführungsbereit" zurück
überführt werden. Dieses Wechselspiel passiert automatisch und i.d.R. schnell,
so dass selbst auf Maschinen mit nur einem Prozessor/Kern der Eindruck einer parallelen
Verarbeitung entsteht.
Nach der Abarbeitung der run()
-Methode oder bei einer nicht gefangenen Exception
wird der Thread beendet und kann nicht wieder neu gestartet werden. Auch wenn der
Thread abgelaufen ist, kann man mit dem Objekt wie üblich interagieren (nur eben
nicht mehr parallel).
Bei Zugriff auf gesperrte Ressourcen oder durch Aufrufe von Methoden wie sleep()
oder join()
kann ein Thread blockiert werden. Hier führt der Thread nichts aus,
bekommt durch den Scheduler aber auch keine neue Zeitscheibe zugewiesen. Aus diesem
Zustand gelangt der Thread wieder heraus, etwa durch Interrupts (Aufruf der Methode
interrupt()
auf dem Thread-Objekt) oder nach Ablauf der Schlafzeit (in sleep()
)
oder durch ein notify
, und wird wieder zurück nach "ausführungsbereit" versetzt
und wartet auf die Zuteilung einer Zeitscheibe durch den Scheduler.
Sie finden in [Boles2008, Kapitel 5.2 "Thread-Zustände"] eine schöne ausführliche Darstellung.
Threads können wie normale Objekte kommunizieren
- Zugriff auf (
public
) Attribute (oder eben über Methoden) - Aufruf von Methoden
Threads können noch mehr
-
Eine Zeitlang schlafen:
Thread.sleep(<duration_ms>)
- Statische Methode der Klasse
Thread
(Klassenmethode) - Aufrufender Thread wird bis zum Ablauf der Zeit oder bis zum Aufruf
der
interrupt()
-Methode des Threads blockiert - "Moderne" Alternative:
TimeUnit
, beispielsweiseTimeUnit.SECONDS.sleep( 2 );
- Statische Methode der Klasse
-
Prozessor abgeben und hinten in Warteschlange einreihen:
yield()
-
Andere Threads stören:
otherThreadObj.interrupt()
- Die Methoden
sleep()
,wait()
undjoin()
im empfangenden ThreadotherThreadObj
lösen eineInterruptedException
aus, wenn sie durch die Methodeinterrupt()
unterbrochen werden. Das heißt,interrupt()
beendet diese Methoden mit der Ausnahme. - Empfangender Thread verlässt ggf. den Zustand "blockiert" und wechselt in den Zustand "ausführungsbereit"
- Die Methoden
-
Warten auf das Ende anderer Threads:
otherThreadObj.join()
- Ausführender Thread wird blockiert (also nicht
otherThreadObj
!) - Blockade des Aufrufers wird beendet, wenn der andere Thread
(
otherThreadObj
) beendet wird.
- Ausführender Thread wird blockiert (also nicht
Hinweis: Ein Thread wird beendet, wenn
- die
run()
-Methode normal endet, oder - die
run()
-Methode durch eine nicht gefangene Exception beendet wird, oder - von außen die Methode
stop()
aufgerufen wird (Achtung: Deprecated! Einen richtigen Ersatz gibt es aber auch nicht.).
Hinweis: Die Methoden wait()
, notify()
/notifyAll()
und die "synchronized
-Sperre"
werden in der Sitzung ["Threads: Synchronisation"](threads-intro.
besprochen.
Wrap-Up
Threads sind weitere Kontrollflussfäden, von Java-VM (oder (selten) von OS) verwaltet
- Ableiten von
Thread
oder implementieren vonRunnable
- Methode
run
enthält den auszuführenden Code - Starten des Threads mit
start
(nie mitrun
!)
- [Boles2008] Parallele Programmierung spielend gelernt mit dem Java-Hamster-Modell
Boles, D., Vieweg+Teubne, 2008. ISBN 978-3-8351-0229-3. - [Java-SE-Tutorial] The Java Tutorials
Oracle Corporation, 2022.
Trail: Essential Java Classes, Lesson: Concurrency - [Ullenboom2021] Java ist auch eine Insel
Ullenboom, C., Rheinwerk-Verlag, 2021. ISBN 978-3-8362-8745-6.
Kap. 16: Einführung in die nebenläufige Programmierung
Synchronisation: Verteilter Zugriff auf gemeinsame Ressourcen
Bei verteiltem Zugriff auf gemeinsame Ressourcen besteht Synchronisierungsbedarf, insbesondere sollten nicht mehrere Threads gleichzeitig geteilte Daten modifizieren. Dazu kommt das Problem, dass ein Thread in einer komplexen Folge von Aktionen die Zeitscheibe verlieren kann und dann später mit veralteten Daten weiter macht.
Um den Zugriff auf gemeinsame Ressourcen oder den Eintritt in kritische Bereiche
zu schützen und zu synchronisieren, kann man diese Zugriffe oder Bereiche in einen
synchronized
-Block legen. Dazu benötigt man noch ein beliebiges (gemeinsam
sichtbares) Objekt, welches als Wächter- oder Sperr-Objekt fungiert. Beim Eintritt
in den geschützten Block muss ein Thread einen Lock auf dem Sperr-Objekt erlangen.
Hat bereits ein anderer Thread den Lock, wird der neue Thread so lange blockiert,
bis der Lock wieder "frei" ist. Beim Eintritt in den Bereich wird dann durch den
Thread auf dem Sperr-Objekt der Lock gesetzt und beim Austritt automatisch wieder
aufgehoben. Dies nennt man auch mehrseitige Synchronisierung (mehrere Threads
"stimmen" sich quasi untereinander über den Zugriff auf eine Ressource ab).
Um auf den Eintritt eines Ereignisses oder die Erfüllung einer Bedingung zu warten,
kann man wait
und notify
nutzen. In einem synchronized
-Block prüft man, ob
die Bedingung erfüllt oder ein Ereignis eingetreten ist, und falls ja arbeitet man
damit normal weiter. Falls die Bedingung nicht erfüllt ist oder das Ereignis nicht
eingetreten ist, kann man auf dem im synchronized
-Block genutzten Sperr-Objekt
die Methode wait()
aufrufen. Damit wird der Thread in die entsprechende Schlange
auf dem Sperr-Objekt eingereiht und blockiert. Zusätzlich wird der Lock auf dem
Sperr-Objekt freigegeben. Zum "Aufwecken" nutzt man an geeigneter Stelle auf dem
selben Sperr-Objekt die Methode notify()
oder notifyALl()
(erstere weckt
einen in der Liste des Sperr-Objekts wartenden Thread, die letztere alle). Nach
dem Aufwachen macht der Thread nach seinem wait()
weiter. Es ist also wichtig,
dass die Bedingung, wegen der ursprünglich das wait()
aufgerufen wurde, erneut
abgefragt wird und ggf. erneut in das wait()
gegangen wird. Dies nennt man
einseitige Synchronisierung.
Es gibt darüber hinaus viele weitere Mechanismen und Probleme, die aber den Rahmen dieser Lehrveranstaltung deutlich übersteigen. Diese werden teilweise in den Veranstaltungen "Betriebssysteme" und/oder "Verteilte Systeme" besprochen.
- (K2) Notwendigkeit zur Synchronisation
- (K2) Unterscheidung einseitige und mehrseitige Synchronisation
- (K3) Synchronisation mit
synchronized
,wait
,notify
undnotifyAll
Motivation: Verteilter Zugriff auf gemeinsame Ressourcen
public class Teaser implements Runnable {
private int val = 0;
public static void main(String... args) {
Teaser x = new Teaser();
new Thread(x).start();
new Thread(x).start();
}
private void incrVal() {
++val;
System.out.println(Thread.currentThread().getId() + ": " + val);
}
public void run() {
IntStream.range(0, 5).forEach(i -> incrVal());
}
}
Zugriff auf gemeinsame Ressourcen: Mehrseitige Synchronisierung
synchronized (<Object reference>) {
<statements (synchronized)>
}
=> "Mehrseitige Synchronisierung"
Fallunterscheidung: Thread T1 führt synchronized
-Anweisung aus:
- Sperre im Sperr-Objekt nicht gesetzt:
- T1 setzt Sperre beim Eintritt,
- führt den Block aus, und
- löst Sperre beim Verlassen
- Sperre durch T1 gesetzt:
- T1 führt den Block aus, und
- löst Sperre beim Verlassen nicht
- Sperre durch T2 gesetzt: => T1 wird blockiert, bis T2 die Sperre löst
Anmerkung: Das für die Synchronisierung genutzte Objekt nennt man "Wächter-Objekt" oder auch "Sperr-Objekt" oder auch "Synchronisations-Objekt".
Damit könnte man den relevanten Teil der Methode incrVal()
beispielsweise in einen
geschützten Bereich einschließen und als Sperr-Objekt das eigene Objekt (this
) einsetzen:
private void incrVal() {
synchronized (this) { ++val; }
}
Synchronisierte Methoden
void f() {
synchronized (this) {
...
}
}
... ist äquivalent zu ...
synchronized void f() {
...
}
Kurzschreibweise: Man spart das separate Wächter-Objekt und synchronisiert auf sich selbst ...
Die Methode incrVal()
könnte entsprechend so umgeschrieben werden:
private synchronized void incrVal() {
++val;
}
Probleme bei der (mehrseitigen) Synchronisierung: Deadlocks
public class Deadlock {
private final String name;
public synchronized String getName() { return name; }
public synchronized void foo(Deadlock other) {
System.out.format("%s: %s.foo() \n", Thread.currentThread().getName(), name);
System.out.format("%s: %s.name()\n", Thread.currentThread().getName(), other.getName());
}
public static void main(String... args) {
final Deadlock a = new Deadlock("a");
final Deadlock b = new Deadlock("b");
new Thread(() -> a.foo(b)).start();
new Thread(() -> b.foo(a)).start();
}
}
Viel hilft hier nicht viel! Durch zu großzügige mehrseitige Synchronisierung kann es passieren, dass Threads gegenseitig aufeinander warten: Thread A belegt eine Ressource, die ein anderer Thread B haben möchte und Thread B belegt eine Ressource, die A gerne bekommen würde. Da es dann nicht weitergeht, nennt man diese Situation auch "Deadlock" ("Verklemmung").
Im Beispiel ruft der erste Thread für das Objekt a
die foo()
-Methode auf und
holt sich damit den Lock auf a
. Um die Methode beenden zu können, muss noch die
getName()
-Methode vom Objekt b
durch diesen ersten Thread aufgerufen werden.
Dafür muss der erste Thread den Lock auf b
bekommen.
Dummerweise hat parallel der zweite Thread auf dem Objekt b
die foo()
-Methode
aufgerufen und sich damit den Lock auf b
geholt. Damit muss der erste Thread so
lange warten, bis der zweite Thread den Lock auf b
freigibt.
Das wird allerdings nicht passieren, da der zweite Thread zur Beendigung der
foo()
-Methode noch getName()
auf a
ausführen muss und dazu den Lock auf b
holen, den aber aktuell der erste Thread hält.
Und schon geht's nicht mehr weiter :-)
Warten auf andere Threads: Einseitige Synchronisierung
Problem
- Thread T1 wartet auf Arbeitsergebnis von T2
- T2 ist noch nicht fertig
Mögliche Lösungen
- Aktives Warten (Polling): Permanente Abfrage
- Kostet unnötig Rechenzeit
- Schlafen mit
Thread.sleep()
- Etwas besser; aber wie lange soll man idealerweise schlafen?
- Warten mit
T2.join()
- Macht nur Sinn, wenn T1 auf das Ende von T2 wartet
- Einseitige Synchronisierung mit
wait()
undnotify()
- Das ist DIE Lösung für das Problem :)
Einseitige Synchronisierung mit wait und notify
-
wait: Warten auf Erfüllung einer Bedingung (Thread blockiert):
synchronized (obj) { // Geschützten Bereich betreten while (!condition) { try { obj.wait(); // Thread wird blockiert } catch (InterruptedException e) {} } ... // Condition erfüllt: Tue Deine Arbeit }
=> Bedingung nach Rückkehr von
wait
erneut prüfen!
Eigenschaften von wait
- Thread ruft auf Synchronisations-Objekt die Methode
wait
auf - Prozessor wird entzogen, Thread blockiert
- Thread wird in interne Warteschlange des Synchronisations-Objekts eingetragen
- Sperre auf Synchronisations-Objekt wird freigegeben
=> Geht nur innerhalb der synchronized
-Anweisung für das Synchronisations-Objekt!
Einseitige Synchronisierung mit wait und notify (cnt.)
-
notify: Aufwecken von wartenden (blockierten) Threads:
synchronized (obj) { obj.notify(); // einen Thread "in" obj aufwecken obj.notifyAll(); // alle Threads "in" obj wecken }
Eigenschaften von notify bzw. notifyAll
- Thread ruft auf einem Synchronisations-Objekt die Methode
notify
odernotifyAll
auf - Falls Thread(s) in Warteschlange des Objekts vorhanden, dann
notify
: Ein zufälliger Thread wird aus Warteschlange entfernt und in den Zustand "ausführungsbereit" versetztnotifyAll
: Alle Threads werden aus Warteschlange entfernt und in den Zustand "ausführungsbereit" versetzt
=> Geht nur innerhalb der synchronized
-Anweisung für das Synchronisations-Objekt!
Wrap-Up
Synchronisierungsbedarf bei verteiltem Zugriff auf gemeinsame Ressourcen:
-
Vorsicht mit konkurrierendem Ressourcenzugriff: Synchronisieren mit
synchronized
=> Mehrseitige Synchronisierung -
Warten auf Ereignisse mit
wait
undnotify
/notifyAll
=> Einseitige Synchronisierung
In den Vorgaben finden Sie eine Modellierung für ein Bankensystem.
Erweitern Sie die Vorgaben um Multithreading.
Erweitern Sie die Klasse Kunde
so, dass sie in einem eigenen Thread ausgeführt werden kann.
In der run()
-Methode soll der Kunde
eine Rechnung
aus der Queue offeneRechnungen
herausnehmen und sie bezahlen. Nutzen Sie dafür die statische Methode Bank#ueberweisen
. Ist die Queue leer, soll der Thread so lange warten, bis eine neue Rechnung eingegangen ist. Nutzen Sie dafür einseitige Synchronisation.
Erweitern Sie die Klasse Transaktion
so, dass sie in einem eigenen Thread ausgeführt werden kann.
In der run()
-Methode soll die Transaktion
ausgeführt werden. Dabei soll vom Konto von
der in der Rechnung hinterlegte Betrag abgezogen werden. Nutzen Sie dafür die Methode Konto#sendeGeld
. Wenn das Geld erfolgreich abgezogen worden ist, soll das Geld auf das Empfängerkonto überwiesen werden. Nutzen Sie dafür die Methode Konto#empfangeGeld
.
Verwenden Sie mehrseitige Synchronisation.
Passen Sie die Methode Bank#ueberweisen
so an, dass diese einen Transaktion
-Thread erstellt und startet. Verwenden Sie dafür eine passende Struktur und setzen Sie die Executor-API ein.
Implementieren Sie die Klasse Geldeintreiber
. Diese bekommt einen Kunden
als Auftraggeber und eine Liste mit weiteren Kunden als Rechnungsempfänger übergeben.
Implementieren Sie den Geldeintreber
so, dass dieser in einem eigenen Thread ausgeführt werden kann.
In der run()
-Methode soll der Geldeintreiber
eine Rechnung generieren und an einen der Kunden
in der Liste schicken. Verwenden Sie dafür die Methode Kunde#empfangeRechnung
. Das Ziel-Konto
der Rechnung
soll das Konto
des Auftraggebers sein.
Der Geldeintreiber
macht nach jeder versendeten Rechnung fünf Sekunden Pause.
Hinweis: Achten Sie darauf, nur die nötigsten Ressourcen zu blockieren und auch nur so lange wie unbedingt nötig.
- [Boles2008] Parallele Programmierung spielend gelernt mit dem Java-Hamster-Modell
Boles, D., Vieweg+Teubne, 2008. ISBN 978-3-8351-0229-3. - [Java-SE-Tutorial] The Java Tutorials
Oracle Corporation, 2022.
Trail: Essential Java Classes, Lesson: Concurrency
High-Level Concurrency
Das Erzeugen von Threads über die Klasse Thread
oder das Interface Runnable
und
das Hantieren mit synchronized
und wait()
/notify()
zählt zu den grundlegenden
Dingen beim Multi-Threading mit Java. Auf diesen Konzepten bauen viele weitere
Konzepte auf, die ein flexibleres Arbeiten mit Threads in Java ermöglichen.
Dazu zählt unter anderem das Arbeiten mit Lock
-Objekten und dazugehörigen Conditions
,
was synchronized
und wait()
/notify()
entspricht, aber feingranulareres und
flexibleres Locking bietet.
Statt Threads immer wieder neu anzulegen (das Anlegen von Objekten bedeutet einen gewissen Aufwand zur Laufzeit), kann man Threads über sogenannte Thread-Pools wiederverwenden und über das Executor-Interface benutzen.
Schließlich bietet sich das Fork/Join-Framework zum rekursiven Zerteilen von Aufgaben und zur parallelen Bearbeitung der Teilaufgaben an.
Die in Swing integrierte Klasse SwingWorker
ermöglicht es, in Swing Berechnungen
in einen parallel ausgeführten Thread auszulagern.
- (K3) Umgang mit High-Level-Abstraktionen: Lock-Objekten und Conditions, Executor-Interface und Thread-Pools, Fork/Join-Framework, SwingWorker
Explizite Lock-Objekte
Sie kennen bereits die Synchronisierung mit dem Schlüsselwort synchronized
.
// Synchronisierung der gesamten Methode
public synchronized int incrVal() {
...
}
// Synchronisierung eines Blocks (eines Teils einer Methode)
public int incrVal() {
...
synchronized (someObj) {
...
}
...
}
Dabei wird implizit ein Lock über ein Objekt (das eigene Objekt im ersten Fall, das Sperrobjekt im zweiten Fall) benutzt.
Seit Java5 kann man alternativ auch explizite Lock-Objekte nutzen:
// Synchronisierung eines Teils einer Methode über ein
// Lock-Objekt (seit Java 5)
// Package `java.util.concurrent.locks`
public int incrVal() {
Lock waechter = new ReentrantLock();
...
waechter.lock();
... // Geschützter Bereich
waechter.unlock();
...
}
Locks aus dem Paket java.util.concurrent.locks
arbeiten analog zum
impliziten Locken über synchronized
. Sie haben darüber hinaus aber einige
Vorteile:
- Methoden zum Abfragen, ob ein Lock möglich ist:
Lock#tryLock
- Methoden zum Abfragen der aktuellen Warteschlangengröße:
Lock#getQueueLength
- Verfeinerung
ReentrantReadWriteLock
mit MethodenreadLock
undwriteLock
- Locks nur zum Lesen bzw. nur zum Schreiben
Lock#newCondition
liefert ein Condition-Objekt zur Benachrichtigung alawait
/notify
:await
/signal
=> zusätzliches Timeout beim Warten möglich
Nachteile:
- Bei Exceptions werden implizite Locks durch
synchronized
automatisch durch das Verlassen der Methode freigegeben. Explizite Locks müssen durch den Programmierer freigegeben werden! => Nutzung desfinally
-Block!
Thread-Management: Executor-Interface und Thread-Pools
Wiederverwendung von Threads
-
Normale Threads sind immer Einmal-Threads: Man kann sie nur einmal in ihrem Leben starten (auch wenn das Objekt anschließend noch auf Nachrichten bzw. Methodenaufrufe reagiert)
-
Zusätzliches Problem: Threads sind Objekte:
- Threads brauchen relativ viel Arbeitsspeicher
- Erzeugen und Entsorgen von Threads kostet Ressourcen
- Zu viele Threads: Gesamte Anwendung hält an
-
Idee: Threads wiederverwenden und Thread-Management auslagern => Executor-Interface und Thread-Pool
Executor-Interface
public interface Executor {
void execute(Runnable command);
}
- Neue Aufgaben als Runnable an einen Executor via
execute
übergeben - Executor könnte damit sofort neuen Thread starten (oder alten
wiederverwenden):
e.execute(r);
=> entspricht in der Wirkung(new Thread(r)).start();
Thread-Pool hält Menge von "Worker-Threads"
-
Statische Methoden von
java.util.concurrent.Executors
erzeugen Thread-Pools mit verschiedenen Eigenschaften:Executors#newFixedThreadPool
erzeugt ExecutorService mit spezifizierter Anzahl von Worker-ThreadsExecutors#newCachedThreadPool
erzeugt Pool mit Threads, die nach 60 Sekunden Idle wieder entsorgt werden
-
Rückgabe:
ExecutorService
(Thread-Pool)public interface ExecutorService extends Executor { ... }
-
Executor#execute
übergibt Runnable dem nächsten freien Worker-Thread (oder erzeugt ggf. neuen Worker-Thread bzw. hängt Runnable in Warteschlange, je nach erzeugtem Pool) -
Methoden zum Beenden eines Thread-Pools (Freigabe):
shutdown()
,isShutdown()
, ...
MyThread x = new MyThread(); // Runnable oder Thread
ExecutorService pool = Executors.newCachedThreadPool();
pool.execute(x); // x.start()
pool.execute(x); // x.start()
pool.execute(x); // x.start()
pool.shutdown(); // Feierabend :)
Hintergrund (vereinfacht)
Der Thread-Pool reserviert sich "nackten" Speicher, der der Größe von $n$ Threads entspricht, und "prägt" die Objektstruktur durch einen Cast direkt auf (ohne wirkliche neue Objekte zu erzeugen). Dieses Vorgehen ist in der C-Welt wohlbekannt und schnell (vgl. Thema Speicherverwaltung in der LV "Systemprogrammierung"). In Java wird dies durch eine wohldefinierte Schnittstelle vor dem Nutzer verborgen.
Ausblick
Hier haben wir nur die absoluten Grundlagen angerissen. Wir können auch
Callables
anstatt von Runnables
übergeben, auf Ergebnisse aus der Zukunft
warten (Futures
), Dinge zeitgesteuert (immer wieder) starten, ...
Schauen Sie sich bei Interesse die weiterführende Literatur an, beispielsweise die Oracle-Dokumentation oder auch [Ullenboom2021] (insbesondere den Abschnitt 16.4 "Der Ausführer (Executor) kommt").
Fork/Join-Framework: Teile und Herrsche
Spezieller Thread-Pool zur rekursiven Bearbeitung parallelisierbarer Tasks
-
java.util.concurrent.ForkJoinPool#invoke
startet Task -
Task muss von
RecursiveTask<V>
erben:public abstract class RecursiveTask<V> extends ForkJoinTask<V> { protected abstract V compute(); }
Prinzipieller Ablauf:
public class RecursiveTask extends ForkJoinTask<V> {
protected V compute() {
if (task klein genug) {
berechne task sequentiell
} else {
teile task in zwei subtasks:
left, right = new RecursiveTask(task)
rufe compute() auf beiden subtasks auf:
left.fork(); // starte neuen Thread
r = right.compute(); // nutze aktuellen Thread
warte auf ende der beiden subtasks: l = left.join()
kombiniere die ergebnisse der beiden subtasks: l+r
}
}
}
Swing und Threads
Lange Berechnungen in Listenern blockieren Swing-GUI
- Problem: Events werden durch einen Event Dispatch Thread (EDT) sequentiell bearbeitet
- Lösung: Berechnungen in neuen Thread auslagern
- Achtung: Swing ist nicht Thread-safe! Komponenten nicht durch verschiedene Threads manipulieren!
Lösung
=> javax.swing.SwingWorker
ist eine spezielle Thread-Klasse, eng mit Swing/Event-Modell verzahnt.
-
Implementieren:
SwingWorker#doInBackground
: Für die langwierige Berechnung (muss man selbst implementieren)SwingWorker#done
: Wird vom EDT aufgerufen, wenndoInBackground
fertig ist
-
Aufrufen:
SwingWorker#execute
: Started neuen Thread nach Anlegen einer Instanz und führt dann automatischdoInBackground
ausSwingWorker#get
: Return-Wert vondoInBackground
abfragen
Anmerkungen
SwingWorker#done
ist optional: kann überschrieben werden- Beispielweise, wenn nach Beendigung der langwierigen Berechnung GUI-Bestandteile mit dem Ergebnis aktualisiert werden sollen
SwingWorker<T, V>
ist eine generische Klasse:T
Typ für das Ergebnis der Berechnung, d.h. Rückgabetyp fürdoInBackground
undget
V
Typ für Zwischenergebnisse
Letzte Worte :-)
-
Viele weitere Konzepte
- Semaphoren, Monitore, ...
- Leser-Schreiber-Probleme, Verklemmungen, ...
=> Verweis auf LV "Betriebssysteme" und "Verteilte Systeme"
-
Achtung: Viele Klassen sind nicht Thread-safe!
Es gibt aber meist ein "Gegenstück", welches Thread-safe ist.
Beispiel Listen:
java.util.ArrayList
ist nicht Thread-safejava.util.Vector
ist Thread-sicher
=> Siehe Javadoc in den JDK-Klassen!
-
Thread-safe bedeutet Overhead (Synchronisierung)!
Wrap-Up
Multi-Threading auf höherem Level: Thread-Pools und Fork/Join-Framework
- Feingranulareres und flexibleres Locking mit Lock-Objekten und Conditions
- Wiederverwendung von Threads: Thread-Management mit Executor-Interface und Thread-Pools
- Fork/Join-Framework zum rekursiven Zerteilen von Aufgaben und zur parallelen Bearbeitung der Teilaufgaben
SwingWorker
für die parallele Bearbeitung von Aufgaben in Swing
- [Java-SE-Tutorial] The Java Tutorials
Oracle Corporation, 2022.
Trail: Essential Java Classes, Lesson: Concurrency - [Ullenboom2021] Java ist auch eine Insel
Ullenboom, C., Rheinwerk-Verlag, 2021. ISBN 978-3-8362-8745-6.
Kap. 16: Einführung in die nebenläufige Programmierung - [Urma2014] Java 8 in Action: Lambdas, Streams, and Functional-Style Programming
Urma, R.-G. und Fusco, M. und Mycroft, A., Manning Publications, 2014. ISBN 978-1-6172-9199-9.
Abschnitt 7.2: The fork/join framework