Das folgende Praktikum umfasst einen relativ grossen Themenblock, der im Unterricht rund vier Wochen umfasst. Da das Praktikum startet, bevor sie den ganzen Stoff kennen, ist das Praktikum in die vier Vorlesungsthemen gegliedert. D.h. Sie können jeweils bis zu dem Teil lösen, der im Unterricht schon behandelt wurde.
Zudem gibt es in diesem Praktikum mehrere kleinere Pflichtaufgaben, mit [PA] gekennzeichnet sind.
Ziele dieses Praktikums sind:
-
Sie verstehen die Grundlagen von Nebenläufigkeit
-
Sie können mehrere Java-Threads starten, kontrollieren und sauber beenden.
-
Sie können das Zustandsmodell von Threads erkären und wissen, welche Mechanismen den Wechsel der Zustände veranlassen.
-
Sie können die verschiedenen Mechanismen zur Thread-Synchronisation sicher anwenden (Mutual-Exclusion, Condition-Synchronisation).
-
Sie können das Monitor-Konzept (wait/notify, Lock & Conditions in Java anwenden.
-
Sie können Producer-Consumer Synchronisation praktisch anwenden.
-
Sie wissen was wie Deadlocks praktisch verhindert werden können.
-
Sie können die modernen Java-Hilfsmittel zum parallelen Ausführen von nebenläufigen Jobs praktisch anwenden.
Das Praktikum enthält verschiedene Arten von Aufgaben, die wie folgt gekennzeichnet sind:
- [TU] – Theoretische Übung
-
Dient der Repetition bzw. Vertiefung des Stoffes aus der Vorlesung und als Vorbereitung für die nachfolgenden Übungen.
- [PU] – Praktische Übung
-
Übungsaufgaben zur praktischen Vertiefung von Teilaspekten des behandelten Themas.
- [PA] – Pflichtaufgabe
-
Übergreifende Aufgabe zum Abschluss. Das Lösen dieser Aufgaben ist Pflicht. Sie muss bis zum definierten Zeitpunkt abgegeben werden, wird bewertet und ist Teil der Vornote.
Für das Praktikum stehen 3 Wochen in den Praktikumslektionen und im Selbststudium zur Verfügung.
Je nach Kenntniss- und Erfahrungsstufe benötigen Sie mehr oder
weniger Zeit.
Nutzen Sie die Gelegenheit den Stoff und zu vertiefen, Auszuprobieren, Fragen zu stellen und Lösungen zu diskutieren (Intensive-Track).
Falls Sie das Thema schon beherrschen müssen Sie nur die Pflichtaufgaben lösen und bis zum angegebenen Zeitpunkt abgeben (Fast-Track).
Die Pflichtaufgabe wird mit 0 bis 2 Punkten bewertet (siehe Leistungsnachweise auf Moodle).
-
Im Unterricht haben Sie zwei Varianten kennengelernt um Threads zu erzeugen. Erläutern Sie jeweils, was für die Implementation spezifisch ist und wie die Thread-Instanz erzeugt und gestartet wird.
-
Erläutern Sie im nachfolgenden (vereinfachten) Thread-Zustandsmodell, was die aufgeführten Zustände bedeuten und ergänzen Sie die Mechanismen welche den Wechsel zwischen den Zuständen auslösen. Wenn vorhanden, geben Sie den entsprechenden Befehl an.
Nachfolgend einige Basisübungen zum Starten und Stoppen von Threads in Java.
public class Printer {
// test program
public static void main(String[] arg) {
PrinterThread a = new PrinterThread("PrinterA", '.', 10);
PrinterThread b = new PrinterThread("PrinterB", '*', 20);
a.start();
b.start();
b.run(); // wie kann das abgefangen werden?
}
private static class PrinterThread extends Thread {
char symbol;
int sleepTime;
public PrinterThread(String name, char symbol, int sleepTime) {
super(name);
this.symbol = symbol;
this.sleepTime = sleepTime;
}
public void run() {
System.out.println(getName() + " run started...");
for (int i = 1; i < 100; i++) {
System.out.print(symbol);
try {
Thread.sleep(sleepTime);
} catch (InterruptedException e) {
System.out.println(e.getMessage());
}
}
System.out.println('\n' + getName() + " run ended.");
}
}
}
-
Studieren Sie das Programm
Printer.java
: Die MethodeThread.run()
ist public und kann daher direkt aufgerufen werden. Erweitern Sie die Methoderun()
so, dass diese sofort terminiert, wenn sie direkt und nicht vom Thread aufgerufen wird.💡Was liefert die Methode Thread.currentThread()
zurück? -
Erstellen sie eine Kopie von
Printer.java
(z.B.PrinterB.java
) und schreiben Sie das Programm so um, dass die run-Methode über das InterfaceRunnable
implementiert wird.
Führen Sie dazu eine KlassePrinterRunnable
ein, die das InterfaceRunnable
implementiert.
Starten Sie zwei Threads, so dass die selbe Ausgabe entsteht wie bei (a). -
Wie kann erreicht werden, dass die Fairness erhöht wird, d.h. dass der Wechsel zwischen den Threads häufiger erfolgt? Wirkt es sich aufs Resultat aus?
-
Wie muss man das Hauptprogramm anpassen, damit der Main-Thread immer als letztes endet?
In dieser Aufgabe üben Sie als erstes wie ein endloss laufender Thread correkt von aussen beendet wird.
Zudem vermittelt diese Aufgabe vermittelt einen Einblick in das Prioritätensystem der Java Threads.
Als Basis der Aufgabe dient die Klasse PriorityTest.java
.
-
Als Erstes, ersetzen Sie die veraltete Methode
thread.stop()
durch eine "saubere" Variante dieSimpleThread
-Instanzen zu beenden. Dabei sollen sichergestellt werden, dass auch Threads beendet werden, die momentan gerade "pausiert" sind. -
Mit Hilfe der Klasse soll das Verhalten von Java Threads mit verschiedenen Prioritäten analysiert werden.
💡Es kann sein, dass verschiedene Betriebssysteme und Java-Versionen sich unterschiedlich verhalten http://www.javamex.com/tutorials/threads/priority.shtml Je nach Priorität im Bereich von
Thread.MIN_PRIORITY=1
überThread.NORM_PRIORITY=5
bisThread.MAX_PRIORITY=10
, sollte der Thread vom Scheduler bevorzugt behandelt werden, d.h. der Zählercount
sollte häufiger inkrementiert werden.
Kommentieren Sie dazu denThread.sleep()
try-catch
-Block aus, um sicherzusstellen, dass damit der Thread nicht zum Freigeben der CPU gezwungen wird.Folgende Fragen müssen abgeklärt und beantwortet werden:
-
Wie verhält es sich, wenn alle Threads die gleiche Priorität haben?
-
Was stellen Sie fest, wenn die Threads unterschiedliche Priorität haben?
Erhöhen Sie auch die Anzahl Threads (z.B. 100), um eine Ressourcen-Knappheit zu provozieren.
-
Nachfolgend eine einfache Klasse, um ein Konto zu verwalten, den Saldo abzufragen oder zu aktualisieren.
public class Account {
private int id;
private int saldo = 0;
public Account(int id, int initialAmount) {
this.id = id;
this.saldo = initialAmount;
}
public int getId() {
return id;
}
public int getSaldo() {
return saldo;
}
public void changeSaldo(int delta) {
this.saldo += delta;
}
}
Ein Entwickler implementiert aufbauend auf der Klasse Account eine Operation für
den Transfer eines Geldbetrages zwischen zwei Konti.
Die Klasse AccountTransferThread
implementiert dazu die Methode accountTransfer
,
welche in einer Schleife mehrfach aufgerufen wird, um viele kleine Transaktionen
zu simulieren. Das Testprogramm AccountTransferTest
(siehe abgegebenen Code)
erzeugt schlussendlich mehrere Threads, die teilweise auf denselben Konto-Objekten
operieren.
class AccountTransferThread extends Thread {
private Account fromAccount;
private Account toAccount;
private int amount;
private int maxIter = 10000;
public AccountTransferThread(String name, Account fromAccount,
Account toAccount, int amount)
{
super(name);
this.fromAccount = fromAccount;
this.toAccount = toAccount;
this.amount = amount;
}
/* Transfer amount from fromAccount to toAccount */
public void accountTransfer() {
// Account must not be overdrawn
if (fromAccount.getSaldo() >= amount) {
fromAccount.changeSaldo(-amount);
toAccount.changeSaldo(amount);
}
}
public void run() {
for (int i = 0; i < maxIter; i++) {
accountTransfer();
try { // simulation of work time
Thread.sleep((int) (Math.random() * 10));
} catch (InterruptedException e) {
System.out.println(e.getMessage());
}
}
System.out.println("DONE! " + getName());
}
}
-
Was stellen Sie fest, wenn Sie das Testprogramm laufen lassen? Erklären Sie wie die Abweichungen zustande kommen.
-
Im Unterricht haben Sie gelernt, dass sie kritische Bereiche Ihres Codes durch Mutual-Exclusion geschützt werden sollen. Wie macht man das in Java?
Versuchen Sie mit Hilfe von Mutual-Exclusion sicher zu stellen, dass keine Abweichungen entstehen. Reicht es, wenn Sie die kritischen Methoden in Account schützen?
Untersuchen Sie mehrere Varianten von Locks (Lock auf Methode oder Block, Lock auf Instanz oder Klasse).
Ihre Implementierung muss noch nebenläufige Transaktionen erlauben, d.h. wenn Sie zu stark synchronisieren, werden alle Transaktionen in Serie ausgeführt und Threads machen keinen Sinn mehr.
Stellen Sie für sich folgende Fragen:-
Welches ist das Monitor-Objekt?
-
Braucht es eventuell das Lock von mehr als einen Monitor während der Transaktion?
-
-
Wenn Sie es geschafft haben die Transaktion thread-safe zu implementieren, ersetzen Sie in
AccountTransferTest
die die folgende Zeile :
AccountTransferThread t1 = new AccountTransferThread("Worker 1", account3, account1, 1);
durch
AccountTransferThread t1 = new AccountTransferThread("Worker 1", account1, account3, 1);
und starten Sie das Programm noch einmal. Was stellen Sie fest? (evtl. müssen Sie es mehrfach versuchen, damit der Effekt auftritt).
Was könnte die Ursache sein und wie können Sie es beheben?ℹ️Falls Sie die Frage noch nicht beantworten können, kommen sie nach der Vorlesung "Concurrency 3" nochmals auf diese Aufgabe zurück und versuchen Sie sie dann zu lösen.
In dieser Aufgabe sollen Sie die Funktionsweise einer Ampel und deren Nutzung nachahmen.
Benutzen Sie hierzu die Vorgabe TrafficLightOperation.java
.
-
Ergänzen Sie zunächst in der Klasse
TrafficLight
drei Methoden:-
Eine Methode zum Setzen der Ampel auf „rot“.
-
Eine Methode zum Setzen der Ampel auf „grün“.
-
Eine Methode mit dem Namen
passby()
. Diese Methode soll das Vorbeifahren eines Fahrzeugs an dieser Ampel nachbilden: Ist die Ampel rot, so wird der aufrufende Thread angehalten, und zwar so lange, bis die Ampel grün wird. Ist die Ampel dagegen grün, so kann der Thread sofort aus der Methode zurückkehren, ohne den Zustand der Ampel zu verändern. Verwenden Siewait
,notify
undnotifyAll
nur an den unbedingt nötigen Stellen!ℹ️Die Zwischenphase „gelb“ spielt keine Rolle – Sie können diesem Zustand ignorieren!
-
-
Erweitern Sie nun die Klasse
Car
(abgeleitet vonThread
). Im Konstruktor wird eine Referenz auf ein Feld von Ampeln übergeben. Diese Referenz wird in einem entsprechenden Attribut der KlasseCar
gespeichert. In der run-Methode werden alle Ampeln dieses Feldes passiert, und zwar in einer Endlosschleife (d.h. nach dem Passieren der letzten Ampel des Feldes wird wieder die erste Ampel im Feld passiert).
Natürlich darf das Auto erst dann eine Ampel passieren, wenn diese auf grün ist!
Für die Simulation der Zeitspanne für das Passieren der des Signals können Sie die folgende Anweisung verwenden:sleep((int)(Math.random() * 500));
Beantworten Sie entweder (c) oder (d) (nicht beide):
-
Falls Sie bei der Implementierung der Klasse TrafficLight die Methode
notifyAll()
benutzt haben: Hätten Sie stattnotifyAll
auch die Methodenotify
verwenden können, oder haben SienotifyAll()
unbedingt gebraucht? Begründen Sie Ihre Antwort! -
Falls Sie bei der Implementierung der Klasse Ampel die Methode
notify()
benutzt haben: Begründen Sie, warum SienotifyAll()
nicht unbedingt gebraucht haben! -
Testen Sie das Programm
TrafficLightOperation.java
. Die vorgegebene Klasse implementiert eine primitive Simulation von Autos, welche die Ampeln passieren. Studieren Sie den Code dieser Klasse und überprüfen Sie, ob die erzeugte Ausgabe sinnvoll ist.
In dieser Aufgabe wird ein Producer-Consumer Beispiel mit Hilfe einer Queue umgesetzt.
Dazu wird eine Implementation mittels eines Digitalen Ringspeichers umgesetzt.
Hierzu sind zwei Klassen (CircularBuffer.java
, Buffer.java
) vorgegeben, mit folgendem Design:
Als erstes soll ein Producer und ein Consumer implementiert werden.
Nachfolgend ist das Gerüst für beide Klassen abgebildet (siehe CircBufferTest.java
):
class Producer extends Thread {
public Producer(String name, Buffer buffer, int prodTime) {
// ...
}
public void run() {
// ...
}
}
class Consumer extends Thread
{
public Consumer(String name, Buffer buffer, int consTime) {
// ...
}
public void run() {
// ...
}
}
Der Producer soll Daten in den Buffer einfüllen, und der Consumer soll Daten
auslesen. Auf den Buffer soll nur über das Interface zugegriffen werden.
Das Zeitintervall, der ein Producer braucht um die Daten zu erstellen, ist mit
sleep((int)(Math.random()*prodTime))
zu definieren. Die Zeit für verarbeitung des Consumers soll entsprechend mit sleep((int)(Math.random() * consTime))
bestimmt werden.
Für Producer und Consumer wurde bereits ein Testprogramm (CircBufferTest
) geschrieben.
Testen Sie damit ihre Consumer- und Producer-Klassen.
Versuchen sie den generierten Output auf der Console richtig zu interpretieren!
Spielen sie mit den Zeitintervallbereichen von Producer (maxProdTime
) und
Consumer (maxConsTime
) und ziehen sie Schlüsse.
Erstellen sie über die Modifikation von prodCount
und consCount
mehrere Producer bzw. Consumer.
ℹ️
|
Generieren sie in den selber implementierten Klassen keine eigene Ausgabe.
Ändern sie den bestehenden Code nicht. Es stehen zwei Ausgabefunktionen zur
Auswahl: |
In der vorangehenden Übung griffen mehrere Threads auf den gleichen Buffer zu. Die Klasse CircularBuffer ist aber nicht thread-safe. Was wir gemacht haben, ist daher nicht tragbar. Deshalb soll jetzt eine Wrapper Klasse geschrieben werden, welche die CircularBuffer-Klasse "thread-safe" macht. Das führt zu folgendem Design:
Aufrufe von put
blockieren, solange der Puffer voll ist, d.h., bis also mindestens wieder ein leeres Puffer-Element vorhanden ist.
Analog dazu blockieren Aufrufe von get
, solange der Puffer leer ist, d.h, bis also mindestens ein Element im Puffer vorhanden ist.
💡
|
Verwenden Sie den Java Monitor des |
Beantworten Sie entweder (a) oder (b) (nicht beide):
-
Falls Sie bei der Implementierung der Klasse
GuardedCircularBuffer
die MethodenotifyAll()
benutzt haben: Hätten Sie stattnotifyAll()
auch die Methodenotify()
verwenden können oder haben SienotifyAll()
unbedingt gebraucht? Begründen Sie Ihre Antwort! -
Falls Sie bei der Implementierung der Klasse
GuardedCircularBuffer
die Methodenotify()
benutzt haben: Begründen Sie, warum SienotifyAll()
nicht unbedingt gebraucht haben!
Die Brücke über einen Fluss ist so schmal, dass Fahrzeuge nicht kreuzen können. Sie soll jedoch von beiden Seiten überquert werden können. Es braucht somit eine Synchronisation, damit die Fahrzeuge nicht kollidieren. Um das Problem zu illustrieren wird eine fehlerhaft funktionierende Anwendung, in welcher keine Synchronisierung vorgenommen wird, zur Verfügung gestellt. Ihre Aufgabe ist es die Synchronisation der Fahrzeuge einzubauen.
Die Anwendung finden Sie im Ordner handout/Bridge
.
Nach dem Kompilieren (z.B. mit gradle build
) können Sie diese starten, in dem
Sie die Klasse Main
ausführen (z.B. mit gradle run
). Das GUI sollte
selbsterklärend sein. Mit den zwei Buttons können sie Autos links bzw. rechts
hinzufügen. Sie werden feststellen, dass die Autos auf der Brücke kollidieren.
Um das Problem zu lösen müssen Sie die den GUI Teil der Anwendung nicht verstehen.
Sie müssen nur wissen, dass Fahrzeuge die von links nach rechts fahren
die Methode controller.enterLeft()
aufrufen bevor sie auf die Brücke fahren
(um Erlaubnis fragen) und die Methode controller.leaveRight()
aufrufen sobald
sie die Brücke verlassen. Fahrzeuge in die andere Richtung rufen entsprechend
die Methoden enterRight()
und leaveLeft()
auf.
Dabei ist controller
eine Instanz der Klasse TrafficController
welche für
die Synchronisation zuständig ist. In der mitgelieferte Klasse sind die vier
Methoden nicht implementiert (Dummy-Methoden).
-
Bauen sie die Klasse
TrafficController
in einen Monitor um der sicherstellt, dass die Autos nicht mehr kollidieren. Verwenden Sie dazu den Lock und Conditions Mechanismus.💡Verwenden Sie eine Statusvariable um den Zustand der Brücke zu repräsentieren (e.g. boolean bridgeOccupied
). -
Erweitern Sie die Klasse
TrafficController
so, dass jeweils abwechslungsweise ein Fahrzeug von links und rechts die Brücke passieren kann. Unter Umständen wird ein Auto blockiert, wenn auf der anderen Seite keines mehr wartet. Verwenden Sie für die Lösung mehrere Condition Objekte. -
Bauen Sie die Klasse
TrafficController
um, so dass jeweils alle wartenden Fahrzeuge aus einer Richtung passieren können und erst wenn keines mehr wartet die Gegenrichtung fahren kann.💡Mit ReentrentLock.hasWaiters(Condition c)
können Sie abfragen ob Threads auf eine bestimmte Condition warten.
Fünf Philosophen sitzen an einem Tisch mit einer Schüssel, die immer genügend Spaghetti enthält. Ein Philosoph ist entweder am Denken oder am Essen. Um zu essen braucht er zwei Gabeln. Es hat aber nur fünf Gabeln. Ein Philosoph kann zum Essen nur die neben ihm liegenden Gabeln gebrauchen. Aus diesen Gründen muss ein Philosoph warten und hungern, solange einer seiner Nachbarn am Essen ist.
Das zweite Bild zeigt die Ausgabe des Systems, das wir in dieser Aufgabe verwenden. Die schwarzen Kreise stellen denkende Philosophen dar, die gelben essende und die roten hungernde. Bitte beachten Sie, dass eine Gabel, die im Besitz eines Philosophen ist, zu dessen Teller hin verschoben dargestellt ist.
-
Analysieren Sie die bestehende Lösung (
PhilosopherTable.java
), die bekanntlich nicht Deadlock-frei ist. Kompilieren und starten Sie die Anwendung. Nach einiger Zeit geraten die Philosophen in eine Deadlock-Situation und verhungern. Überlegen Sie sich, wo im Code der Deadlock entsteht und versuchen Sie, dessen Auftreten schneller herbeizuführen. -
Passen Sie die bestehende Lösung so an, dass keine Deadlocks mehr möglich sind. Passen Sie den
ForkManager
so an, dass sich Gabelpaare in einer atomaren Operation belegen bzw. freigegeben lassen. Die GUI-Klasse müssen Sie nicht anpassen. Die Änderungen an der KlassePhilosoph
sind minimal, da sie nur den Methodenaufruf für die Freigabe bzw. Belegung der Gabeln ändern müssen.ℹ️Verwenden Sie für die Synchronisation Locks und Conditions!
Testen Sie ihre Lösung auf Deadlock-Freiheit! -
In der Vorlesung haben Sie mehrere Lösungsansätze kennen gelernt. Erläutern Sie (theoretisch) wie implementiert werden könnte, wenn Sie den Deadlock über Nummerierung der Ressourcen verhindern möchten.
Im Unterricht haben sie verschieden Arten von Thread-Pools kennengelernt.
Welcher davon würde sich für die folgend Anwendungsfälle am Besten eignen?
Wenn nötig, geben Sie auch die Konfiguration des Thread-Pools an.
-
Sie schreiben einen Server, der via Netzwerk Anfragen erhält. Jede Anfrage soll in einem eigenen Task beantwortet werden. Die Anzahl gleichzeitiger Anfragen schwankt über den Tag verteilt stark.
-
Ihr Graphikprogramm verwendet komplexe Mathematik um von hunderten von Objekten die Position, Geschwindigkeit und scheinbare Grösse (aus Sicht des Betrachters) zu berechnen und auf dem Bildschirm darzustellen.
-
Je nach Datenset sind unterschiedliche Algorithmen schneller in der Berechnung des Resultats (z.B. Sortierung). Sie möchten jedoch in jedem Fall immer so schnell wie möglich das Resultat haben und lassen deshalb mehrere Algorithmen parallel arbeiten.
Die JavaFX-Anwendung Mandelbrot
berechnet die Fraktaldarstellung eines Ausschnitts aus der Mandelbrot-Menge.
Dazu wird die zeilenweise Berechnung auf mehrere Threads aufgeteilt.
ℹ️
|
Sie müssen die Mathematik hinter den Mandelbrotfraktalen nicht verstehen um die Aufgaben zu lösen. |
In der abgegebenen Version werden die Threads noch "konventionell" erzeugt und beendet. Der Benutzer kann wählen, wieviele Threads verwendet werden sollen. Jedem Thread wird dann ein Block von Zeilen zugeteilt (startRow…endRow), welcher diese berechnet und ausgibt.
Analysieren und testen Sie die Anwendung:
-
Mit welcher Anzahl Threads erhalten Sie auf Ihrem Rechner die besten Resultate?
💡Die Gesamtrechenzeit wird in der Konsole ausgegeben. -
Wie interpretieren Sie das Resultat im Verhältnis zur Anzahl Cores ihres Rechners?
Erstellen Sie eine Kopie der Klasse Mandelbrot
mit Namen MandelbrotExecutor
.
Bauen Sie die Anwendung um, so dass für das Management der Tasks ein ExecutorService
mit einem Thread-Pool verwendet wird.
-
Die Methoden
start
,startOrStopCalculation
,taskFinished
unddrawOneRow
gehören zum GUI und sollten nicht verändert werden müssen. -
Das Task-Management sollte durch Anpassen der Methoden
startTasks
,stopTasks
und der KlasseMandelbrotTask
erfolgen. -
Überlegen Sie sich, welchen Typ von Thread-Pool hier sinnvollerweise verwendet wird.
-
Verwenden Sie den vom Benutzer gesetzten ThreadCount um die Grösse des Thread-Pools zu definieren.
-
Neu soll der
MandelbrotTask
nur noch eine Zeile berechnen und ausgeben. Das heisst, es muss für die Berechnung jeder Zeile ein Task erzeugt und übermittelt werden, nicht mehr pro Zeilenblock.
Erstellen Sie eine weitere Kopie mit dem Namen MandelbrotCallable
.
Bauen Sie die Anwendung so um, dass MandelbrotTask
ein Callable ist, welches eine Zeile berechnet und zurückgibt.