Technische Universität München Institut für Informatik Lehrstuhl für Computer Graphik & Visualisierung Praktikum: Grundlagen der Programmierung Prof. R. Westermann, A. Lehmann, R. Fraedrich, F. Reichl WS 2010 Aufgabenblatt 11 25. Januar – 31. Januar Threads 11.1 (Ü) Allgemeine Fragen (a) Was sind Threads und wozu dienen sie? Lassen Sie sich zur Veranschaulichung von Ihrem Tutor das Rendern einer Grafik mit der bekannten Raytracer-Anwendung vorführen. Zunächst mit einem einzigen, anschließend mit je einem Thread pro Pixel-Zeile. (b) Aus mindestens wievielen Threads besteht jedes Programm? (c) Was macht der folgende Java-Code? Wie und wo wird hier ein Thread erzeugt? Wann enden beide Threads? public class HelloWorldRunner implements Runnable { public void run () { System . out . println ( " Hello World ! My Name is " + Thread . currentThread (). getName () ); } public static void main ( String [] args ) { Thread t = new Thread ( new HelloWorldRunner ()); t . start (); } } (d) Was ist der Unterschied, wenn anstelle von t.start() die Methode t.run() aufgerufen werden würde? (e) Laufen Threads grundsätzlich parallel ab? Dürfen Sie Annahmen über die Laufzeit eines Threads machen? (f) Kann die Ausführung von Threads jederzeit, d.h. insbesondere an jeder Stelle, unterbrochen werden? Betrachten Sie hierzu die Ausgabe des folgenden Beispielcodes: public class VerboseThread implements Runnable { private int id ; public VerboseThread ( int id ) { this . id = id ; } 1 @Override public void run () { while (! Thread . interrupted ()) { System . out . print ( " Thread " + id + " says : " ); System . out . println ( " I am thread " + id ); } } public static void main ( String [] args ) { Thread tOne = new Thread ( new VerboseThread (1)); Thread tTwo = new Thread ( new VerboseThread (2)); tOne . start (); tTwo . start (); // ... } } (g) Was machen die Methoden Thread.sleep(long millis), Thread.yield() und Thread.join()? Wozu dient in diesem Zusammenhang eine InterruptedException? (h) Sowohl Thread.sleep() als auch Thread.yield() sind statische Methoden. Auf welchen Thread wirkt sich der Aufruf dieser Methoden aus? Betrachten Sie hierzu beispielsweise die Methoden run() und main() aus Teilaufgabe (c). (i) Diskutieren Sie den folgenden Code-Ausschnitt. Was macht dieses Programm? Vergegenwärtigen Sie sich die verschiedenen Ausführungsstränge! Wie werden die Threads beendet? public class WaitThread implements Runnable { public void run () { while (! Thread . interrupted ()) { try { System . out . println ( " I am still alive . I will sleep for a second ... " ); // Sleep for _about_ a second . Thread . sleep (1000); } catch ( InterruptedException e ) { Thread . currentThread (). interrupt (); } } System . out . println ( " I ’ ve been interrupted and will terminate myself . " ); } public static void main ( String [] args ) { Thread t = new Thread ( new WaitThread ()); t . start (); try { Thread . sleep (3000); } catch ( InterruptedException e ) { } finally { // Tell the thread t to terminate . t . interrupt (); } } } 2 11.2 (Ü) Hallo Echo. . . Im Folgenden sollen Sie ein Programm schreiben, dass Ihnen zu einer Eingabe ein virtuelles Echo“ ” erzeugt. Die eingegebene Zeichenkette soll dabei sekündlich auf der Konsole wiederholt werden, wobei die Zeichenkette bei jeder Wiederholung um zwei Zeichen von vorne verkürzt wird. Beispielsweise soll die Eingabe Hallo Echo die folgende Ausgaben erzeugen: llo Echo o Echo Echo ho Echo von einer einzelnen Eingabe Um das Echo für eine Zeichenkette zu erzeugen, soll ein Thread verwendet werden. Die Implementierung soll dabei in der Klasse Echo erfolgen, die das Interface Runnable implementiert und deren Konstruktor die zu wiederholende Zeichenkette übergeben bekommt. In der Methode run() soll der Thread jeweils eine Sekunde pausieren, bevor die nächste verkürzte Zeichenkette ausgegeben wird. Sobald die Zeichenkette soweit verkürzt ist, dass nichts mehr ausgegeben wird, soll sich der Thread von selbst beenden. Der Rumpf für das Einlesen einer Zeichenkette ist bereits in der main()-Methode vorhanden. Nach dem Einlesen soll für die eingelesene Zeichenkette der Echo-Thread erzeugt und gestartet werden. Testen Sie Ihr Programm nun mit verschiedenen Eingaben. Echo von mehreren Eingaben Nun soll das Programm so erweitert werden, dass auch mehrere Eingaben hintereinander erlaubt sind, für die jeweils ein entsprechendes Echo erzeugt wird. Wenn die Eingaben schnell genug hintereinander stattfinden, sollte dies zu einer Verschachtelung der Echos führen. Ändern Sie die main()-Methode so ab, dass solange Zeichenketten eingelesen und der dazugehörige Thread erzeugt und gestartet werden, bis der Benutzer stop“ eingibt. ” Testen Sie wiederum ihr Programm mit verschiedenen Eingaben und prüfen Sie, ob Ihr Programm beendet wird, sobald Sie stop“ eingeben. ” Beenden aller Threads Falls Sie stop“ eingegeben haben, während ein Echo noch wiederholt wurde, werden Sie bemerkt ” haben, dass zwar die main()-Methode dadurch beendet wurde, die übrigen Threads aber solange fortgesetzt wurden, bis sämtliche Echo-Threads vollständig ausgeführt wurden. Ändern Sie Ihr Programm nun so ab, dass bei der Eingabe von stop“ alle Threads inklusive der ” Echos unmittelbar beendet werden. Merken Sie sich dazu alle erzeugten Echo-Threads in einer Liste und teilen Sie beim Beenden des Programms allen Threads mit Hilfe der Methode interrupt() mit, dass sie sich selbst beenden sollen. Ändern Sie die Methode run() entsprechend so ab, dass der Thread sich nach dem Interrupt selbst beendet. 11.3 (Ü) Synchronisation (a) Welche Werte können Threads untereinander lesen und schreiben? 3 (b) Worauf muss beim Zugriff auf gemeinsam genutzte Variablen und Objekte geachtet werden? (c) Was bezeichnet der Begriff Race Condition? Nennen Sie Beispiele für Fehler, die in diesem Zusammenhang auftreten können. (d) Was bezeichnet hierbei einen kritischen Abschnitt? Wie werden die kritschen Abschnitte in den beiden nachfolgenden Beispielen geschützt? public class TicketBooking implements Runnable { protected static int globalTicketCount = 10; protected int ticketsToBuy ; public TicketBooking ( int numTicketsToBuy ) { ticketsToBuy = numTicketsToBuy ; } protected static synchronized boolean buyTickets ( int numTickets ) { if ( globalTicketCount >= numTickets ) { globalTicketCount -= numTickets ; return true ; } return false ; } public void run () { if ( ! buyTickets ( ticketsToBuy ) ) System . out . println ( " Sorry , not enough tickets available ! " ); } public static void main ( String [] args ) { Thread t1 = new Thread ( new TicketBooking (4) ); Thread t2 = new Thread ( new TicketBooking (3) ); t1 . start (); t2 . start (); } } class TicketBooking2 implements Runnable { protected static Integer globalTicketCount = 10; protected int ticketsToBuy ; public TicketBooking2 ( int numTicketsToBuy ) { ticketsToBuy = numTicketsToBuy ; } public void run () { synchronized ( globalTicketCount ) { if ( globalTicketCount >= ticketsToBuy ) { globalTicketCount -= ticketsToBuy ; } else { System . out . println ( " Sorry , not enough tickets available ! " ); } } } 4 public static void main ( String [] args ) { Thread t1 = new Thread ( new TicketBooking2 (4) ); Thread t2 = new Thread ( new TicketBooking2 (3) ); t1 . start (); t2 . start (); } } 11.4 (Ü) Kekse In dieser Aufgabe sollen Sie ein einfaches Producer-Consumer -Szenario realisieren. Zunächst benötigen Sie Objekte, die produziert und konsumiert werden können: Erstellen Sie dazu eine Klasse Cookie mit einer Methode public void eat(). Nun brauchen Sie Producer- und Consumer-Threads: Legen Sie die Klassen Mother und Child an, die das Runnable-Interface implementieren. Beide Klassen erhalten in ihrem Konstruktor eine Referenz auf denselben Stapel Cookies, welche sie in einer Membervariable speichern. Beide Klassen enthalten in ihrer run()-Methode eine Endlosschleife. Die Mother-Klasse testet dabei immer wieder, ob weniger als z.B. 30 Plätzchen vorhanden sind. Wenn dem so ist, erzeugt sie ein neues Plätzchen und legt es auf den gemeinsamen Stapel. Die Child-Klasse testet immer wieder, ob mindestens ein Plätzchen auf dem Stapel liegt. Wenn ja, wird es vom Stapel genommen und gegessen. Legen Sie eine main()-Methode an. Legen Sie dort einen leeren Stapel mit Keksen an. Legen Sie einen Mutter- und drei Kinderthreads an, die alle Zugriff auf denselben Keksstapel haben. Starten Sie alle 4 Threads. Einer oder mehrere Kind-Threads werden recht schnell mit einer EmptyStackException abstürzen. Warum? Reparieren Sie das Programm, so dass endlos Plätzchen produziert und konsumiert werden, ohne dass ein Thread abstürzt. 5 11.5 (H) Threads beim Raytracing (+++) In den bisherigen Aufgaben mit dem Raytracer wurde ein einzelner Thread dafür verwendet, das Bild zu erzeugen und es in einem Fenster anzuzeigen. Entsprechend wurde das Bild nicht angezeigt, bis das Bild komplett erstellt wurde. Da der Raytracer allerdings die Farbwerte der einzelnen Pixel nach und nach bestimmt, wäre es viel schöner, wenn man diesen Fortschritt direkt am Bild mitverfolgen könnte. In den nachfolgenden Teilaufgaben sollen Sie dies mit Hilfe von Threads realisieren und MultiThreading anschließend dazu nutzen, dass Rendern der Bilder zu beschleunigen. Hinweis: Um die Reihenfolge der Erzeugung und Beendigung aller Threads auf der Konsole nachverfolgen zu können, muss bei allen Threads in dieser Aufgabe auf folgende Punkte geachtet werden: • Jeder Thread, der erzeugt wird, muss nach der Klasse benannt werden, deren run()-Methode im Thread ausgeführt wird. Werden mehrere Threads gleichen Typs erzeugt, müssen die Threads von 0 beginnend durchnummeriert werden, z.B. RenderLineWorker 0“ ” • Als erster Befehl in jeder run()-Methode soll auf der Konsole ausgegeben werden, dass der Thread mit dem entsprechenden Namen jetzt startet, z.B. “RenderWorker starts...”. • Als letzter Befehl in jeder run-Methode, soll auf der Konsole ausgegeben werden, dass der Thread mit dem entsprechenden Namen jetzt beendet wird, z.B. RenderPoolWorker 2 ” ends...“. RenderWorker Anstatt die Methode renderImage(...) direkt in der main()-Methode von ThreadedRaytracer aufzurufen, soll die Erzeugung des Bildes in einen eigenen Thread ausgelagert werden. Erstellen Sie dafür eine Klasse RenderWorker, welche das Interface Runnable implementiert. Der Konstruktor soll eine Referenz auf den dazugehörigen ThreadedRaytracer als Parameter erhalten und diese in einer protected Member-Variablen speichern. Die Methode run() soll den Aufruf zum Rendern des Bildes aus der main-Methode enthalten. Dort soll stattdessen ein Thread erzeugt und gestartet werden, der dann den RenderWorker ausführt. Sollten Sie das Programm nun ausführen, werden Sie feststellen, dass weiterhin keine Zwischenergebnisse angezeigt werden. Noch schlimmer: Auch im Anschluss an die Generierung des Bildes wird das Bild nicht ohne Weiteres im Fenster angezeigt. Das liegt daran, dass die Methode paint(), welche das Bild im Fenster anzeigt, lediglich ein einziges Mal beim Erstellen des Fensters aufgerufen wird. Damit die Anzeige aktualisiert wird, muss das Fenster mit Hilfe der Methode repaint() dazu veranlasst werden. Eine Möglichkeit besteht darin, ein repaint() im RenderWorker im Anschluss an die Berechnung des Bildes zu erzwingen. Dadurch werden allerdings nachwievor keine Zwischenergebnisse angezeigt. Stattdessen sollen Sie als nächstes einen Thread schreiben, der in Abständen von 0,1 Sekunden das JFrame zu einer Aktualisierung veranlaßt. FrameRepainter Erstellen Sie dazu eine neue Klasse FrameRepainter, die vom Interface Runnable abgeleitet werden soll. Dem Konstruktor soll sowohl eine Referenz auf den ThreadedRaytracer als auch eine Referenz auf den Thread mit dem RenderWorker übergeben werden. Beide Referenzen sollen in protected Member-Variablen gespeichert werden. 6 In der Methode run() soll alle 0,1 Sekunden das Frame neu gezeichnet werden. Sobald der Thread mit dem RenderWorker beendet und das fertige Bild im Fenster angezeigt wird oder vorher ein interrupt() auftritt, soll sich der Thread von selbst beenden. Erstellen und starten Sie in der main()-Methode einen Thread, der den FrameRepainter ausführt, um das Bild fortwährend zu aktualisieren. Kontrollieren Sie, ob Sie den Fortschritt nun tatsächlich während des Renderns mitverfolgen können. Multi-Threading beim Raytracing Bis hierhin wurden Threads nur dafür verwendet, dass der Fortschritt beim Raytracing auch tatsächlich im Fenster sichtbar ist. Darüber hinaus kann Multi-Threading auch dafür verwendet werden, die Berechnung des Bildes zu beschleunigen. Hierbei kann der Umstand genutzt werden, dass die Berechnung der einzelnen Pixels im Bild unabhängig voneinander möglich ist. Dadurch ist Raytracing ideal zur Parallelisierung geeignet. Der einzige kritische Abschnitt beim Raytracing besteht darin, wenn ein berechneter Farbwert tatsächlich in einem Pixel gesetzt wird. Da man grundsätzlich nicht davon ausgehen darf, dass das Setzen der einzelnen Pixel vollkommen unabhängig voneinander erfolgt, muss dieser Schreibzugriff synchronisiert werden. SynchronizedImage Erstellen Sie hierfür eine Klasse SynchronizedImage, welche im Konstruktor eine Referenz auf ein BufferedImage erhält und diese als protected Member-Variable speichert. Mit Hilfe der Methode writePixel(int x, int y, Color c) soll man in diesem Bild das entsprechende Pixel an der Stelle (x,y) auf die Farbe c setzen können. Das Setzen des Pixels mit dieser Methode soll so synchronisiert sein, dass während des Setzen eines Pixels keine weiteren Pixel verändert werden können. ThreadedCamera Es gibt verschiedenste Möglichkeiten, das Berechnen der Pixel auf Threads zu verteilen und wieviele Threads dabei verwendet werden. Unabhängig davon besitzen all diese Möglichkeiten sehr viele Gemeinsamkeiten, die in der abstrakten Klasse ThreadedCamera gekapselt sind. Eine MultiThread-Kamera, die von dieser Oberklasse abgeleitet ist, muss lediglich die Methode startRenderThreads() implementieren, welche die Threads zur Generierung des Bildes erstellt und startet. Wie in der Klasse ThreadedCamera soll bei jeder abgeleiteten Klasse der Parameter field-of-view der Kamera durch den Konstruktor festgelegt werden. Das tatsächliche Rendering wird mit Hilfe von Klassen realisiert, die vom Interface Runnable abgeleitet werden. Zum Berechnen der Farbwerte eines einzelnen Pixels soll die Methode renderPixel() in ThreadedCamera benutzt werden. Machen Sie sich mit dieser und den anderen Methoden der Klasse vertraut, indem Sie die Kommentare lesen und den Code nachzuvollziehen versuchen. LineThreadCamera Eine triviale Idee zur Aufteilung des Renderings könnte darin bestehen, für jede einzelne Zeile im Bild einen eigenen Thread zu starten. Dieser Ansatz soll mit Hilfe der Klasse LineThreadCamera extends ThreadedCamera und der Klasse RenderLineWorker implements Runnable realisiert werden. Jede Instanz von RenderLineWorker soll im Konstruktor eine Referenz auf die dazugehörige 7 LineThreadCamera sowie die Nummer der vom Thread zu berechnenden Zeile übergeben bekommen. In der Methode run() soll der entsprechende Thread alle Pixel in dieser Zeile berechnen und sich danach von selbst beenden. Bei einem interrupt() soll der Thread keine weiteren Pixel rendern, sondern sich von selbst beenden. Die Threads für die einzelnen Zeilen sollen von der Methode startRenderThreads() erstellt und gestartet und als Liste zurückgegeben werden. Ersetzen Sie im ThreadedRaytracer die alte Kamera durch Ihre neu erstellte Kamera und starten Sie Ihr Programm. Vergleich der Rechenzeit In dem von Ihnen zu erweiternden Projekt wird bereits die Zeit gemessen, die das Erstellen des Bildes in Anspruch nimmt. Wieviel Zeit benötigt das Rendern mit der ursprünglichen Camera und wieviel Zeit mit Ihrer neu erstellten LineThreadCamera? Notieren Sie in einem Kommentar die von Ihnen gemessenen Zeiten und welche Szene Sie für Ihren Test verwendet haben. Erklären Sie zusätzlich in ein bis drei Sätzen, wie dieser Unterschied zustandekommt. MultiThreadCamera Wenn das Berechnen der Pixel möglichst gleichmäßig auf eine bestimmte Anzahl von Threads aufgeteilt werden soll, liegt es nahe, dass jeder Thread dieselbe Anzahl von Pixeln berechnen soll. Dieser Ansatz soll mit Hilfe der Klasse MultiThreadCamera extends ThreadedCamera und der Klasse RenderSameNumPixelWorker implements Runnable realisiert werden. Als Konstruktor soll die MultiThreadCamera zusätzlich zum field-of-view die Anzahl der Threads übergeben bekommen, die zum Rendern genutzt werden sollen. Um die Pixel gleichmäßig auf die Threads aufteilen zu können, ist es einfacher wenn die Pixel mit einem Index statt mit Hilfe ihrer (x, y)-Koordinate addressiert werden können. Erstellen Sie in der Klasse MultiThreadCamera dazu eine Methode renderPixel(int pixelIdx) die einen Pixel-Index automatisch in eine (x, y)Koordinate umgerechnet und den Farbwert für das entsprechende Pixel bestimmt. Die Klasse RenderSameNumPixelWorker soll einen Konstruktor mit den Parametern MultiThreadCamera camera, int firstPixel und int lastPixel zur Verfügung stellen. Die Methode run() soll dafür sorgen, dass alle Pixel mit einem Index i (f irstP ixel ≤ i < lastP ixel) berechnet werden1 . Wie bei der LineThreadCamera soll man die Render-Threads durch ein interrupt() unterbrechen können und in der Methode startRenderThreads() soll die entsprechende Anzahl der Threads erzeugt und jedem ein möglichst gleich großer Bereich von Pixeln übergeben werden. Testen Sie Ihre neue Kamera und vergleichen Sie die Zeit zur Berechnung des Bildes mit den vorherigen Kamera-Implementierungen. ThreadPoolCamera Die MultiThreadCamera versucht die Arbeitslast des Renderns dadurch möglichst gerecht zu verteilen, dass jeder Thread dieselbe Anzahl an Pixeln zu berechnen hat. Allerdings kann das Rendern von einzelnen Pixeln unterschiedlich aufwändig sein2 . Dies kann bei der MultiThreadCamera dazu führen, dass einige Threads bereits beendet sind, während andere Threads weiterhin mit dem Rendern aufwändigerer Bereiche zu tun haben. 1 Mit anderen Worten ist firstPixel das erste Pixel, der vom Thread gerendert wird und lastPixel das erste Pixel, der nicht mehr vom Thread berechnet wird. 2 Zum Beispiel, weil an spiegelnden Materialien sogenannte Sekundär-Strahlen rekursiv weiter verfolgt werden müssen. 8 Eine gerechtere Verteilung der Arbeitslast kann dadurch erreicht werden, dass das Bild nicht in fixe Bereiche pro Thread aufgeteilt wird, sondern dass das Bild in kleinere Bereich (z.B. Zeilen) zerlegt wird. Immer wenn ein Thread einen solchen Bereich erfolgreich bearbeitet hat, bekommt er den nächsten zu bearbeitenden Bereich zugeteilt, bis die gesamte Arbeitslast komplett auf die Threads aufgeteilt wurde. Diesen Ansatz sollen Sie mit Hilfe der Klassen ThreadPoolCamera extends ThreadedCamera und der Klasse RenderPoolWorker implements Runnable implementieren. Der Konstruktor der ThreadPoolCamera soll wieder den field-of-view und die Anzahl der zu verwendenen Threads übergeben bekommen. Damit die Threads die Nummer der nächsten zu rendernden Zeile anfordern können, soll die KameraKlasse zusätzlich die Methode getNextLine() zur Verfügung stellen. Achten Sie darauf, dass zwei Threads niemals dieselbe Zeilennummer zugewiesen werden kann. Falls bereits alle Zeilen vergeben wurden, soll die Methode den Wert −1 zurückgeben. Die Klasse RenderPoolWorker benötigt im Konstruktor lediglich eine Referenz auf die dazugehörige ThreadPoolCamera. In der Methode run() soll jeder Thread fortwährend eine neue Zeilennummer anfordern und anschließend die entsprechenden Pixel berechnen. Sobald es keine zu rendernden Zeilen mehr gibt oder der Thread ein interrupt() erhält, soll der Thread umgehend beendet werden. In der Methode startRenderThreads() soll wie üblich die entsprechende Anzahl der Threads erzeugt und gestartet werden. Testen Sie Ihre neue Klasse mit verschiedenen Szenen und einer unterschiedlichen Anzahl an Threads und beobachten Sie wie die Zeit zum Generieren des Bildes davon abhängt. 9