Informatik im Fokus
Herausgeber: Prof. Dr. O. Günther Prof. Dr. W. Karl Prof. Dr. R. Lienhart Prof. Dr. K. Zeppenfeld
Informatik im Fokus
Rauber, T.; Rünger, G. Multicore: Parallele Programmierung. 2008 El Moussaoui, H.; Zeppenfeld, K. AJAX. 2008 Behrendt, J.; Zeppenfeld, K. Web 2.0. 2008 Bode, A.; Karl, W. Multicore-Architekturen. 2008
Thomas Rauber · Gudula Rünger
Multicore: Parallele Programmierung
123
Prof. Dr. Thomas Rauber Universität Bayreuth LS Angewandte Informatik II Universitätsstr. 30 95447 Bayreuth
[email protected]
Prof. Dr. Gudula Rünger TU Chemnitz Fakultät für Informatik Straße der Nationen 62 09107 Chemnitz
[email protected]
Herausgeber: Prof. Dr. O. Günther Humboldt Universität zu Berlin
Prof. Dr. R. Lienhart Universität Augsburg
Prof. Dr. W. Karl Universität Karlsruhe (TH)
Prof. Dr. K. Zeppenfeld Fachhochschule Dortmund
ISBN 978-3-540-73113-9
e-ISBN 978-3-540-73114-6
DOI 10.1007/978-3-540-73114-6 ISSN 1865-4452 Bibliografische Information der Deutschen Nationalbibliothek Die Deutsche Nationalbibliothek verzeichnet diese Publikation in der Deutschen Nationalbibliografie; detaillierte bibliografische Daten sind im Internet über http://dnb.d-nb.de abrufbar. © 2008 Springer-Verlag Berlin Heidelberg Dieses Werk ist urheberrechtlich geschützt. Die dadurch begründeten Rechte, insbesondere die der Übersetzung, des Nachdrucks, des Vortrags, der Entnahme von Abbildungen und Tabellen, der Funksendung, der Mikroverfilmung oder der Vervielfältigung auf anderen Wegen und der Speicherung in Datenverarbeitungsanlagen, bleiben, auch bei nur auszugsweiser Verwertung, vorbehalten. Eine Vervielfältigung dieses Werkes oder von Teilen dieses Werkes ist auch im Einzelfall nur in den Grenzen der gesetzlichen Bestimmungen des Urheberrechtsgesetzes der Bundesrepublik Deutschland vom 9. September 1965 in der jeweils geltenden Fassung zulässig. Sie ist grundsätzlich vergütungspflichtig. Zuwiderhandlungen unterliegen den Strafbestimmungen des Urheberrechtsgesetzes. Die Wiedergabe von Gebrauchsnamen, Handelsnamen, Warenbezeichnungen usw. in diesem Werk berechtigt auch ohne besondere Kennzeichnung nicht zu der Annahme, dass solche Namen im Sinne der Warenzeichen- und Markenschutz-Gesetzgebung als frei zu betrachten wären und daher von jedermann benutzt werden dürften. Text und Abbildungen wurden mit größter Sorgfalt erarbeitet. Verlag und Autor können jedoch für eventuell verbliebene fehlerhafte Angaben und deren Folgen weder eine juristische Verantwortung noch irgendeine Haftung übernehmen. Einbandgestaltung: KünkelLopka Werbeagentur, Heidelberg Gedruckt auf säurefreiem Papier 987654321 springer.com
Vorwort
Nach vielen Jahren stetigen technologischen Fortschritts in der Mikroprozessorentwicklung stellt die Multicore-Technologie die neueste Entwickungsstufe dar. F¨ uhrende Hardwarehersteller wie Intel, AMD, Sun oder IBM liefern seit 2005 Mikroprozessoren mit mehreren unabh¨ angigen Prozessorkernen auf einem einzelnen Prozessorchip. Im Jahr 2007 verwendet ein typischer Desktop-PC je nach Ausstattung einen Dualcore- oder Quadcore-Prozessor mit zwei bzw. vier Prozessorkernen. Die Ank¨ undigungen der Prozessorhersteller zeigen, dass dies erst der Anfang einer l¨anger andauernden Entwicklung ist. Eine Studie von Intel prognostiziert, dass im Jahr 2015 ein typischer Prozessorchip aus Dutzenden bis Hunderten von Prozessorkernen besteht, die zum Teil spezialisierte Aufgaben wie Verschl¨ usselung, Grafikdarstellung oder Netzwerkmanagement wahrnehmen. Ein Großteil der Prozessorkerne steht aber f¨ ur Anwendungsprogramme zur Verf¨ ugung und kann z.B. f¨ ur B¨ uro- oder Unterhaltungssoftware genutzt werden. Die von der Hardwareindustrie vorgegebene Entwicklung hin zu Multicore-Prozessoren bietet f¨ ur die Software-
VI
Vorwort
entwickler also neue M¨ oglichkeiten, die in der Bereitstellung zus¨ atzlicher Funktionalit¨ aten der angebotenen Software liegen, die parallel zu den bisherigen Funktionalit¨aten ausgef¨ uhrt werden k¨ onnen, ohne dass dies beim Nutzer zu Wartezeiten f¨ uhrt. Diese Entwicklung stellt aber auch einen Paradigmenwechsel in der Softwareentwicklung dar, weg von der herk¨ ommlichen sequentiellen Programmierung hin zur parallelen oder Multithreading-Programmierung. Beide Programmierformen sind nicht neu. Der Paradigmenwechsel besteht eher darin, dass diese Programmiertechniken bisher nur in speziellen Bereichen eingesetzt wurden, nun aber durch die Einf¨ uhrung von Multicore-Prozessoren in alle Bereiche der Softwareentwicklung getragen werden und so f¨ ur viele Softwareentwickler eine neue Herausforderung entsteht. Das Ziel dieses Buches ist es, dem Leser einen ersten Einblick in die f¨ ur Multicore-Prozessoren geeigneten parallelen Programmiertechniken und -systeme zu geben. Programmierumgebungen wie Pthreads, Java-Threads und OpenMP werden vorgestellt. Die Darstellung geht dabei davon aus, dass der Leser mit Standardtechniken der Programmierung vertraut ist. Das Buch enth¨ alt zahlreiche Hinweise auf weiterf¨ uhrende Literatur sowie neuere Entwicklungen wie etwa neue Programmiersprachen. F¨ ur Hilfe bei der Erstellung des Buches und Korrekturen danken wir J¨org D¨ ummler, Monika Glaser, Marco H¨ obbel, Raphael Kunis und Michael Schwind. Dem Springer-Verlag danken wir f¨ ur die gute Zusammenarbeit.
Bayreuth, Chemnitz, August 2007
Thomas Rauber Gudula R¨ unger
Inhaltsverzeichnis
1
Kurz¨ uberblick Multicore-Prozessoren . . . . . . 1 1.1 Entwicklung der Mikroprozessoren . . . . . . . . . 1 1.2 Parallelit¨ at auf Prozessorebene . . . . . . . . . . . . 4 1.3 Architektur von Multicore-Prozessoren . . . . . 8 1.4 Beispiele . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 13
2
Konzepte paralleler Programmierung . . . . . . 2.1 Entwurf paralleler Programme . . . . . . . . . . . . 2.2 Klassifizierung paralleler Architekturen . . . . . 2.3 Parallele Programmiermodelle . . . . . . . . . . . . . 2.4 Parallele Leistungsmaße . . . . . . . . . . . . . . . . . .
21 22 27 29 35
3
Thread-Programmierung . . . . . . . . . . . . . . . . . . 3.1 Threads und Prozesse . . . . . . . . . . . . . . . . . . . . 3.2 Synchronisations-Mechanismen . . . . . . . . . . . . 3.3 Effiziente und korrekte Thread-Programme . 3.4 Parallele Programmiermuster . . . . . . . . . . . . . 3.5 Parallele Programmierumgebungen . . . . . . . .
39 39 46 51 54 61
VIII
Inhaltsverzeichnis
4
Programmierung mit Pthreads . . . . . . . . . . . . 4.1 Threaderzeugung und -verwaltung . . . . . . . . . 4.2 Koordination von Threads . . . . . . . . . . . . . . . . 4.3 Bedingungsvariablen . . . . . . . . . . . . . . . . . . . . . 4.4 Erweiterter Sperrmechanismus . . . . . . . . . . . . 4.5 Implementierung eines Taskpools . . . . . . . . . .
63 63 66 70 75 78
5
Java-Threads . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 85 5.1 Erzeugung von Threads in Java . . . . . . . . . . . 85 5.2 Synchronisation von Java-Threads . . . . . . . . . 91 5.3 Signalmechanismus in Java . . . . . . . . . . . . . . . 101 5.4 Erweiterte Synchronisationsmuster . . . . . . . . . 109 5.5 Thread-Scheduling in Java . . . . . . . . . . . . . . . . 113 5.6 Paket java.util.concurrent . . . . . . . . . . . . 115
6
OpenMP . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 125 6.1 Programmiermodell . . . . . . . . . . . . . . . . . . . . . . 125 6.2 Spezifikation der Parallelit¨ at . . . . . . . . . . . . . . 127 6.3 Koordination von Threads . . . . . . . . . . . . . . . . 139
7
Weitere Ans¨ atze . . . . . . . . . . . . . . . . . . . . . . . . . . . 145 7.1 Sprachans¨ atze . . . . . . . . . . . . . . . . . . . . . . . . . . . 146 7.2 Transaktionsspeicher . . . . . . . . . . . . . . . . . . . . . 150
Literatur . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 155 Index . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 161
1 Kurzu ¨ berblick Multicore-Prozessoren
Die Entwicklung der Mikroprozessoren hat in den letzten Jahrzehnten durch verschiedene technologische Innovationen immer leistungsst¨ arkere Prozessoren hervorgebracht. Multicore-Prozessoren stellen einen weiteren Meilenstein in der Entwicklung dar.
1.1 Entwicklung der Mikroprozessoren Prozessorchips sind intern aus Transistoren aufgebaut, deren Anzahl ein ungef¨ ahres Maß f¨ ur die Komplexit¨at und Leistungsf¨ ahigkeit des Prozessors ist. Das auf empirischen Beobachtungen beruhende Gesetz von Moore besagt, dass die Anzahl der Transistoren eines Prozessorchips sich alle 18 bis 24 Monate verdoppelt. Diese Beobachtung wurde 1965 von Gordon Moore zum ersten Mal gemacht und gilt nun seit u ¨ ber 40 Jahren. Ein typischer Prozessorchip aus dem Jahr 2007 besteht aus ca. 200-400 Millionen Transistoren: beispielsweise besteht ein Intel Core 2 Duo Prozessor
2
1 Kurz¨ uberblick Multicore-Prozessoren
aus ca. 291 Millionen Transistoren, ein IBM Cell-Prozessor aus ca. 250 Millionen Transistoren. Die Erh¨ ohung der Transistoranzahl ging in der Vergangenheit mit einer Erh¨ ohung der Taktrate einher. Dies steigerte die Verarbeitungsgeschwindigkeit der Prozessoren und die Taktrate eines Prozessors wurde oftmals als alleiniges Merkmal f¨ ur dessen Leistungsf¨ ahigkeit wahrgenommen. Gemeinsam f¨ uhrte die Steigerung der Taktrate und der Transistoranzahl zu einer durchschnittlichen j¨ahrlichen Leistungssteigerung der Prozessoren von ca. 55% (bei Integer-Operationen) bzw. 75% (bei Floating-PointOperationen), was durch entsprechende Benchmark-Programme gemessen wurde, siehe [32] und www.spec.org f¨ ur eine Beschreibung der oft verwendeten SPEC-Benchmarks. Eine Erh¨ ohung der Taktrate im bisherigen Umfang ist jedoch f¨ ur die Zukunft nicht zu erwarten. Dies ist darin begr¨ undet, dass mit einer Erh¨ ohung der Taktrate auch die Leistungsaufnahme, also der Energieverbrauch des Prozessors, ansteigt, wobei ein Großteil des verbrauchten Stroms in W¨ arme umgewandelt wird und u ufter abgef¨ uhrt ¨ ber L¨ werden muss. Das Gesetz von Moore scheint aber bis auf weiteres seine G¨ ultigkeit zu behalten. Die steigende Anzahl verf¨ ugbarer Transistoren wurde in der Vergangenheit f¨ ur eine Vielzahl weiterer architektonischer Verbesserungen genutzt, die die Leistungsf¨ahigkeit der Prozessoren erheblich gesteigert hat. Dazu geh¨oren u.a. • • •
die Erweiterung der internen Wortbreite auf 64 Bits, die Verwendung interner Pipelineverarbeitung f¨ ur die ressourcenoptimierte Ausf¨ uhrung aufeinanderfolgender Maschinenbefehle, die Verwendung mehrerer Funktionseinheiten, mit denen voneinander unabh¨ angige Maschinenbefehle parallel zueinander abgearbeitet werden k¨ onnen und
1.1 Entwicklung der Mikroprozessoren
•
3
die Vergr¨ oßerung der prozessorlokalen Cachespeicher.
Wesentliche Aspekte der Leistungssteigerung sind also die Erh¨ ohung der Taktrate und der interne Einsatz paralleler Abarbeitung von Instruktionen, z.B. durch das Duplizieren von Funktionseinheiten. Die Grenzen beider Entwicklungen sind jedoch abzusehen: Ein weiteres Duplizieren von Funktionseinheiten und Pipelinestufen ist zwar m¨oglich, bringt aber wegen vorhandener Abh¨ angigkeiten zwischen den Instruktionen kaum eine weitere Leistungssteigerung. Gegen eine weitere Erh¨ ohung der prozessoreigenen Taktrate sprechen mehrere Gr¨ unde [36]: •
•
•
Ein Problem liegt darin, dass die Speicherzugriffsgeschwindigkeit nicht im gleichen Umfang wie die Prozessorgeschwindigkeit zunimmt, was zu einer Erh¨ohung der Zyklenanzahl pro Speicherzugriff f¨ uhrt. So brauchte z.B. um 1990 ein Intel i486 f¨ ur einen Zugriff auf den Hauptspeicher zwischen 6 und 8 Maschinenzyklen, w¨ ahrend 2006 ein Intel Pentium Prozessor u ¨ ber 220 Zyklen ben¨ otigt. Die Speicherzugriffszeiten stellen daher einen kritischen limitierenden Faktor f¨ ur eine weitere Leistungssteigerung dar. Zum Zweiten wird die Erh¨ ohung der Transistoranzahl durch eine erh¨ ohte Packungsdichte erreicht, mit der aber auch eine gesteigerte W¨ armeentwicklung pro Fl¨acheneinheit verbunden ist. Diese wird zunehmend zum Problem, da die notwendige K¨ uhlung entsprechend aufwendiger wird. Zum Dritten w¨ achst mit der Anzahl der Transistoren auch die prozessorinterne Leitungsl¨ ange f¨ ur den Signaltransport, so dass die Signallaufzeit eine wichtige Rolle spielt. Dies sieht man an folgender Berechnung: Ein mit 3GHz getakteter Prozessor hat eine Zykluszeit von 0.33 ns = 0.33 ·10−9 sec. In dieser Zeit kann ein Signal eine
4
1 Kurz¨ uberblick Multicore-Prozessoren
Entfernung von 0.33 ·10−9 s·0.3 · 109m/s ≈ 0.1m zur¨ ucklegen, wenn wir die Lichtgeschwindigkeit im Vakuum als ¨ Signalgeschwindigkeit ansetzen. Je nach Ubergangsmedium ist die Signalgeschwindigkeit sogar deutlich niedriger. Damit k¨ onnen die Signale in einem Takt nur eine relativ geringe Entfernung zur¨ ucklegen, so dass der Layout-Entwurf der Prozessorchips entsprechend gestaltet werden muss. Um eine weitere Leistungssteigerung der Prozessoren im bisherigen Umfang zu erreichen, setzen die Prozessorhersteller auf eine explizite Parallelverarbeitung innerhalb eines Prozessors, indem mehrere logische Prozessoren von einem physikalischen Prozessor simuliert werden oder mehrere vollst¨ andige, voneinander nahezu unabh¨ angige Prozessorkerne auf einen Prozessorchip platziert werden. Der Einsatz expliziter Parallelverarbeitung innerhalb eines Prozessors hat weitreichende Konsequenzen f¨ ur die Programmierung: soll ein Programm von der verf¨ ugbaren Leistung des Multicore-Prozessors profitieren, so muss es die verf¨ ugbaren Prozessorkerne entsprechend steuern und effizient ausnutzen. Dazu werden Techniken der parallelen Programmierung eingesetzt. Da die Prozessorkerne eines Prozessorchips ein gemeinsames Speichersystem nutzen, sind Programmierans¨ atze f¨ ur gemeinsamen Adressraum geeignet.
1.2 Parallelit¨ at auf Prozessorebene Explizite Parallelit¨ at auf Prozessorebene wird durch eine entsprechende Architekturorganisation des Prozessorchips erreicht. Eine M¨ oglichkeit ist die oben erw¨ ahnte Platzierung mehrerer Prozessorkerne mit jeweils unabh¨ angigen Ausf¨ uhrungseinheiten auf einem Prozessorchip, was als Multicore-
1.2 Parallelit¨ at auf Prozessorebene
5
Prozessor bezeichnet wird. Ein anderer Ansatz besteht darin, mehrere Kontrollfl¨ usse dadurch gleichzeitig auf einem Prozessor oder Prozessorkern auszuf¨ uhren, dass der Prozessor je nach Bedarf per Hardware zwischen den Kontrollfl¨ ussen umschaltet. Dies wird als simultanes Multithreading (SMT) oder Hyperthreading (HT) bezeichnet [43]. Bei dieser Art der Parallelit¨ at werden die Kontrollfl¨ usse oft als Threads bezeichnet. Dieser Begriff und die Unterschiede zu Prozessen werden in den folgenden Abschnitten n¨ aher erl¨ autert; zun¨ achst reicht es aus, einen Thread als Kontrollfluss anzusehen, der parallel zu anderen Threads desselben Programms ausgef¨ uhrt werden kann. Simultanes Multithreading (SMT) Simultanes Multithreading basiert auf dem Duplizieren des Prozessorbereiches zur Ablage des Prozessorzustandes auf der Chipfl¨ ache des Prozessors. Zum Prozessorzustand geh¨oren die Benutzer- und Kontrollregister sowie der InterruptController mit seinen zugeh¨ origen Registern. Damit verh¨alt sich der physikalische Prozessor aus der Sicht des Betriebssystems und des Benutzerprogramms wie zwei logische Prozessoren, denen Prozesse oder Threads zur Ausf¨ uhrung zugeordnet werden k¨ onnen. Diese k¨onnen von einem oder mehreren Anwendungsprogrammen stammen. Jeder logische Prozessor legt seinen Prozessorzustand in einem separaten Prozessorbereich ab, so dass beim Wechsel zu einem anderen Thread kein aufwendiges Zwischenspeichern des Prozessorzustandes im Speichersystem erforderlich ist. Die logischen Prozessoren teilen sich fast alle Ressourcen des physikalischen Prozessors wie Caches, Funktions- und Kontrolleinheiten oder Bussystem. Die Realisierung der SMT-Technologie erfordert daher nur eine ge-
6
1 Kurz¨ uberblick Multicore-Prozessoren
ringf¨ ugige Vergr¨ oßerung der Chipfl¨ ache. F¨ ur zwei logische Prozessoren w¨ achst z.B. f¨ ur einen Intel Xeon Prozessor die erforderliche Chipfl¨ ache um weniger als 5% [44, 67]. Die gemeinsamen Ressourcen des Prozessorchips werden den logischen Prozessoren reihum zugeteilt, so dass die logischen Prozessoren simultan zur Verf¨ ugung stehen. Treten bei einem logischen Prozessor Wartezeiten auf, k¨onnen die Ausf¨ uhrungs-Ressourcen dem anderen logischen Prozessor zugeordnet werden, so dass aus der Sicht des physikalischen Prozessors eine verbesserte Nutzung der Ressourcen gew¨ ahrleistet ist. Untersuchungen zeigen, dass die verbesserte Nutzung der Ressourcen durch zwei logische Prozessoren je nach Anwendungsprogramm Laufzeitverbesserungen zwischen 15% und 30% bewirken kann [44]. Da alle Ressourcen des Chips von den logischen Prozessoren geteilt werden, ist beim Einsatz von wesentlich mehr als zwei logischen Prozessoren f¨ ur die meisten Einsatzgebiete keine weitere signifikante Laufzeitverbesserung zu erwarten. Der Einsatz simultanen Multithreadings wird daher voraussichtlich auf wenige logische Prozessoren beschr¨ ankt bleiben. Zum Erreichen einer Leistungsverbesserung durch den Einsatz der SMT-Technologie ist es erforderlich, dass das Betriebssystem in der Lage ist, die logischen Prozessoren anzusteuern. Aus Sicht eines Anwendungsprogramms ist es erforderlich, dass f¨ ur jeden logischen Prozessor ein separater Thread zur Ausf¨ uhrung bereitsteht, d.h. f¨ ur die Implementierung des Programms m¨ ussen Techniken der parallelen Programmierung eingesetzt werden. Multicore-Prozessoren Neue Prozessorarchitekturen mit mehreren Prozessorkernen auf einem Prozessorchip werden schon seit vielen Jah-
1.2 Parallelit¨ at auf Prozessorebene
7
ren als die vielversprechendste Technik zur weiteren Leistungssteigerung angesehen. Die Idee besteht darin, anstatt eines Prozessorchips mit einer sehr komplexen internen Organisation mehrere Prozessorkerne mit einfacherer Organisation auf dem Prozessorchip zu integrieren. Dies hat den zus¨ atzlichen Vorteil, dass der Stromverbrauch des Prozessorchips dadurch reduziert werden kann, dass vor¨ ubergehend ungenutzte Prozessorkerne abgeschaltet werden [27]. Bei Multicore-Prozessoren werden mehrere Prozessorkerne auf einem Prozessorchip integriert. Jeder Prozessorkern stellt f¨ ur das Betriebssystem einen separaten logischen Prozessor mit separaten Ausf¨ uhrungsressourcen dar, die getrennt angesteuert werden m¨ ussen. Das Betriebssystem kann damit verschiedene Anwendungsprogramme parallel zueinander zur Ausf¨ uhrung bringen. So k¨ onnen z.B. mehrere Hintergrundanwendungen wie Viruserkennung, Verschl¨ usselung und Kompression parallel zu Anwendungsprogrammen des Nutzers ausgef¨ uhrt werden [58]. Es ist aber mit Techniken der parallelen Programmierung auch m¨ oglich, ein rechenzeitintensives Anwendungsprogramm (etwa aus dem Bereich der Computerspiele, der Bildverarbeitung oder naturwissenschaftlicher Simulationsprogramme) auf mehreren Prozessorkernen parallel abzuarbeiten, so dass die Berechnungszeit im Vergleich zu einer Ausf¨ uhrung auf einem Prozessorkern reduziert werden kann. Damit k¨ onnen auch Standardprogramme wie Textverarbeitungsprogramme oder Computerspiele zus¨atzliche, im Hintergrund ablaufende Funktionalit¨ aten zur Verf¨ ugung stellen, die parallel zu den Haupt-Funktionalit¨aten auf einem separaten Prozessorkern durchgef¨ uhrt werden und somit f¨ ur den Nutzer nicht zu wahrnehmbaren Verz¨ogerungen f¨ uhren. F¨ ur die Koordination der innerhalb einer Anwendung ablaufenden unterschiedlichen Funktionalit¨aten
8
1 Kurz¨ uberblick Multicore-Prozessoren
m¨ ussen Techniken der parallelen Programmierung eingesetzt werden.
1.3 Architektur von Multicore-Prozessoren F¨ ur die Realisierung von Multicore-Prozessoren gibt es verschiedene Implementierungsvarianten, die sich in der Anzahl der Prozessorkerne, der Gr¨ oße und Anordnung der Caches, den Zugriffm¨ oglichkeiten der Prozessorkerne auf die Caches und dem Einsatz von heterogenen Komponenten unterscheiden [37]. Dabei werden zur Zeit drei unterschiedliche Architekturmodelle eingesetzt, von denen auch Mischformen auftreten k¨ onnen. Hierarchisches Design
Cache/Speicher
Cache Kern
Kern
Cache Kern
Kern
hierarchisches Design
Abbildung 1.1. Hierarchisches Design.
Bei einem hierarchischen Design teilen sich mehrere Prozessorkerne mehrere Caches, die in einer baumartigen Konfiguration angeordnet sind, wobei die Gr¨ oße der Caches von den Bl¨ attern zur Wurzel steigt. Die Wurzel repr¨ asentiert die Verbindung zum Hauptspeicher. So kann z.B. jeder Prozessorkern einen separaten L1Cache haben, sich aber mit anderen Prozessorkernen einen L2-Cache teilen. Alle Prozessorkerne k¨ onnen auf den gemeinsamen externen Hauptspeicher zugreifen, was eine
1.3 Architektur von Multicore-Prozessoren
9
dreistufige Hierarchie ergibt. Dieses Konzept kann auf mehrere Stufen erweitert werden und ist in Abbildung 1.1 f¨ ur drei Stufen veranschaulicht. Zus¨ atzliche Untersysteme k¨ onnen die Caches einer Stufe miteinander verbinden. Ein hierarchisches Design wird typischerweise f¨ ur ServerKonfigurationen verwendet. Ein Beispiel f¨ ur ein hierarchisches Design ist der IBM Power5 Prozessor, der zwei 64-Bit superskalare Prozessorkerne enth¨ alt, von denen jeder zwei logische Prozessoren durch Einsatz von SMT simuliert. Jeder Prozessorkern hat einen separaten L1-Cache (f¨ ur Daten und Programme getrennt) und teilt sich mit dem anderen Prozessorkern einen L2-Cache (1.8 MB) sowie eine Schnittstelle zu einem externen 36 MB L3-Cache. Andere Prozessoren mit hierarchischem Design sind die Intel Core 2 Prozessoren und die Sun UltraSPARC T1 (Niagara) Prozessoren. Pipeline-Design
Abbildung 1.2. PipelineDesign.
Bei einem Pipeline-Design werden die Daten durch mehrere Prozessorkerne schrittweise weiterverarbeitet, bis sie vom letzten Prozessorkern im Speichersystem abgelegt werden, vgl. Abbildung 1.2. Hochspezialisierte Netzwerk-Prozessoren und Grafikchips arbeiten oft nach diesem Prinzip. Ein Beispiel sind die X10 und X11 Prozessoren von Xelerator zur Verarbeitung von Netzwerkpaketen in HochleistungsRoutern. Der Xelerator X10q,
10
1 Kurz¨ uberblick Multicore-Prozessoren
eine Variante des X10, enth¨ alt z.B. 200 separate Prozessorkerne, die in einer logischen linearen Pipeline miteinander verbunden sind. Die Pakete werden dem Prozessor u uhrt und dann ¨ ber mehrere Netzwerkschnittstellen zugef¨ durch die Prozessorkerne schrittweise verarbeitet, wobei jeder Prozessorkern einen Schritt ausf¨ uhrt. Die X11 Netzwerkprozessoren haben bis zu 800 Pipeline-Prozessorkerne. Netzwerkbasiertes Design Bei einem netzwerkbasierten Design sind die Prozessorkerne und ihre lokalen Caches oder Speicher u ¨ ber ein Verbindungsnetzwerk mit den anderen Prozessorkernen des Chips verbunden, so dass der gesamte Datentransfer zwischen den Prozessorkernen u ¨ber das Verbindungsnetzwerk l¨auft, vgl. Abbildung 1.3. Der Einsatz eines prozessorinternen Netzwerkes ist insbesondere Abbildung 1.3. Netzwerk- dann sinnvoll, wenn eine Vielbasiertes Design. zahl von Prozessorkernen verwendet werden soll. Ein netzwerkorientiertes Design wird z.B. f¨ ur den Intel TeraflopProzessor verwendet, der im Rahmen der Intel Tera-ScaleInitiative entwickelt wurde, vgl. unten, und in dem 80 Prozessorkerne eingesetzt werden. Weitere Entwicklungen Das Potential der Multicore-Prozessoren wurde von vielen Hardwareherstellern wie Intel und AMD erkannt und
1.3 Architektur von Multicore-Prozessoren
11
seit 2005 bieten viele Hardwarehersteller Prozessoren mit zwei oder mehr Kernen an. Ab Ende 2006 liefert Intel Quadcore-Prozessoren und ab 2008 wird mit der Auslieferung von Octcore-Prozessoren gerechnet. IBM bietet mit der Cell-Architektur einen Prozessor mit acht spezialisierten Prozessorkernen, vgl. Abschnitt 1.4. Der seit Dezember 2005 ausgelieferte UltraSPARC T1 Niagara Prozessor von Sun hat bis zu acht Prozessorkerne, von denen jeder durch den Einsatz von simultanem Multithreading, das von Sun als CoolThreads-Technologie bezeichnet wird, vier Threads simultan verarbeiten kann. Damit kann ein UltraSPARC T1 bis zu 32 Threads simultan ausf¨ uhren. Das f¨ ur 2008 angek¨ undigte Nachfolgemodell des Niagara-Prozessors (ROCK) soll bis zu 16 Prozessorkerne enthalten. Intel Tera-Scale-Initiative Intel untersucht im Rahmen des Tera-scale Computing Programs die Herausforderungen bei der Herstellung und Programmierung von Prozessoren mit Dutzenden von Prozessorkernen [27]. Diese Initiative beinhaltet auch die Entwicklung eines Teraflop-Prozessors, der 80 Prozessorkerne enth¨ alt, die als 8×10-Gitter organisiert sind. Jeder Prozessorkern kann Floating-Point-Operationen verarbeiten und enth¨ alt neben einem lokalen Cachespeicher auch einen Router zur Realisierung des Datentransfers zwischen den Prozessorkernen und dem Hauptspeicher. Zus¨ atzlich kann ein solcher Prozessor spezialisierte Prozessorkerne f¨ ur die Verarbeitung von Videodaten, graphischen Berechnungen und zur Verschl¨ usselung von Daten enthalten. Je nach Einsatzgebiet kann die Anzahl der spezialisierten Prozessorkerne variiert werden. Ein wesentlicher Bestandteil eines Prozessors mit einer Vielzahl von Prozessorkernen ist ein effizientes Verbin-
12
1 Kurz¨ uberblick Multicore-Prozessoren
dungsnetzwerk auf dem Prozessorchip, das an eine variable Anzahl von Prozessorkernen angepasst werden kann, den Ausfall einzelner Prozessorkerne toleriert und bei Bedarf das Abschalten einzelner Prozessorkerne erlaubt, falls diese f¨ ur die aktuelle Anwendung nicht ben¨ otigt werden. Ein solches Abschalten ist insbesondere zur Reduktion des Stromverbrauchs sinnvoll. F¨ ur eine effiziente Nutzung der Prozessorkerne ist entscheidend, dass die zu verarbeitenden Daten schnell zu den Prozessorkernen transportiert werden k¨ onnen, so dass diese nicht auf die Bereitstellung der Daten warten m¨ ussen. Dazu sind ein leistungsf¨ ahiges Speichersystem und I/O-System erforderlich. Das Speichersystem setzt private L1-Caches ein, auf die nur von jeweils einem Prozessorkern zugegriffen werden kann, sowie gemeinsame, evtl. aus mehreren Stufen bestehende L2-Caches ein, die Daten verschiedener Prozessorkerne enthalten. F¨ ur einen Prozessorchip mit Dutzenden von Prozessorkernen muss voraussichtlich eine weitere Stufe im Speichersystem eingesetzt werden [27]. Das I/O-System muss in der Lage sein, Hunderte von Gigabytes pro Sekunde zum Prozessorchip zu transportieren. Hier arbeitet z.B. Intel an der Entwicklung geeigneter Systeme. ¨ Tabelle 1.1 gibt einen Uberblick u ¨ ber aktuelle MulticoreProzessoren. Zu bemerken ist dabei, dass der Sun UltraSPARC T1-Prozessor im Gegensatz zu den drei anderen Prozessoren kaum Unterst¨ utzung f¨ ur Floating-PointBerechnungen bietet und somit u ur den Ein¨ berwiegend f¨ satz im Serverbereich, wie Web-Server oder ApplikationsServer, geeignet ist. F¨ ur eine detailliertere Behandlung der Architektur von Multicore-Prozessoren und weiterer Beispiele verweisen wir auf [10, 28].
1.4 Beispiele
13
¨ Tabelle 1.1. Uberblick u ¨ber verschiedene Multicore-Prozessoren, vgl. auch [28]. Intel Prozessor Core 2 Duo Prozessorkerne 2 Instruktionen 4 pro Zyklus SMT nein L1-Cache I/D 32/32 in KB per core L2-Cache 4 MB shared Taktrate (GHz) 2.66 Transistoren 291 Mio Stromverbrauch 65 W
IBM AMD Power 5 Opteron 2 2 4 3 ja 64/32 1.9 MB shared 1.9 276 Mio 125 W
nein 64/64
Sun T1 8 1 ja 16/8
1 MB 3 MB per core shared 2.4 1.2 233 Mio 300 Mio 110 W 79 W
1.4 Beispiele Im Folgenden wird die Architektur von Multicore-Prozessoren anhand zweier Beispiele verdeutlicht: der Intel Core 2 Duo-Architektur und dem IBM Cell-Prozessor. Intel Core 2 Prozessor Intel Core 2 bezeichnet eine Familie von Intel-Prozessoren mit ¨ ahnlicher Architektur. Die Intel Core-Architektur basiert auf einer Weiterentwicklung der Pentium M Prozessoren, die viele Jahre im Notebookbereich eingesetzt wurden. Die neue Architektur l¨ ost die bei den Pentium 4 Prozessoren noch eingesetzte NetBurst-Architektur ab. Signifikante Merkmale der neuen Architektur sind:
14
• • •
•
1 Kurz¨ uberblick Multicore-Prozessoren
eine drastische Verk¨ urzung der internen Pipelines (maximal 14 Stufen anstatt maximal 31 Stufen bei der NetBurst-Architektur), damit verbunden eine Reduktion der Taktrate und damit verbunden auch eine deutliche Reduktion des Stromverbrauchs: die Reduktion des Stromverbrauches wird auch durch eine Power-Management-Einheit unterst¨ utzt, die das zeitweise Ausschalten ungenutzter Prozessorteile erm¨oglicht [48] und die Unterst¨ utzung neuer Streaming-Erweiterungen (Streaming SIMD Extensions, SSE).
Intel Core 2 Prozessoren werden zur Zeit (August 2007) als Core 2 Duo bzw. Core 2 Quad Prozessoren mit 2 bzw. 4 unabh¨ angigen Prozessorkernen in 65nm-Technologie gefertigt. Im Folgenden wird der Core 2 Duo Prozessor kurz beschrieben [24]. Die Core 2 Quad Prozessoren haben einen a ¨hnlichen Aufbau, enthalten aber 4 statt 2 Prozessorkerne. Da die Core 2 Prozessoren auf der Technik des Pentium M Prozessors basieren, unterst¨ utzen sie kein Hyperthreading. Die allgemeine Struktur der Core 2 Duo Architektur ist in Abb. 1.4 wiedergegeben. Der Prozessorchip enth¨alt zwei unabh¨ angige Prozessorkerne, von denen jeder separate L1-Caches anspricht; diese sind f¨ ur Instruktionen (32K) und Daten (32K) getrennt realisiert. Der L2-Cache (4 MB) ist dagegen nicht exklusiv und wird von beiden Prozessorkernen gemeinsam f¨ ur Instruktionen und Daten genutzt. Alle Zugriffe von den Prozessorkernen und vom externen Bus auf den L2-Cache werden von einem L2-Controller behandelt. F¨ ur die Sicherstellung der Koh¨ arenz der auf den verschiedenen Stufen der Speicherhierarchie abgelegten Daten wird ein MESI-Protokoll (Modified, Exclusive, Shared, Invalid) verwendet, vgl. [17, 47, 59] f¨ ur eine detaillierte Erkl¨arung. Alle Daten- und I/O-Anfragen zum oder vom externen Bus
1.4 Beispiele
15
(Front Side Bus) werden u ¨ ber einen Bus-Controller gesteuert. Core 2 Duo Prozessor Core 0
Core 1
Architektur−Ressourcen
Architektur−Ressourcen
Ausführungs−Ressourcen
Ausführungs−Ressourcen
L1−Caches (I/O) Cache−Controller
L1−Caches (I/O) Cache−Controller
Power−Management−Controller L2−Cache mit Controller (shared) Bus−Interface und −Controller
FrontSideBus
¨ Abbildung 1.4. Uberblick Core 2 Duo Architektur.
Ein wichtiges Element ist die Kontrolleinheit f¨ ur den Stromverbrauch des Prozessors (Power Management Controller) [48], die den Stromverbrauch des Prozessorchips durch Reduktion der Taktrate der Prozessorenkerne oder durch Abschalten (von Teilen) des L2-Caches reduzieren kann. Jeder Prozessorkern f¨ uhrt einen separaten Strom von Instruktionen aus, der sowohl Berechnungs- als auch Speicherzugriffsinstruktionen (load/store) enthalten kann. Dabei kann jeder der Prozessorkerne bis zu vier Instruktionen gleichzeitig verarbeiten. Die Prozessorkerne enthalten separate Funktionseinheiten f¨ ur das Laden bzw. Speichern von
16
1 Kurz¨ uberblick Multicore-Prozessoren
Daten, die Ausf¨ uhrung von Integeroperationen (durch eine ALU, arithmetic logic unit), Floating-Point-Operationen sowie SSE-Operationen. Instruktionen k¨ onnen aber nur dann parallel zueinander ausgef¨ uhrt werden, wenn keine Abh¨ angigkeiten zwischen ihnen bestehen. F¨ ur die Steuerung der Ausf¨ uhrung werden komplexe Schedulingverfahren eingesetzt, die auch eine Umordnung von Instruktionen (out-of-order execution) erlauben, um eine m¨ oglichst gute Ausnutzung der Funktionseinheiten zu verwirklichen [28]. Laden von Instruktionen Instruktionsschlange Mikrocode ROM
Dekodiereinheit
L2− Cache (shared)
Register−Umbenennung und −Allokierung Umordnungspuffer Instruktions−Scheduler ALU Branch MMX/SSE FPMove
ALU FPAdd MMX/SSE FPMove
ALU FPMul MMX/SSE FPMove
Load
Store
Speicherzugriffspuffer
L1−Datencache
Abbildung 1.5. Instruktionsverarbeitung und Speicherorganisation eines Prozessorkerns des Intel Core 2 Prozessors.
Abbildung 1.5 veranschaulicht die Organisation der Abarbeitung von Instruktionen durch einen der Prozessorkerne [20]. Jeder der Prozessorkerne l¨ adt x86-Instruktionen in eine Instruktionsschlange, auf die die Dekodiereinheit zugreift und die Instruktionen in Mikroinstruktionen zer-
1.4 Beispiele
17
legt. F¨ ur komplexere x86-Instruktionen werden die zugeh¨ origen Mikroinstruktionen u ¨ ber einen ROM-Speicher geladen. Die Mikroinstruktionen werden vom InstruktionsScheduler freien Funktionseinheiten zugeordnet, wobei die Instruktionen in einer gegen¨ uber dem urspr¨ unglichen Programmcode ge¨ anderten Reihenfolge abgesetzt werden k¨onnen. Alle Speicherzugriffsoperationen werden u ¨ ber den L1Datencache abgesetzt, der Integer-und Floating-Point-Daten enth¨ alt. F¨ ur Ende 2007 bzw. Anfang 2008 sollen Intel Core 2Prozessoren mit verbesserter Core-Architektur eingef¨ uhrt werden (Codename Penryn). Voraussichtlich f¨ ur Ende 2008 ist eine neue Generation von Intel-Prozessoren geplant, die auf einer neuen Architektur basiert (Codename Nehalem). Diese neuen Prozessoren sollen neben mehreren Prozessorkernen (zu Beginn acht) auch einen Graphikkern und einen Speichercontroller auf einem Prozessorchip integrieren. Die neuen Prozessoren sollen auch wieder die SMT-Technik (simultanes Multithreading) unterst¨ utzen, so dass auf jedem Prozessorkern gleichzeitig zwei Threads ausgef¨ uhrt werden k¨ onnen. Diese Technik wurde teilweise f¨ ur Pentium 4 Prozessoren verwendet, nicht jedoch f¨ ur die Core 2 Duo und Quad Prozessoren. IBM Cell-Prozessor Der Cell-Prozessor wurde von IBM in Zusammenarbeit mit Sony und Toshiba entwickelt. Der Prozessor wird u.a. von Sony in der Spielekonsole PlayStation 3 eingesetzt, siehe [39, 34] f¨ ur ausf¨ uhrlichere Informationen. Der CellProzessor enth¨ alt ein Power Processing Element (PPE) und 8 Single-Instruction Multiple-Datastream (SIMD) Prozessoren. Das PPE ist ein konventioneller 64-Bit-Mikroprozessor auf der Basis der Power-Architektur von IBM mit relativ
18
1 Kurz¨ uberblick Multicore-Prozessoren
einfachem Design: der Prozessor kann pro Takt zwei Instruktionen absetzen und simultan zwei unabh¨ angige Threads ausf¨ uhren. Die einfache Struktur hat den Vorteil, dass trotz hoher Taktrate eine geringe Leistungsaufnahme resultiert. F¨ ur den gesamten Prozessor ist bei einer Taktrate von 3.2 GHz nur eine Leistungsaufnahme von 60-80 Watt erforderlich. Auf der Chipfl¨ ache des Cell-Prozessors sind neben dem PPE acht SIMD-Prozessoren integriert, die als SPE (Synergetic Processing Element) bezeichnet werden. Jedes SPE stellt einen unabh¨ angigen Vektorprozessor mit einem 256KB großen lokalem SRAM-Speicher dar, der als Local Store (LS) bezeichnet wird. Das Laden von Daten in den LS und das Zur¨ uckspeichern von Resultaten aus dem LS in den Hauptspeicher muss per Software erfolgen. Jedes SPE enth¨ alt 128 128-Bit-Register, in denen die Operanden von Instruktionen abgelegt werden k¨onnen. Da auf die Daten in den Registern sehr schnell zugegriffen werden kann, reduziert die große Registeranzahl die Notwendigkeit von Zugriffen auf den LS und f¨ uhrt damit zu einer geringen mittleren Speicherzugriffszeit. Jedes SPE hat vier Floating-Point-Einheiten (32 Bit) und vier IntegerEinheiten. Z¨ ahlt man eine Multiply-Add-Instruktion als zwei Operationen, kann jedes SPE bei 3.2 GHz Taktrate pro Sekunde u ¨ ber 25 Milliarden Floating-Point-Operationen (25.6 GFlops) und u ¨ ber 25 Milliarden Integer-Operationen (25.6 Gops) ausf¨ uhren. Da ein Cell-Prozessor acht SPE enth¨ alt, f¨ uhrt dies zu einer maximalen Performance von u ¨ ber 200 GFlops, wobei die Leistung des PPE noch nicht ber¨ ucksichtigt wurde. Eine solche Leistung kann allerdings nur bei guter Ausnutzung der LS-Speicher und effizienter Zuordnung von Instruktionen an Funktionseinheiten der SPE erreicht werden. Zu beachten ist auch, dass sich diese Angabe auf 32-Bit Floating-Point-Zahlen bezieht. Der Cell-
1.4 Beispiele
19
Prozessor kann durch Zusammenlegen von Funktionseinheiten auch 64-Bit Floating-Point-Zahlen verarbeiten, dies resultiert aber in einer wesentlich geringeren maximalen Performance. Zur Vereinfachung der Steuerung der SPEs und zur Vereinfachung des Schedulings verwenden die SPEs intern keine SMT-Technik. Die zentrale Verbindungseinheit des Cell-Prozessors ist ein Bussystem, der sogenannte Element Interconnect Bus (EIB). Dieser besteht aus vier unidirektionalen Ringverbindungen, die eine Wortbreite von 16 Bytes haben und mit der halben Taktrate des Prozessors arbeiten. Zwei der Ringe werden in entgegengesetzter Richtung zu den anderen beiden Ringe betrieben, so dass die maximale Latenz im schlechtesten Fall durch einen halben Ringdurchlauf bestimmt wird. F¨ ur den Transport von Daten zwischen benachbarten Ringelementen k¨ onnen maximal drei Transferoperationen simultan durchgef¨ uhrt werden, f¨ ur den Zyklus des Prozessors ergibt dies 16 · 3/2 = 24 Bytes pro Zyklus. F¨ ur die vier Ringverbindungen ergibt dies eine maximale Transferrate von 96 Bytes pro Zyklus, woraus bei einer Taktrate von 3.2 GHz eine maximale Transferrate von u ¨ ber 300 GBytes/Sekunde resultiert. Abbildung 1.6 zeigt einen schematischen Aufbau des Cell-Prozessors mit den bisher beschriebenen Elementen sowie dem Speichersystem (Memory Interface Controller, MIC) und dem I/O-System (Bus Interface Controller, BIC). Das Speichersystem unterst¨ utzt die XDR-Schnittstelle von Rambus. Das I/O-System unterst¨ utzt das Rambus RRAC (Redwood Rambus Access Cell) Protokoll. Zum Erreichen einer guten Leistung ist es wichtig, die SPEs des Cell-Prozessors effizient zu nutzen. Dies kann f¨ ur spezialisierte Programme, wie z.B. Videospiele, durch direkte Verwendung von SPE-Assembleranweisungen erreicht werden. Da dies f¨ ur die meisten Anwendungsprogramme
20
1 Kurz¨ uberblick Multicore-Prozessoren Synergetic Processing Elements SPU
SPU
SPU
SPU
SPU
SPU
SPU
SPU
LS
LS
LS
LS
LS
LS
LS
LS
16B/ Zyklus
EIB (bis 96 B/Zyklus)
L2
L1
MIC
BIC
Dual XDR
RRAC I/O
PPU
64−Bit Power Architektur
Abbildung 1.6. Schematischer Aufbau des Cell-Prozessors.
zu aufwendig ist, werden f¨ ur das Erreichen einer guten Gesamtleistung eine effektive Compilerunterst¨ utzung sowie die Verwendung spezialisierter Programmbibliotheken z.B. zur Verwaltung von Taskschlangen wichtig sein.
2 Konzepte paralleler Programmierung
Die Leistungsverbesserung der Generation der MulticoreProzessoren wird technologisch durch mehrere Prozessorkerne auf einem Chip erreicht. Im Gegensatz zu bisherigen Leistungsverbesserungen bei Prozessoren hat diese Technologie jedoch Auswirkungen auf die Softwareentwicklung: Konnten bisherige Verbesserungen der Prozessorhardware zu Leistungsgewinnen bei existierenden (sequentiellen) Programmen f¨ uhren, ohne dass die Programme ge¨andert werden mussten, so ist zur vollen Ausnutzung der Leistung der Multicore-Prozessoren ein Umdenken hin zur parallelen Programmierung notwendig [62]. Parallele Programmiertechniken sind seit vielen Jahren im Einsatz, etwa im wissenschaftlichen Rechnen auf Parallelrechnern oder im Multithreading, und stellen somit keinen wirklich neuen Programmierstil dar. Neu hingegen ist, dass durch die k¨ unftige Allgegenw¨ artigkeit der MulticoreProzessoren ein Ausbreiten paralleler Programmiertechniken in alle Bereiche der Softwareentwicklung erwartet wird und diese damit zum R¨ ustzeug eines jeden Softwareentwicklers geh¨ oren werden.
22
2 Konzepte paralleler Programmierung
Ein wesentlicher Schritt in der Programmierung von Multicore-Prozessoren ist das Bereitstellen mehrerer Berechnungsstr¨ ome, die auf den Kernen eines Multicore-Prozessors simultan, also gleichzeitig, abgearbeitet werden. Zun¨ achst ist die rein gedankliche Arbeit durchzuf¨ uhren, einen einzelnen Anwendungsalgorithmus in solche Berechnungsstr¨ ome zu zerlegen, was eine durchaus langwierige, schwierige und kreative Aufgabe sein kann, da es eben sehr viele M¨ oglichkeiten gibt, dies zu tun, und insbesondere korrekte und effiziente Software resultieren soll. Zur Erstellung der parallelen Software sind einige Grundbegriffe und -kenntnisse hilfreich: • • • • •
Wie wird beim Entwurf eines parallelen Programmes vorgegangen? Welche Eigenschaften der parallelen Hardware sollen zu Grunde gelegt werden? Welches parallele Programmiermodell soll genutzt werden? Wie kann der Leistungsvorteil des parallelen Programms gegen¨ uber dem sequentiellen bestimmt werden? Welche parallele Programmierumgebung oder -sprache soll genutzt werden?
2.1 Entwurf paralleler Programme Die Grundidee der parallelen Programmierung besteht darin, mehrere Berechnungsstr¨ ome zu erzeugen, die gleichzeitig, also parallel, ausgef¨ uhrt werden k¨ onnen und durch koordinierte Zusammenarbeit eine gew¨ unschte Aufgabe erledigen. Liegt bereits ein sequentielles Programm vor, so spricht man auch von der Parallelisierung eines Programmes.
2.1 Entwurf paralleler Programme
23
Zur Erzeugung der Berechnungsstr¨ ome wird die auszuf¨ uhrende Aufgabe in Teilaufgaben zerlegt, die auch Tasks genannt werden. Tasks sind die kleinsten Einheiten der Parallelit¨ at. Beim Entwurf der Taskstruktur eines Programmes m¨ ussen Daten- und Kontrollabh¨ angigkeiten beachtet und eingeplant werden, um ein korrektes paralleles Programm zu erhalten. Die Gr¨ oße der Tasks wird Granularit¨ at genannt. F¨ ur die tats¨ achliche parallele Abarbeitung werden die Teilaufgaben in Form von Threads oder Prozessen auf physikalische Rechenressourcen abgebildet. Die Rechenressourcen k¨ onnen Prozessoren eines Parallelrechners, aber auch die Prozessorkerne eines MulticoreProzessors sein. Die Zuordnung von Tasks an Prozesse oder Threads wird auch als Scheduling bezeichnet. Dabei kann man zwischen statischem Scheduling, bei dem die Zuteilung beim Start des Programms festgelegt wird, und dynamischem Scheduling, bei dem die Zuteilung w¨ahrend der Abarbeitung des Programms erfolgt, unterscheiden. Die Abbildung von Prozessen oder Threads auf Prozessorkerne, auch Mapping genannt, kann explizit im Programm bzw. durch das Betriebssystem erfolgen. Abbildung 2.1 zeigt eine Veranschaulichung. Software mit mehreren parallel laufenden Tasks gibt es in vielen Bereichen schon seit Jahren. So bestehen Serveranwendungen h¨ aufig aus mehreren Threads oder Prozessen. Ein typisches Beispiel ist ein Webserver, der mit einem Haupt-Thread HTTP-Anfragenachrichten von beliebigen Clients (Browsern) entgegennimmt und f¨ ur jede eintreffende Verbindungsanfrage eines Clients einen separaten Thread erzeugt. Dieser Thread behandelt alle von diesem Client eintreffenden HTTP-Anfragen und schickt die zugeh¨ origen HTTP-Antwortnachrichten u ¨ ber eine Clientspezifische TCP-Verbindung. Wird diese TCP-Verbindung
24
2 Konzepte paralleler Programmierung
Tasks
Prozessor− Kerne
Threads Schedu− ling
T1
T2
Mapping
T1 T2
P1
T3
Task− Zerlegung
Thread− Zuordnung
T3
P2
Abbildung 2.1. Veranschaulichung der typischen Schritte zur Parallelisierung eines Anwendungsalgorithmus. Der Algorithmus wird in der Zerlegungsphase in Tasks mit gegenseitigen Abh¨ angigkeiten aufgespalten. Diese Tasks werden durch das Scheduling Threads zugeordnet, die auf Prozessorkerne abgebildet werden.
geschlossen, wird auch der zugeh¨ orige Thread beendet. Durch dieses Vorgehen kann ein Webserver gleichzeitig viele ankommende Anfragen nebenl¨ aufig erledigen oder auf verf¨ ugbaren Rechenressourcen parallel bearbeiten. F¨ ur Webserver mit vielen Anfragen (wie google oder ebay) werden entsprechende Plattformen mit vielen Rechenressourcen bereitgehalten. Abarbeitung paralleler Programme F¨ ur die parallele Abarbeitung der Tasks bzw. Threads oder Prozesse gibt es verschiedene Ans¨ atze, vgl. z.B. auch [3]: •
Multitasking: Multitasking-Betriebssysteme arbeiten mehrere Threads oder Prozesse in Zeitscheiben auf demselben Prozessor ab. Hierdurch k¨ onnen etwa die Latenzzeiten von I/O-Operationen durch eine verschachtelte Abarbeitung der Tasks u ¨ berdeckt werden. Diese Form
2.1 Entwurf paralleler Programme
•
•
•
25
der Abarbeitung mehrerer Tasks wird als Nebenl¨aufigkeit (Concurrency) bezeichnet; mehrere Tasks werden gleichzeitig bearbeitet, aber nur eine Task macht zu einem bestimmten Zeitpunkt einen tats¨ achlichen Rechenfortschritt. Eine simultane parallele Abarbeitung findet also nicht statt. Multiprocessing: Die Verwendung mehrerer physikalischer Prozessoren macht eine tats¨ achliche parallele Abarbeitung mehrerer Tasks m¨ oglich. Bedingt durch die hardwarem¨ aßige Parallelit¨ at mit mehreren physikalischen Prozessoren kann jedoch ein nicht unerheblicher zeitlicher Zusatzaufwand (Overhead) entstehen. Simultanes Multithreading (SMT): Werden mehrere logische Prozessoren auf einem physikalischen Prozessor ausgef¨ uhrt, so k¨ onnen die Hardwareressourcen eines Prozessors besser genutzt werden und es kann eine teilweise beschleunigte Abarbeitung von Tasks erfolgen, vgl. Kap. 1. Bei zwei logischen Prozessoren sind Leistungssteigerungen durch Nutzung von Wartezeiten eines Threads f¨ ur die Berechnungen des anderen Threads um bis zu 30 % m¨ oglich. Chip-Multiprocessing: Der n¨ achste Schritt ist nun, die Idee der Threading-Technologie auf einem Chip mit dem Multiprocessing zu verbinden, was durch MulticoreProzessoren m¨ oglich ist. Die Programmierung von Multicore-Prozessoren vereint das Multiprocessing mit dem simultanen Multithreading in den Sinne, dass Multithreading-Programme nicht nebenl¨ aufig sondern tats¨ achlich parallel auf einen Prozessor abgearbeitet werden. Dadurch sind im Idealfall Leistungssteigerungen bis zu 100 % f¨ ur einen Dualcore-Prozessor m¨oglich.
F¨ ur die Programmierung von Multicore-Prozessoren werden Multithreading-Programme eingesetzt. Obwohl viele
26
2 Konzepte paralleler Programmierung
moderne Programme bereits Multithreading verwenden, gibt es doch Unterschiede, die gegen¨ uber der Programmierung von Prozessoren mit simultanem Multithreading zu beachten sind: •
•
•
Einsatz zur Leistungsverbesserung: Die Leistungsverbesserungen von SMT-Prozessoren wird meistens zur Verringerung der Antwortzeiten f¨ ur den Nutzer eingesetzt, indem etwa ein Thread zur Beantwortung einer oder mehrerer Benutzeranfragen abgespalten und nebenl¨ aufig ausgef¨ uhrt wird. In Multicore-Prozessoren hingegen wird die gesamte Arbeit eines Programmes durch Partitionierung auf die einzelnen Kerne verteilt und gleichzeitig abgearbeitet. Auswirkungen des Caches: Falls jeder Kern eines Multicore-Prozessors einen eigenen Cache besitzt, so kann es zu dem beim Multiprocessing bekannten False Sharing kommen. Bei False Sharing handelt es sich um das Problem, dass zwei Kerne gleichzeitig auf Daten arbeiten, die zwar verschieden sind, jedoch in derselben Cachezeile liegen. Obwohl die Daten also unabh¨angig sind, wird die Cachezeile im jeweils anderen Kern als ung¨ ultig markiert, wodurch es zu Leistungsabfall kommt. Thread-Priorit¨ aten: Bei der Ausf¨ uhrung von Multithreading-Programmen auf Prozessoren mit nur einem Kern wird immer der Thread mit der h¨ ochsten Priorit¨at zuerst bearbeitet. Bei Prozessoren mit mehreren Kernen k¨ onnen jedoch auch Threads mit unterschiedlichen Priorit¨ aten gleichzeitig abgearbeitet werden, was zu durchaus unterschiedlichen Resultaten f¨ uhren kann.
Diese Beispiele zeigen, dass f¨ ur den Entwurf von Multithreading-Programmen f¨ ur Multicore-Prozessoren nicht nur die Techniken der Threadprogrammierung gebraucht werden, sondern Programmiertechniken, Methoden und Design-
2.2 Klassifizierung paralleler Architekturen
27
entscheidungen der parallelen Programmierung eine erhebliche Rolle spielen.
2.2 Klassifizierung paralleler Architekturen Unter paralleler Hardware wird Hardware zusammengefasst, die mehrere Rechenressourcen bereitstellt, auf denen ein Programm in Form mehrerer Berechnungsstr¨ome abgearbeitet wird. Die Formen paralleler Hardware reichen also von herk¨ ommlichen Parallelrechnern bis hin zu parallelen Rechenressourcen auf einem Chip, wie z.B. bei MulticoreProzessoren. Eine erste Klassifizierung solcher paralleler Hardware hat bereits Flynn in der nach ihm benannten Flynnschen Klassifikation gegeben [23]. Diese Klassifikation unterteilt parallele Hardware in vier Klassen mit unterschiedlichen Daten- und Kontrollfl¨ ussen: •
•
•
Die SISD (Single Instruction, Single Data) Rechner besitzen eine Rechenressource, einen Datenspeicher und einen Programmspeicher, entsprechen also dem klassischen von-Neumann-Rechner der sequentiellen Programmierung. Die MISD (Multiple Instruction, Single Data) Rechner stellen mehrere Rechenressourcen, aber nur einen Programmspeicher bereit. Wegen der geringen praktischen Relevanz spielt diese Klasse keine wesentliche Rolle. Die SIMD (Single Instruction, Multiple Data) Rechner bestehen aus mehreren Rechenressourcen mit jeweils separatem Zugriff auf einen Datenspeicher, aber nur einem Programmspeicher. Jede Ressource f¨ uhrt dieselben Instruktionen aus, die aus dem Programmspeicher geladen werden, wendet diese aber auf unterschiedliche Daten an. F¨ ur diese Klasse wurden in der Vergangenheit Parallelrechner konstruiert und genutzt.
28
•
2 Konzepte paralleler Programmierung
Die MIMD (Multiple Instruction, Multiple Data) Rechner sind die allgemeinste Klasse und zeichnen sich durch mehrere Rechenressourcen mit jeweils separatem Zugriff auf einen Datenspeicher und jeweils lokalen Programmspeichern aus. Jede Rechenressource kann also unterschiedliche Instruktionen auf unterschiedlichen Daten verarbeiten.
Zur Klasse der MIMD-Rechner geh¨ oren viele der heute aktuellen Parallelrechner, Cluster von PCs aber auch Multicore-Prozessoren, wobei die einzelnen Prozessoren, die PCs des Clusters oder die Kerne auf dem Chip eines Multicore-Prozessors die jeweiligen Rechenressourcen bilden. Dies zeigt, dass die Flynnsche Klassifizierung f¨ ur die heutige Vielfalt an parallelen Rechenressourcen zu grob ist und weitere Unterteilungen f¨ ur den Softwareentwickler n¨ utzlich sind. Eine der weiteren Unterschiede der Hardware von MIMD-Rechnern ist die Speicherorganisation, die sich auf den Zugriff der Rechenressourcen auf die Daten eines Programms auswirkt: Rechner mit verteiltem Speicher bestehen aus Rechenressourcen, die jeweils einen ihnen zugeordneten lokalen bzw. privaten Speicher haben. Auf Daten im lokalen Speicher hat jeweils nur die zugeordnete Rechenressource Zugriff. Werden Daten aus einem Speicher ben¨otigt, der zu einer anderen Rechenressource lokal ist, so werden Programmiermechanismen, wie z. B. Kommunikation u ¨ ber ein Verbindungsnetzwerk, eingesetzt. Clustersysteme, aber auch viele Parallelrechner geh¨ oren in diese Klasse. Rechner mit gemeinsamem Speicher bestehen aus mehreren Rechenressourcen und einem globalen oder gemeinsamen Speicher, auf den alle Rechenressourcen u ¨ ber ein Verbindungsnetzwerk auf Daten zugreifen k¨onnen. Dadurch kann jede Rechenressource die gesamten Daten des
2.3 Parallele Programmiermodelle
29
parallelen Programms zugreifen und verarbeiten. ServerArchitekturen und insbesondere Multicore-Prozessoren arbeiten mit einem physikalisch gemeinsamen Speicher. Die durch die Hardware gegebene Organisation des Speichers in verteilten und gemeinsamen Speicher kann f¨ ur den Programmierer in Form privater oder gemeinsamer Variable sichtbar und nutzbar sein. Es ist prinzipiell jedoch mit Hilfe entsprechender Softwareunterst¨ utzung m¨oglich, das Programmieren mit gemeinsamen Variablen (shared variables) auch auf physikalisch verteiltem Speicher bereitzustellen. Ebenso kann die Programmierung mit verteiltem Adressraum und Kommunikation auf einem physikalisch gemeinsamen Speicher durch zus¨ atzliche Software m¨oglich sein. Dies ist nur ein Beispiel daf¨ ur, dass die gegebene Hardware nur ein Teil dessen ist, was dem Softwareentwickler als Sicht auf ein paralleles System dient.
2.3 Parallele Programmiermodelle Der Entwurf eines parallelen Programmes basiert immer auch auf einer abstrakten Sicht auf das parallele System, auf dem die parallele Software abgearbeitet werden soll. Diese abstrakte Sicht wird paralleles Programmiermodell genannt und setzt sich aus mehr als nur der gegebenen parallelen Hardware zusammen: Ein paralleles Programmiermodell beschreibt ein paralleles Rechensystem aus der Sicht, die sich dem Softwareentwickler durch Systemsoftware wie Betriebssystem, parallele Programmiersprache oder -bibliothek, Compiler und Laufzeitbibliothek bietet. Entsprechend viele durchaus unterschiedliche parallele Programmiermodelle bieten sich dem Softwareentwickler an. Folgende Kriterien erlauben jedoch eine systematische Her-
30
2 Konzepte paralleler Programmierung
angehensweise an diese Vielfalt der Programmiermodelle [59]: • • •
•
• •
Auf welcher Ebene eines Programmes sollen parallele Programmteile ausgef¨ uhrt werden? (z.B. Instruktionsebene, Anweisungsebene oder Prozedurebene) Sollen parallele Programmteile explizit angegeben werden? (explizit oder implizit parallele Programme) In welcher Form werden parallele Programmteile angegeben? (z.B. als beim Start des Programmes erzeugte Menge von Prozessen oder etwa Tasks, die dynamisch erzeugt und zugeordnet werden) Wie erfolgt die Abarbeitung der parallelen Programmteile im Verh¨ altnis zueinander? (SIMD oder SPMD (Single Program, Multiple Data); synchrone oder asynchrone Berechnungen) Wie findet der Informationsaustausch zwischen parallelen Programmteilen statt? (Kommunikation oder gemeinsame Variable) Welche Formen der Synchronisation k¨ onnen genutzt werden? (z.B. Barrier-Synchronisation oder Sperrmechanismen)
Ebenen der Parallelit¨ at Unabh¨ angige und damit parallel abarbeitbare Aufgaben k¨ onnen auf sehr unterschiedlichen Ebenen eines Programms auftreten, wobei f¨ ur die Parallelisierung eines Programmes meist jeweils nur eine Ebene genutzt wird. •
Parallelit¨ at auf Instruktionsebene kann ausgenutzt werden, falls zwischen zwei Instruktionen keine Datenabh¨ angigkeit besteht. Diese Form der Parallelit¨at kann durch Compiler auf superskalaren Prozessoren eingesetzt werden.
2.3 Parallele Programmiermodelle
•
•
•
31
Bei der Parallelit¨ at auf Anweisungsebene werden mehrere Anweisungen auf denselben oder verschiedenen Daten parallel ausgef¨ uhrt. Eine Form ist die Datenparallelit¨ at, bei der Datenstrukturen wie Felder in Teilbereiche unterteilt werden, auf denen parallel zueinander dieselben Operationen ausgef¨ uhrt werden. Diese Arbeitsweise wird im SIMD Modell genutzt, in dem in jedem Schritt die gleiche Anweisung auf evtl. unterschiedlichen Daten ausgef¨ uhrt wird. Bei der Parallelit¨ at auf Schleifenebene werden unterschiedliche Iterationen einer Schleifenanweisung parallel zueinander ausgef¨ uhrt. Besondere Auspr¨agungen sind die forall und dopar Schleife. Bei der forallSchleife werden die Anweisungen im Schleifenrumpf parallel in Form von Vektoranweisungen nacheinander abgearbeitet. Die dopar-Schleife f¨ uhrt alle Anweisungen einer Schleifeniteration unabh¨ angig vor den anderen Schleifeniterationen aus. Je nach Datenabh¨angigkeiten zwischen den Schleifeniterationen kann es durch die Parallelit¨ at auf Schleifenebene zu unterschiedlichen Ergebnissen kommen als bei der sequentiellen Abarbeitung der Schleifenr¨ umpfe. Als parallele Schleife wird eine Schleife bezeichnet, deren Schleifenr¨ umpfe keine Datenabh¨ angigkeit aufweisen und somit eine parallele Abarbeitung zum gleichen Ergebnis f¨ uhrt wie die sequentielle Abarbeitung. Parallele Schleifen spielen bei Programmierumgebungen wie OpenMP eine wesentliche Rolle. Bei der Parallelit¨ at auf Funktionsebene werden gesamte Funktionsaktivierungen eines Programms parallel zueinander auf verschiedenen Prozessoren oder Prozessorkernen ausgef¨ uhrt. Bestehen zwischen parallel auszuf¨ uhrenden Funktionen Daten- oder Kontrollabh¨angigkeiten, so ist eine Koordination zwischen den Funktionen erforderlich. Dies erfolgt in Form von Kommunika-
32
2 Konzepte paralleler Programmierung
tion und Barrier-Anweisungen bei Modellen mit verteiltem Adressraum. Ein Beispiel ist die Programmierung mit MPI (Message Passing Interface) [59, 61]. Bei Modellen mit gemeinsamem Adressraum ist Synchronisation erforderlich; dies ist also f¨ ur die Programmierung von Multicore-Prozessoren notwendig und wird in Kapitel 3 vorgestellt. Explizite oder implizite Parallelit¨ at Eine Voraussetzung f¨ ur die parallele Abarbeitung auf einem Multicore-Prozessor ist das Vorhandensein mehrerer Berechnungsstr¨ ome. Diese k¨ onnen auf recht unterschiedliche Art erzeugt werden [60]. Bei impliziter Parallelit¨ at braucht der Programmierer keine Angaben zur Parallelit¨ at zu machen. Zwei unterschiedliche Vertreter impliziter Parallelit¨ at sind parallelisierende Compiler oder funktionale Programmiersprachen. Parallelisierende Compiler erzeugen aus einem gegebenen sequentiellen Programm ein paralleles Programm und nutzen dabei Abh¨ angigkeitsanalysen, um unabh¨ angige Berechnungen zu ermitteln. Dies ist in den meisten F¨allen eine komplexe Aufgabe und die Erfolge parallelisierender Compiler sind entsprechend begrenzt [55, 66, 5, 2]. Programme in einer funktionalen Programmiersprache bestehen aus einer Reihe von Funktionsdefinitionen und einem Ausdruck, dessen Auswertung das Programmergebnis liefert. Das Potential f¨ ur Parallelit¨ at liegt in der parallelen Auswertung der Argumente von Funktionen, da funktionale Programme keine Seiteneffekte haben und sich die Argumente somit nicht beeinflussen k¨ onnen [33, 64, 8]. Implizite Parallelit¨at wird teilweise auch von neuen Sprachen wie Fortress eingesetzt, vgl. Abschnitt 7.1.
2.3 Parallele Programmiermodelle
33
Explizite Parallelit¨ at mit impliziter Zerlegung liegt vor, wenn der Programmierer zwar angibt, wo im Programm Potential f¨ ur eine parallele Bearbeitung vorliegt, etwa eine parallele Schleife, die explizite Kodierung in Threads oder Prozesse aber nicht vornehmen muss. Viele parallele FORTRAN-Erweiterungen nutzen dieses Prinzip. Bei einer expliziten Zerlegung muss der Programmierer zus¨ atzlich angeben, welche Tasks es f¨ ur die parallele Abarbeitung geben soll, ohne aber eine Zuordnung an Prozesse oder explizite Kommunikation formulieren zu m¨ ussen. Ein Beispiel ist BSP [65]. Die explizite Zuordnung der Tasks an Prozesse wird in Koordinationssprachen wie Linda [12] zus¨ atzlich angegeben. Bei Programmiermodellen mit expliziter Kommunikation und Synchronisation muss der Programmierer alle Details der parallelen Abarbeitung angeben. Hierzu geh¨ ort das Message-Passing-Programmiermodell mit MPI, aber auch Programmierumgebungen zur Benutzung von Threads, wie Pthreads, das in Kap. 4 vorgestellt wird. Angabe paralleler Programmteile Sollen vom Programmierer die parallelen Programmteile explizit angegeben werden, so kann dies in ganz unterschiedlicher Form erfolgen. Bei der Angabe von Teilaufgaben in Form von Tasks werden diese implizit Prozessoren oder Kernen zugeordnet. Bei vollkommen explizit paralleler Programmierung sind die Programmierung von Threads oder von Prozessen die weit verbreiteten Formen. Thread-Programmierung: Ein Thread ist eine Folge von Anweisungen, die parallel zu anderen Anweisungsfolgen, also Threads, abgearbeitet werden k¨ onnen. Die Threads eines einzelnen Programmes besitzen f¨ ur die Abarbeitung jeweils eigene Ressourcen, wie Programmz¨ahler, Sta-
34
2 Konzepte paralleler Programmierung
tusinformationen des Prozessors oder einen Stack f¨ ur lokale Daten, nutzen jedoch einen gemeinsamen Datenspeicher. Damit ist das Thread-Modell f¨ ur die Programmierung von Multicore-Prozessoren geeignet. Message-Passing-Programmierung: Die MessagePassing-Programmierung nutzt Prozesse, die Programmteile bezeichnen, die jeweils auf einem separaten physikalischen oder logischen Prozessor abgearbeitet werden und somit jeweils einen privaten Adressraum besitzen. Abarbeitung paralleler Programmteile Die Abarbeitung paralleler Programmteile kann synchron erfolgen, indem die Anweisungen paralleler Threads oder Prozesse jeweils gleichzeitig abgearbeitet werden, wie etwa im SIMD-Modell, oder asynchron, also unabh¨angig voneinander bis eine explizite Synchronisation erfolgt, wie etwa im SPMD-Modell. Diese Festlegung der Abarbeitung wird meist vom Programmiermodell der benutzten Programmierumgebung vorgegeben. Dar¨ uber hinaus gibt es eine Reihe von Programmiermustern, in denen parallele Programmteile angeordnet werden, z.B. Pipelining, MasterWorker oder Produzenten-Konsumenten-Modell, und die vom Softwareentwickler explizit ausgew¨ ahlt werden. Informationsaustausch Ein wesentliches Merkmal f¨ ur den Informationsaustausch ist die Organisation des Adressraums. Bei einem verteilten Adressraum werden Daten durch Kommunikation ausgetauscht. Dies kann explizit im Programm angegeben sein, aber auch durch einen Compiler oder das Laufzeitsystem erzeugt werden. Bei einem gemeinsamen Adressraum kann
2.4 Parallele Leistungsmaße
35
der Informationsaustausch einfach u ¨ ber gemeinsame Variable in diesem Adressraum geschehen, auf die lesend oder schreibend zugegriffen werden kann. Hierdurch kann es jedoch auch zu Konflikten oder unerw¨ unschten Ergebnissen kommen, wenn dies unkoordiniert erfolgt. Die Koordination von parallelen Programmteilen spielt also eine wichtige Rolle bei der Programmierung eines gemeinsamen Adressraums und ist daher ein wesentlicher Bestandteil der Thread-Programmierung und der Programmierung von Multicore-Prozessoren. Formen der Synchronisation Synchronisation gibt es in Form von Barrier-Synchronisation, die bewirkt, dass alle beteiligten Threads oder Prozesse aufeinander warten, und im Sinne der Koordination von Threads. Letzteres hat insbesondere mit der Vermeidung von Konflikten beim Zugriff auf einen gemeinsamen Adressraum zu tun und setzt Sperrmechanismen und bedingtes Warten ein.
2.4 Parallele Leistungsmaße Ein wesentliches Kriterium zur Bewertung eines parallelen Programms ist dessen Laufzeit. Die parallele Laufzeit Tp (n) eines Programmes ist die Zeit zwischen dem Start der Abarbeitung des parallelen Programmes und der Beendigung der Abarbeitung aller beteiligten Prozessoren. Die parallele Laufzeit wird meist in Abh¨ angigkeit von der Anzahl p der zur Ausf¨ uhrung benutzten Prozessoren und einer Problemgr¨ oße n angegeben, die z.B. durch die Gr¨oße der Eingabe gegeben ist. F¨ ur Multicore-Prozessoren mit gemeinsamem Adressraum setzt sich die Laufzeit eines parallelen Programmes zusammen aus:
36
• • •
2 Konzepte paralleler Programmierung
der Zeit f¨ ur die Durchf¨ uhrung von Berechnungen durch die Prozessorkerne, der Zeit f¨ ur die Synchronisation beim Zugriff auf gemeinsame Daten, den Wartezeiten, die z.B. wegen ungleicher Verteilung der Last oder an Synchronisationspunkten entstehen.
Kosten: Die Kosten eines parallelen Programmes, h¨aufig auch Arbeit oder Prozessor-Zeit-Produkt genannt, ber¨ ucksichtigen die Zeit, die alle an der Ausf¨ uhrung beteiligten Prozessoren insgesamt zur Abarbeitung des Programmes verwenden. Die Kosten Cp (n) eines parallelen Programms sind definiert als Cp (n) = Tp (n) · p und sind damit ein Maß f¨ ur die von allen Prozessoren durchgef¨ uhrte Arbeit. Ein paralleles Programm heißt kostenoptimal, wenn Cp (n) = T ∗ (n) gilt, d.h. wenn insgesamt genauso viele Operationen ausgef¨ uhrt werden wie vom schnellsten sequentiellen Verfahren, das Laufzeit T ∗ (n) hat. Speedup: Zur Laufzeitanalyse paralleler Programme ist insbesondere ein Vergleich mit einer sequentiellen Implementierung von Interesse, um den Nutzen des Einsatzes der Parallelverarbeitung absch¨ atzen zu k¨ onnen. F¨ ur einen solchen Vergleich wird oft der Speedup-Begriff als Maß f¨ ur den relativen Geschwindigkeitsgewinn herangezogen. Der Speedup Sp (n) eines parallelen Programmes mit Laufzeit Tp (n) ist definiert als Sp (n) =
T ∗ (n) , Tp (n)
wobei p die Anzahl der Prozessoren zur L¨ osung des Problems der Gr¨ oße n bezeichnet. Der Speedup einer parallelen Implementierung gibt also den relativen Geschwindigkeitsvorteil an, der gegen¨ uber der besten sequentiellen
2.4 Parallele Leistungsmaße
37
Implementierung durch den Einsatz von Parallelverarbeitung auf p Prozessoren entsteht. Theoretisch gilt immer Sp (n) ≤ p. Durch Cacheeffekte kann in der Praxis auch der Fall Sp (n) > p (superlinearer Speedup) auftreten. Effizienz: Alternativ zum Speedup kann der Begriff der Effizienz eines parallelen Programmes benutzt werden, der ein Maß f¨ ur den Anteil der Laufzeit ist, den ein Prozessor f¨ ur Berechnungen ben¨ otigt, die auch im sequentiellen Programm vorhanden sind. Die Effizienz Ep (n) eines parallelen Programms ist definiert als Ep (n) =
Sp (n) T ∗ (n) T ∗ (n) = = Cp (n) p p · Tp (n)
wobei T ∗ (n) die Laufzeit des besten sequentiellen Algorithmus und Tp (n) die parallele Laufzeit ist. Liegt kein superlinearer Speedup vor, gilt Ep (n) ≤ 1. Der ideale Speedup Sp (n) = p entspricht einer Effizienz Ep (n) = 1. Amdahlsches Gesetz: Die m¨ ogliche Verringerung von Laufzeiten durch eine Parallelisierung sind oft begrenzt. So stellt etwa die Anzahl der Prozessoren die theoretisch obere Schranke des Speedups dar. Weitere Begrenzungen liegen im zu parallelisierenden Algorithmus selber begr¨ undet, der neben parallelisierbaren Anteilen auch durch Datenabh¨ angigkeiten bedingte, inh¨ arent sequentielle Anteile enthalten kann. Der Effekt von Programmteilen, die sequentiell ausgef¨ uhrt werden m¨ ussen, auf den erreichbaren Speedup wird durch das Amdahlsche Gesetz quantitativ erfasst [6]: Wenn bei einer parallelen Implementierung ein (konstanter) Bruchteil f (0 ≤ f ≤ 1) sequentiell ausgef¨ uhrt werden muss, setzt sich die Laufzeit der parallelen Implementierung aus der Laufzeit f · T ∗ (n) des sequentiellen Teils und der Laufzeit des parallelen Teils, die mindestens agt, zusammen. F¨ ur den erreichbaren (1 − f )/p · T ∗ (n) betr¨
38
2 Konzepte paralleler Programmierung
Speedup gilt damit Sp (n) =
1 T ∗ (n) 1 = ≤ . 1−f ∗ 1−f ∗ f f · T (n) + p T (n) f+ p
Bei dieser Berechnung wird der beste sequentielle Algorithmus verwendet und es wurde angenommen, dass sich der parallel ausf¨ uhrbare Teil perfekt parallelisieren l¨asst. Durch ein einfaches Beispiel sieht man, dass nicht parallelisierbare Berechnungsteile einen großen Einfluss auf den erreichbaren Speedup haben: Wenn 20% eines Programmes sequentiell abgearbeitet werden m¨ ussen, betr¨ agt nach Aussage des Amdahlschen Gesetzes der maximal erreichbare Speedup 5, egal wie viele Prozessoren eingesetzt werden. Nicht parallelisierbare Teile m¨ ussen insbesondere bei einer großen Anzahl von Prozessoren besonders beachtet werden. Skalierbarkeit: Das Verhalten der Leistung eines parallelen Programmes bei steigender Prozessoranzahl wird durch die Skalierbarkeit erfasst. Die Skalierbarkeit eines parallelen Programmes auf einem gegebenen Parallelrechner ist ein Maß f¨ ur die Eigenschaft, einen Leistungsgewinn proportional zur Anzahl p der verwendeten Prozessoren zu erreichen. Der Begriff der Skalierbarkeit wird in unterschiedlicher Weise pr¨ azisiert, z.B. durch Einbeziehung der Problemgr¨ oße n. Eine h¨ aufig beobachtete Eigenschaft paralleler Algorithmen ist es, dass f¨ ur festes n und steigendes p eine S¨ attigung des Speedups eintritt, dass aber f¨ ur festes p und steigende Problemgr¨ oße n ein h¨ oherer Speedup erzielt wird. In diesem Sinne bedeutet Skalierbarkeit, dass die Effizienz eines parallelen Programmes bei gleichzeitigem Ansteigen von Prozessoranzahl p und Problemgr¨oße n konstant bleibt.
3 Thread-Programmierung
Die Programmierung von Multicore-Prozessoren ist eng mit der parallelen Programmierung eines gemeinsamen Adressraumes und der Thread-Programmierung verbunden. Mehrere Berechnungsstr¨ ome desselben Programms k¨onnen parallel zueinander bearbeitet werden und greifen dabei auf Variablen des gemeinsamen Speichers zu. Diese Berechnungsstr¨ ome werden als Threads bezeichnet. Die Programmierung mit Threads ist ein seit vielen Jahren bekanntes Programmierkonzept [9] und kann vom Softwareentwickler durch verschiedene Programmierumgebungen oder -bibliotheken wie Pthreads, Java-Threads, OpenMP oder Win32 f¨ ur Multithreading-Programme genutzt werden.
3.1 Threads und Prozesse Die Abarbeitung von Threads h¨ angt eng mit der Abarbeitung von Prozessen zusammen, so dass beide zun¨achst nochmal genauer definiert und voneinander abgegrenzt werden.
40
3 Thread-Programmierung
Prozesse Ein Prozess ist ein sich in Ausf¨ uhrung befindendes Programm und umfasst neben dem ausf¨ uhrbaren Programmcode alle Informationen, die zur Ausf¨ uhrung des Programms erforderlich sind. Dazu geh¨ oren die Daten des Programms auf dem Laufzeitstack oder Heap, die zum Ausf¨ uhrungszeitpunkt aktuellen Registerinhalte und der aktuelle Wert des Programmz¨ ahlers, der die n¨ achste auszuf¨ uhrende Instruktion des Prozesses angibt. Jeder Prozess hat also seinen eigenen Adressraum. Alle diese Informationen ¨andern sich w¨ ahrend der Ausf¨ uhrung des Prozesses dynamisch. Wird die Rechenressource einem anderen Prozess zugeordnet, so muss der Zustand des suspendierten Prozesses gespeichert werden, damit die Ausf¨ uhrung dieses Prozesses zu einem sp¨ ateren Zeitpunkt mit genau diesem Zustand fortgesetzt werden kann. Dies wird als Kontextwechsel bezeichnet und ist je nach Hardwareunterst¨ utzung relativ aufwendig [54]. Prozesse werden bei Multitasking im Zeitscheibenverfahren von der Rechenressource abgearbeitet; es handelt sich also um Nebenl¨ aufigkeit und keine Gleichzeitigkeit. Bei Multiprozessor-Systemen ist eine tats¨ achliche Parallelit¨at m¨ oglich. Beim Erzeugen eines Prozesses muss dieser die zu seiner Ausf¨ uhrung erforderlichen Daten erhalten. Im UNIXBetriebssystem kann ein Prozess P1 mit Hilfe einer forkAnweisung einen neuen Prozess P2 erzeugen. Der neue Kindprozess P2 ist eine identische Kopie des Elternprozesses P1 zum Zeitpunkt des fork-Aufrufes. Dies bedeutet, dass der Kindprozess auf einer Kopie des Adressraumes des Elternprozesses arbeitet und das gleiche Programm wie der Elternprozess ausf¨ uhrt, und zwar ab der der forkAnweisung folgenden Anweisung. Der Kindprozess hat jedoch eine eigene Prozessnummer und kann in Abh¨angigkeit
3.1 Threads und Prozesse
41
von dieser Prozessnummer andere Anweisungen als der Elternprozess ausf¨ uhren, vgl. [46]. Da jeder Prozess einen eigenen Adressraum hat, ist die Erzeugung und Verwaltung von Prozessen je nach Gr¨ oße des Adressraumes relativ zeitaufwendig. Weiter kann bei h¨ aufiger Kommunikation der Austausch von Daten (¨ uber Sockets) einen nicht unerheblichen Anteil der Ausf¨ uhrungszeit ausmachen. Threads Das Threadmodell ist eine Erweiterung des Prozessmodells. Jeder Prozess besteht anstatt nur aus einem aus mehreren unabh¨ angigen Berechnungsstr¨ omen, die w¨ ahrend der Abarbeitung des Prozesses durch ein Schedulingverfahren der Rechenressource zugeteilt werden. Die Berechnungsstr¨ome eines Prozesses werden als Threads bezeichnet. Das Wort Thread wurde gew¨ ahlt, um anzudeuten, dass eine zusammenh¨ angende, evtl. sehr lange Folge von Instruktionen abgearbeitet wird. Ein wesentliches Merkmal von Threads besteht darin, dass die verschiedenen Threads eines Prozesses sich den Adressraum des Prozesses teilen, also einen gemeinsamen Adressraum haben. Wenn ein Thread einen Wert im Adressraum ablegt, kann daher ein anderer Thread des gleichen Prozesses diesen unmittelbar darauf lesen. Damit ist der Informationsaustausch zwischen Threads im Vergleich zur Kommunikation zwischen Prozessen u ¨ ber Sockets sehr schnell. Da die Threads eines Prozesses sich einen Adressraum teilen, braucht auch die Erzeugung von Threads wesentlich weniger Zeit als die Erzeugung von Prozessen. Das Kopieren des Adressraumes, das z.B. in UNIX beim Erzeugen von Prozessen mit einer fork-Anweisung notwendig ist, entf¨ allt. Das Arbeiten mit mehreren Threads innerhalb eines Prozesses ist somit wesentlich flexibler als das Arbei-
42
3 Thread-Programmierung
ten mit kooperierenden Prozessen, bietet aber die gleichen Vorteile. Insbesondere ist es m¨ oglich, die Threads eines Prozesses auf verschiedenen Prozessoren oder Prozessorkernen parallel auszuf¨ uhren. Threads k¨ onnen auf Benutzerebene als Benutzer-Threads oder auf Betriebssystemebene als BetriebssystemThreads implementiert werden. Threads auf Benutzerebene werden durch eine Thread-Bibliothek ohne Beteiligung des Betriebssystems verwaltet. Ein Wechsel des ausgef¨ uhrten Threads kann damit ohne Beteiligung des Betriebssystems erfolgen und ist daher in der Regel wesentlich schneller als der Wechsel bei Betriebssystem-Threads. T T
Bibliotheks− Scheduler
BP
T T
Prozess 1 T T T
Prozess n
BP
Bibliotheks− Scheduler
Betriebssystem− Scheduler
BP
P
BP
P
BP
P
BP
P
BP
Prozessoren
Betriebssystem− Prozesse
Abbildung 3.1. N:1-Abbildung – Thread-Verwaltung ohne Betriebssystem-Threads. Der Scheduler der Thread-Bibliothek w¨ ahlt den auszuf¨ uhrenden Thread T des Benutzerprozesses aus. Jedem Benutzerprozess ist ein Betriebssystemprozss BP zugeordnet. Der Betriebssystem-Scheduler w¨ ahlt die zu einem bestimmten Zeitpunkt auszuf¨ uhrenden Betriebssystemprozesse aus und bildet diese auf die Prozessoren P ab.
3.1 Threads und Prozesse
43
Der Nachteil von Threads auf Benutzerebene liegt darin, dass das Betriebssystem keine Kenntnis von den Threads hat und nur gesamte Prozesse verwaltet. Wenn ein Thread eines Prozesses das Betriebssystem aufruft, um z.B. eine I/O-Operation durchzuf¨ uhren, wird der CPU-Scheduler des Betriebssystems den gesamten Prozess suspendieren und die Rechenressource einem anderen Prozess zuteilen, da das Betriebssystem nicht weiß, dass innerhalb des Prozesses zu einem anderen Thread umgeschaltet werden kann. Dies gilt f¨ ur Betriebssystem-Threads nicht, da das Betriebssystem die Threads direkt verwaltet. T
BT
T
BT
T
Betriebssystem− Scheduler
BT
P
BT
P
BT
P
T
BT
P
T
BT
T
Prozess 1
T
Prozess n
Prozessoren
Betriebssystem− Threads
Abbildung 3.2. 1:1-Abbildung – Thread-Verwaltung mit Betriebssystem-Threads. Jeder Benutzer-Thread T wird eindeutig einem Betriebssystem-Thread BT zugeordnet.
44
3 Thread-Programmierung T T
Bibliotheks− Scheduler
BT
T T
Prozess 1 T T T
Prozess n
BT
Betriebssystem− Scheduler
BT
P
BT
P
BT
P
BT
P
BT
Bibliotheks− Scheduler
Prozessoren
Betriebssystem− Threads
Abbildung 3.3. N:M-Abbildung – Thread-Verwaltung mit Betriebssystem-Threads und zweistufigem Scheduling. BenutzerThreads T verschiedener Prozesse werden einer Menge von Betriebssystem-Threads BT zugeordnet (N:M-Abbildung).
Ausf¨ uhrungsmodelle f¨ ur Threads Wird eine Thread-Verwaltung durch das Betriebssystem nicht unterst¨ utzt, so ist die Thread-Bibliothek f¨ ur das Scheduling der Threads verantwortlich. Alle Benutzer-Threads eines Prozesses werden vom Bibliotheks-Scheduler auf einen Betriebssystem-Prozess abgebildet, was N:1-Abbildung genannt wird, siehe Abb. 3.1. Stellt das Betriebssystem eine Thread-Verwaltung zur Verf¨ ugung, so gibt es f¨ ur die Abbildung von Benutzer-Threads auf Betriebssystem-Threads zwei M¨ oglichkeiten: Die erste ist die 1:1-Abbildung, die f¨ ur jeden Benutzer-Thread einen Betriebssystem-Thread erzeugt, siehe Abb. 3.2. Der Betriebssystem-Scheduler w¨ahlt den jeweils auszuf¨ uhrenden Betriebssystem-Thread aus und verwaltet bei Mehr-Prozessor-Systemen die Ausf¨ uhrung der Betriebssystem-Threads auf den verschiedenen Prozesso-
3.1 Threads und Prozesse
45
ren. Die zweite M¨ oglichkeit ist die N:M-Abbildung, die ein zweistufiges Schedulingverfahren anwendet, siehe Abb. 3.3. Der Scheduler der Thread-Bibliothek ordnet die verschiedenen Threads der verschiedenen Prozesse einer vorgegebenen Menge von Betriebssystem-Threads zu, wobei ein Benutzer-Thread zu verschiedenen Zeitpunkten auf verschiedene Betriebssystem-Threads abgebildet werden kann. Zust¨ ande eines Threads Ob ein Thread gerade von einem Prozessor oder Prozessorkern abgearbeitet wird, h¨ angt nicht nur vom Scheduler, sondern auch von seinem Zustand ab. Threads k¨onnen sich in verschiedenen Zust¨ anden befinden: • • • • •
neu erzeugt lauff¨ ahig laufend wartend beendet
beendet
neu
Start
Ende Unterbrechung
lauf− fähig
laufend Zuteilung
Au Abbildung 3.4 verg un fw er ec ki ke anschaulicht die Zuoc l n B stands¨ uberg¨ ange. Die wartend ¨ Uberg¨ ange zwischen lauff¨ahig und laufend Abbildung 3.4. Zust¨ande eines Threwerden vom Schedu- ads. ler bestimmt. Blockierung bzw. Warten kann durch I/OOperationen, aber auch durch die Koordination zwischen den Threads eintreten.
Sichtbarkeit von Daten Die Threads eines Prozesses teilen sich einen gemeinsamen Adressraum, d.h. die globalen Variablen und alle dynamisch
46
3 Thread-Programmierung Stackdaten Stackdaten Stackdaten
} } }
Stackbereich für Hauptthread Stackbereich für Thread 1 Stackbereich für Thread 2
... Heapdaten globale Daten Programmcode
Adresse 0
Abbildung 3.5. Laufzeitverwaltung f¨ ur ein Programm mit mehreren Threads.
erzeugten Datenobjekte sind von allen erzeugten Threads des Prozesses zugreifbar. F¨ ur jeden Thread wird jedoch ein eigener Laufzeitstack gehalten, auf dem die von dem Thread aufgerufenen Funktionen mit ihren lokalen Variablen verwaltet werden, siehe Abb. 3.5. Die auf dem Laufzeitstack verwalteten, also statisch deklarierten Daten, sind lokale Daten des zugeh¨ origen Threads und k¨ onnen von anderen Threads nicht zugegriffen werden. Da der Laufzeitstack eines Threads nur so lange existiert, wie der Thread selbst, kann ein Thread einen m¨ oglichen R¨ uckgabewert an einen anderen Thread nicht u ¨ber seinen Laufzeitstack u ¨ bergeben und es m¨ ussen andere Techniken verwendet werden.
3.2 Synchronisations-Mechanismen Wichtig bei der Thread-Programmierung ist die Koordination der Threads, die durch Synchronisations-Mechanismen erreicht wird. Die Koordination der Threads eines Multithreading-Programms wird vom Softwareentwick-
3.2 Synchronisations-Mechanismen
47
ler eingesetzt, um eine gew¨ unschte Ausf¨ uhrungsreihenfolge der beteiligten Threads zu erreichen oder um den Zugriff auf gemeinsame Daten zu gestalten. Die Koordination des Zugriffs auf den gemeinsamen Speicher dient der Vermeidung von unerw¨ unschten Effekten beim gleichzeitigen Zugriff auf dieselbe Variable. Dies trifft f¨ ur MultithreadingProgramme auf einer Rechenressource mit nebenl¨aufiger Abarbeitung im Zeitscheibenverfahren zu, aber auch auf eine tats¨ achlich parallele Abarbeitung auf mehreren Rechenressourcen. Da die Threads eines Prozesses im Wesentlichen u ¨ ber gemeinsame Daten kooperieren, bewirkt eine bestimmte Ausf¨ uhrungsreihenfolge einen speziellen Zustand des gemeinsamen Speichers, also eine bestimmte Belegung der gemeinsamen Variablen mit Werten, die f¨ ur alle Threads sichtbar sind. Die Effekte der Kooperation und Koordination k¨ onnen jedoch bei Nebenl¨ aufigkeit anders sein als bei Parallelit¨ at. Koordination der Berechnungsstr¨ ome Eine Barrier-Synchronisation bewirkt, dass alle beteiligten Threads aufeinander warten und keiner der Threads eine nach der Synchronisationsanweisung stehende Anweisung ausf¨ uhrt, bevor alle anderen Threads diese erreicht haben. Dadurch erscheint den Threads der gemeinsame Speicher aller beteiligten Threads in dem Zustand, der durch die Abarbeitung aller Anweisungen aller Threads vor der Barrier-Synchronisation erreicht wird. Zeitkritische Abl¨ aufe Das lesende und schreibende Zugreifen verschiedener Threads auf dieselbe gemeinsame Variable kann zu sogenannten zeitkritischen Abl¨ aufen f¨ uhren. Dies bedeutet, dass
48
3 Thread-Programmierung
das Ergebnis der Ausf¨ uhrung eines Programmst¨ ucks durch mehrere Threads von der relativen Ausf¨ uhrungsgeschwindigkeit der Threads zueinander abh¨ angt: Wenn das Programmst¨ uck zuerst von Thread T1 und dann von Thread T2 ausgef¨ uhrt wird, kann ein anderes Ergebnis berechnet werden als wenn dieses Programmst¨ uck zuerst von Thread uhrt wird. Das AufT2 und dann von Thread T1 ausgef¨ treten von zeitkritischen Abl¨ aufen ist meist unerw¨ unscht, da die relative Ausf¨ uhrungsreihenfolge von vielen Faktoren abh¨ angen kann (z.B. der Ausf¨ uhrungsgeschwindigkeit der Prozessoren, dem Auftreten von Interrupts oder der Belegung der Eingabedaten), die vom Programmierer nur bedingt zu beeinflussen sind. Es entsteht ein nichtdeterministisches Verhalten, da f¨ ur die Ausf¨ uhrungsreihenfolge und das Ergebnis verschiedene M¨ oglichkeiten eintreten k¨ onnen, ohne dass dies vorhergesagt werden kann. Kritischer Bereich Ein Programmst¨ uck, in dem Zugriffe auf gemeinsame Variablen vorkommen, die auch konkurrierend von anderen Threads zugegriffen werden k¨ onnen, heißt kritischer Bereich. Eine fehlerfreie Abarbeitung kann dadurch gew¨ahrleistet werden, dass durch einen wechselseitigen Ausschluss (oder mutual exclusion) jeweils nur ein Thread auf Variablen zugreifen kann, die in einem kritischen Bereich liegen. Programmiermodelle f¨ ur einen gemeinsamen Adressraum stellen Operationen und Mechanismen zur Sicherstellung des wechselseitigen Ausschlusses zur Verf¨ ugung, mit denen erreicht werden kann, dass zu jedem Zeitpunkt nur ein Thread auf eine gemeinsame Variable zugreift. Die grundlegenden Mechanismen, die angeboten werden, sind Sperrmechanismus und Bedingungs-Synchronisation.
3.2 Synchronisations-Mechanismen
49
Sperrmechanismus Zur Vermeidung des Auftretens zeitkritischer Abl¨aufe mit Hilfe eines Sperrmechanismus wird eine Sperrvariable (oder Mutex-Variable von mutual exclusion) s eines speziell vorgegebenen Typs verwendet, die mit den Funktionen lock(s) und unlock(s) angesprochen wird. Vor Betreten des kritischen Bereichs f¨ uhrt der Thread lock(s) zur Belegung der Sperrvariable s aus; nach Verlassen des Programmsegments wird unlock(s) zur Freigabe der Sperrvariable aufgerufen. Nur wenn jeder Prozessor diese Vereinbarung einh¨alt, werden zeitkritische Abl¨ aufe vermieden. Der Aufruf lock(s) hat den Effekt, dass der aufrufende Thread T1 nur dann das dieser Sperrvariablen zugeordnete Programmsegment ausf¨ uhren kann, wenn gerade kein anderer Thread diese Sperrvariable belegt hat. Hat jedoch ein uhrt und die Sperranderer Thread T2 zuvor lock(s) ausgef¨ variable s noch nicht mit unlock(s) wieder freigegeben, so wird Thread T1 so lange blockiert, bis Thread T2 unlock(s) aufruft. Der Aufruf unlock(s) bewirkt neben der Freigabe der Sperrvariablen auch das Aufwecken anderer bzgl. der Sperrvariablen s blockierter Threads. Die Verwendung eines Sperrmechanismus kann also zu einer Sequentialisierung f¨ uhren, da Threads durch ihn nur nacheinander auf eine gemeinsame Variable zugreifen k¨ onnen. Sperrmechanismen sind in Laufzeitbibliotheken wie Pthreads, JavaThreads oder OpenMP auf leicht unterschiedliche Art realisiert. Bedingungs-Synchronisation Bei einer Bedingungs-Synchronisation wird ein Thread T1 so lange blockiert bis eine bestimmte Bedingung eingetreten ist. Das Aufwecken des blockierten Threads kann
50
3 Thread-Programmierung
nur durch einen anderen Thread T2 erfolgen. Dies geschieht sinnvollerweise nachdem durch die Ausf¨ uhrung von Thread T2 diese Bedingung eingetreten ist. Da jedoch mehrere Threads auf dem gemeinsamen Adressraum arbeiten und dadurch zwischenzeitlich wieder Ver¨ anderungen der Bedingung erfolgt sein k¨ onnten, muss die Bedingung durch den uft werden. Die aufgeweckten Thread T1 nochmal u ¨ berpr¨ Bedingungs-Synchronisation wird durch Bedingungsvariablen realisiert; zum Schutz vor zeitkritischen Abl¨aufen wird zus¨ atzlich ein Sperrmechanismus verwendet. Semaphor-Mechanismus Ein weiterer Mechanismus zur Realisierung eines wechselseitigen Ausschlusses ist der Semaphor [19]. Ein Semaphor ist eine Struktur, die eine Integervariable s enth¨alt, auf die zwei atomare Operationen P (s) und V (s) angewendet werden k¨ onnen. Ein bin¨ arer Semaphor kann nur die Werte 0 und 1 annehmen. Werden weitere Werte angenommen, spricht man von einem z¨ ahlenden Semaphor. Die Operation P (s) (oder wait(s)) wartet bis der Wert von s gr¨oßer als 0 ist, dekrementiert den Wert von s anschließend um 1 und erlaubt dann die weitere Ausf¨ uhrung der nachfolgenden Berechnungen. Die Operation V (s) (oder signal(s)) inkrementiert den Wert von s um 1. Der genaue Mechanismus der Verwendung von P und V zum Schutz eines kritischen Bereiches ist nicht streng festgelegt. Eine u ¨ bliche Form ist: wait(s) kritischer Bereich signal(s) Verschiedene Threads f¨ uhren die Operationen P und V auf s aus und koordinieren so ihren Zugriff auf kritische Berei-
3.3 Effiziente und korrekte Thread-Programme
51
che. F¨ uhrt etwa Threads T1 die Operation wait(s) aus um danach seinen kritischen Bereich zu bearbeiten, so wird jeder andere Threads T2 beim Aufruf von wait(s) am Eintritt in seinen kritischen Bereich so lange gehindert, bis T1 die Operation signal(s) ausf¨ uhrt. Monitor Ein abstrakteres Konzept stellt der Monitor dar [31]. Ein Monitor ist ein Sprachkonstrukt, das Daten und Operationen, die auf diese Daten zugreifen, in einer Struktur zusammenfasst. Auf die Daten eines Monitors kann nur durch dessen Monitoroperationen zugegriffen werden. Da zu jedem Zeitpunkt die Ausf¨ uhrung nur einer Monitoroperation erlaubt ist, wird der wechselseitige Ausschluss bzgl. der Daten des Monitors automatisch sichergestellt.
3.3 Effiziente und korrekte Thread-Programme Je nach Applikation kann durch Synchronisation eine enge und komplizierte Verzahnung von Threads entstehen, was zu Problemen wie Leistungseinbußen durch Sequentialisierung oder sogar zu Deadlocks f¨ uhren kann. Anzahl der Threads und Sequentialisierung Die Laufzeit eines parallelen Programms kann je nach Entwurf und Umsetzung sehr verschieden sein. Um ein effizientes paralleles Programm zu erhalten, sollte schon beim Entwurf darauf geachtet werden, dass •
eine geeignete Anzahl von Threads genutzt wird und
52
•
3 Thread-Programmierung
Sequentialisierungen nach M¨ oglichkeit vermieden werden.
Die Erzeugung von Threads bewirkt Parallelit¨at, so dass eine hinreichend große Anzahl von Threads im parallelen Programm vorhanden sein sollte, um alle Prozessorkerne mit Arbeit zu versorgen und so die verf¨ ugbaren parallelen Ressourcen gut auszunutzen. Andererseits sollte die Anzahl der Threads auch nicht zu groß werden, da erstens der Anteil der Arbeit f¨ ur einen einzelnen Thread im Vergleich zum Overhead f¨ ur Erzeugung, Verwaltung und Terminierung des Threads zu gering werden kann, und da zweitens viele Hardwareressourcen (vor allem Caches) von den Prozessorkernen geteilt werden und es so zu Leistungsverlusten bei der Lese/Schreib-Bandbreite kommen kann. Aufgrund der notwendigen Kooperationen zwischen den Threads kann die vorgegebene Parallelit¨ at nicht immer ausgenutzt werden, da zur Vermeidung von zeitkritischen Abl¨ aufen Synchronisations-Mechanismen eingesetzt werden m¨ ussen. Bei h¨ aufiger Synchronisation kann es jedoch dazu kommen, dass immer nur einer oder wenige der Threads aktiv sind, w¨ ahrend alle anderen auf Grund der Synchronisation warten, so dass eine Nacheinanderausf¨ uhrung, also Sequentialisierung, auftritt. Deadlock Die Nutzung von Sperr- und anderen SynchronisationsMechanismen hilft Nichtdeterminismus und zeitkritische Abl¨ aufe zu vermeiden. Die Nutzung von Sperren kann jedoch zu einem Deadlock (Verklemmung) im Anwendungsprogramm f¨ uhren, wenn die Abarbeitung in einen Zustand kommt, in dem jeder Thread auf ein Ereignis wartet, das nur von einem anderen Thread ausgel¨ ost werden kann, der aber auch vergeblich auf ein Ereignis wartet.
3.3 Effiziente und korrekte Thread-Programme
53
Allgemein ist ein Deadlock f¨ ur eine Menge von Aktivit¨ aten dann gegeben, wenn jede der Aktivit¨ aten auf ein Ereignis wartet, das nur durch eine der anderen Aktivit¨aten hervorgerufen werden kann, so dass ein Zyklus des gegenseitigen Aufeinanderwartens entsteht. Ein Beispiel f¨ ur einen Deadlock ist folgende Situation: • •
Thread T1 versucht zuerst Sperre s1 und dann Sperre s2 zu belegen; nach Sperrung von s1 wird er unterbrochen; Thread T2 versucht zuerst Sperre s2 und dann Sperre s1 zu belegen; nach Sperrung von s2 wird er unterbrochen;
Nachdem T1 Sperre s1 und T2 Sperre s2 belegt hat, warten beide Threads auf die Freigabe der fehlenden Sperre durch den jeweils anderen Thread, die nicht eintreten kann. Die Verwendung von Sperrmechanismen sollte also gut geplant sein, um diesen Fall etwa durch eine spezielle Reihenfolge der Sperrbelegung zu vermeiden, vgl. auch [59]. Speicherzugriffszeiten und Cacheeffekte Speicherzugriffszeiten k¨ onnen einen hohen Anteil an der parallelen Laufzeit eines Programms haben. Die Speicherzugriffe eines Programms f¨ uhren zum Transfer von Daten zwischen Speicher und den Caches der Prozessorkerne. Dieser Datentransfer wird durch Lese- und Schreiboperationen der Kerne ausgel¨ ost und kann nicht direkt vom Programmierer gesteuert werden. Zwischen Datenzugriffen verschiedener Prozessorkerne k¨ onnen verschiedene Abh¨ angigkeiten auftreten: Lese-LeseAbh¨ angigkeiten, Lese-Schreib-Abh¨ angigkeiten und SchreibSchreib-Abh¨ angigkeiten. Lesen zwei Prozessorkerne dieselben Daten, so kann dies evtl. ohne Speicherzugriff aus den jeweiligen Caches erfolgen. Die beiden anderen Abh¨angigkeiten l¨ osen Speicherzugriffe aus, da die Daten zwischen
54
3 Thread-Programmierung
den Prozessorkernen ausgetauscht werden m¨ ussen. Die Anzahl der Speicherzugriffe kann reduziert werden, indem der Zugriff der Prozessorkerne auf gemeinsame Daten so gestaltet wird, dass die Kerne auf verschiedene Daten zugreifen. Dies sollte bereits beim Entwurf des parallelen Programms ber¨ ucksichtigt werden. False Sharing, bei dem zwei verschiedene Threads auf verschiedene Daten zugreifen, die jedoch in derselben Cachezeile liegen, l¨ ost jedoch ebenfalls Speicheroperationen aus. False Sharing kann vom Programmierer nur schwer beeinflusst werden, da auch eine weit auseinandergezogene Abspeicherung von Daten nicht immer zum Erfolg f¨ uhrt.
3.4 Parallele Programmiermuster Parallele oder verteilte Programme bestehen aus einer Ansammlung von Tasks, die in Form von Threads auf verschiedenen Rechenressourcen ausgef¨ uhrt werden. Zur Strukturierung der Programme k¨ onnen parallele Muster verwendet werden, die sich in der parallelen Programmierung als sinnvoll herausgestellt haben, siehe z.B. [56] oder [45]. Diese Muster geben eine spezielle Koordinationsstruktur der beteiligten Threads vor. Erzeugung von Threads Die Erzeugung von Threads kann statisch oder dynamisch erfolgen. Im statischen Fall wird meist eine feste Anzahl von Threads zu Beginn der Abarbeitung des parallelen Programms erzeugt, die w¨ ahrend der gesamten Abarbeitung existieren und erst am Ende des Gesamtprogramms beendet werden. Alternativ k¨ onnen Threads zu jedem Zeitpunkt der Programmabarbeitung (statisch oder dynamisch) erzeugt
3.4 Parallele Programmiermuster
55
und beendet werden. Zu Beginn der Abarbeitung ist meist nur ein einziger Thread aktiv, der das Hauptprogramm abarbeitet. Fork-Join Das Fork-Join-Konstrukt ist eines der einfachsten Konzepte zur Erzeugung von Threads oder Prozessen [15], das von der Programmierung mit Prozessen herr¨ uhrt, aber als Muster auch f¨ ur Threads anwendbar ist. Ein bereits existierender Thread T1 spaltet mit einem Fork-Aufruf einen weiteren Thread T2 ab. Bei einem zugeordneten Join-Aufruf des Threads T1 wartet dieser auf die Beendigung des Threads T2 . Das Fork-Join-Konzept kann explizit als Sprachkonstrukt oder als Bibliotheksaufruf zur Verf¨ ugung stehen und wird meist in der Programmierung mit gemeinsamem Adressraum verwendet. Die Spawn- und Exit-Operationen der Message-Passing-Programmierung, also der Programmierung mit verteiltem Adressraum, bewirken im Wesentlichen dieselben Aktionen wie die Fork-Join-Operationen. Obwohl das Fork-Join-Konzept sehr einfach ist, erlaubt es durch verschachtelte Aufrufe eine beliebige Struktur paralleler Aktivit¨ at. Spezielle Programmiersprachen und Programmierumgebungen haben oft eine spezifische Auspr¨ agung der beschriebenen Erzeugung von Threads. Parbegin-Parend Eine strukturierte Variante der Thread-Erzeugung wird durch das gleichzeitige Erzeugen und Beenden mehrerer Threads erreicht. Dazu wird das Parbegin-Parend-Konstrukt bereitgestellt, das manchmal auch mit dem Namen Cobegin-Coend bezeichnet wird. Zwischen Parbegin und
56
3 Thread-Programmierung
Parend werden Anweisungen angegeben, die auch Funktionsaufrufe beinhalten k¨ onnen und die Threads zur Ausf¨ uhrung zugeordnet werden k¨ onnen. Erreicht der ausf¨ uhrende Thread den Parbegin-Befehl, so werden die von ParbeginParend umgebenen Anweisungen separaten Threads zur Ausf¨ uhrung zugeordnet. Der Programmtext nach dem Parend-Befehl wird erst ausgef¨ uhrt, wenn alle so gestarteten Threads beendet sind. Die Threads innerhalb des ParbeginParend-Konstrukts k¨ onnen gleichen oder verschiedenen Programmtext haben. Ob und wie die Threads tats¨achlich parallel ausgef¨ uhrt werden, h¨ angt von der zur Verf¨ ugung stehenden Hardware und der Implementierung des Konstrukts ab. Die Anzahl und Art der zu erzeugenden Threads steht meist statisch fest. Auch f¨ ur dieses Konstrukt haben spezielle parallele Sprachen oder Umgebungen ihre spezifische Syntax und Auspr¨ agung, wie z.B. in Form von parallelen Bereichen (parallel sections), vgl. auch OpenMP. SPMD und SIMD Im SIMD- (Single Instruction, Multiple Data) und SPMDProgrammiermodell (Single Program, Multiple Data) wird zu Programmbeginn eine feste Anzahl von Threads gestartet. Alle Threads f¨ uhren dasselbe Programm aus, das sie auf verschiedene Daten anwenden. Durch Kontrollanweisungen innerhalb des Programmtextes kann jeder Thread verschiedene Programmteile ausw¨ ahlen und ausf¨ uhren. Im SIMDAnsatz werden die einzelnen Instruktionen synchron abgearbeitet, d.h. die verschiedenen Threads arbeiten dieselbe Instruktion gleichzeitig ab. Der Ansatz wird auch h¨aufig als Datenparallelit¨at im engeren Sinne bezeichnet. Im SPMDAnsatz k¨ onnen die Threads asynchron arbeiten, d.h. zu einem Zeitpunkt k¨ onnen verschiedene Threads verschiedene Programmstellen bearbeiten. Dieser Effekt tritt ent-
3.4 Parallele Programmiermuster
57
weder durch unterschiedliche Ausf¨ uhrungsgeschwindigkeiten oder eine Verz¨ ogerung des Kontrollflusses in Abh¨angigkeit von lokalen Daten auf. Der SPMD-Ansatz ist z.Zt. einer der popul¨ arsten Ans¨ atze der parallelen Programmierung, insbesondere in der Programmierung mit verteiltem Adressraum mit MPI. Besonders geeignet ist die SPMDProgrammierung f¨ ur Anwendungsalgorithmen, die auf Feldern arbeiten und bei denen eine Zerlegung der Felder die Grundlage einer Parallelisierung ist. Master-Slave oder Master-Worker Bei diesem Ansatz kontrolliert ein einzelner Thread die gesamte Arbeit eines Programms. Dieser Master-Thread entspricht oft dem Hauptprogramm des Anwendungsprogramms. Der Master-Prozess erzeugt mehrere, meist gleichartige Worker- oder Slave-Threads, die die eigentlichen Berechnungen ausf¨ uhren, siehe Abb. 3.6. Diese WorkerThreads k¨ onnen statisch oder dynamisch erzeugt werden. Die Zuteilung von Arbeit an die Worker-Threads kann durch den Master-Thread erfolgen. Die Worker-Threads k¨ onnen aber auch eigenst¨ andig Arbeit allokieren. In diesem Fall ist der Master-Thread nur f¨ ur alle u ¨ brigen Koordinationsaufgaben zust¨ andig, wie etwa Initialisierung, Zeitmessung oder Ausgabe. Client-Server-Modell Programmierstrukturierungen nach dem Client-Server-Prinzip ¨ ahneln dem MPMD-Modell (Multiple Program, Multiple Data). Es stammt aus dem verteilten Rechnen, wo mehrere Client-Rechner mit einem als Server dienenden Mainframe verbunden sind, der etwa Anfragen an eine Datenbank bedient. Parallelit¨ at kann auf der Server-Seite
3 Thread-Programmierung Master
Slave 3
rt tw o An
Client 1
Antwort
ge An f ra
Steuerung
Slave 2
ng eru eu
Slave 1
St
Ste ue r un
g
Server Anfrage
58
An
fra
An
tw
ge
or t
Client 3
Client 2
Abbildung 3.6. Veranschaulichung Master-Slave-Modell (links) und Client-Server-Modell (rechts).
auftreten, indem mehrere Client-Anfragen verschiedener Clients nebenl¨ aufig oder parallel zueinander beantwortet werden. Eine parallele Programmstrukturierung nach dem Client-Server-Prinzip nutzt mehrere Client-Threads, die Anfragen an einen Server-Thread stellen, siehe Abb. 3.6. Nach Erledigung der Anfrage durch den Server-Thread geht die Antwort an den jeweiligen Client-Thread zur¨ uck. Das ClientServer-Prinzip kann auch weiter gefasst werden, indem etwa mehrere Server-Threads vorhanden sind oder die Threads des Programmes die Rolle von Clients und von Servern u ¨ bernehmen und sowohl Anfragen stellen als auch beantworten k¨ onnen. Pipelining Der Pipelining-Ansatz beschreibt eine spezielle Form der Zusammenarbeit verschiedener Threads, bei der Daten zwischen den Threads weitergereicht werden. Die beteiligten Threads T1 , . . . , Tp sind dazu logisch in einer vorgegebealt die Ausgabe nen Reihenfolge angeordnet. Thread Ti erh¨ von Thread Ti−1 als Eingabe und produziert eine Ausgabe, die dem n¨ achsten Thread Ti+1 , i = 2, . . . , p − 1 als Eingaalt die Eingabe von anderen Probe dient; Thread T1 erh¨ grammteilen und Tp gibt seine Ausgabe an wiederum ande-
3.4 Parallele Programmiermuster
59
re Progammteile weiter. Jeder Thread verarbeitet also einen Strom von Eingaben in einer sequentiellen Reihenfolge und produziert einen Strom von Ausgaben. Somit k¨onnen die Threads durch Anwendung des Pipeline-Prinzips trotz der Datenabh¨ angigkeiten parallel zueinander ausgef¨ uhrt werden. Pipelining kann als spezielle Form einer funktionalen Zerlegung betrachtet werden, bei der die Threads Funktionen eines Anwendungsalgorithmus bearbeiten, die durch ihre Datenabh¨ angigkeiten nicht nacheinander ausgef¨ uhrt werden, sondern auf die beschriebene Weise gleichzeitig abgearbeitet werden k¨ onnen. Das Pipelining-Konzept kann prinzipiell mit gemeinsamem Adressraum oder mit verteiltem Adressraum realisiert werden. Taskpools Ein Taskpool ist eine Datenstruktur, in der die noch abzuarbeitenden Programmteile (Tasks) eines Programms etwa in Form von Funktionen abgelegt sind. F¨ ur die Abarbeitung der Tasks wird eine feste Anzahl von Threads verwendet, die zu Beginn des Programms vom Haupt-Thread erzeugt werden und bis zum Ende des Programms existieren. F¨ ur die Threads ist der Taskpool eine gemeinsame Datenstruktur, auf die sie zugreifen k¨ onnen, um die dort abgelegten Tasks zu entnehmen und anschließend abzuarbeiten, siehe Abb. 3.7. W¨ ahrend der Abarbeitung einer Task kann ein Thread neue Tasks erzeugen und diese in den Taskpool einf¨ ugen. Der Zugriff auf den Taskpool muss synchronisiert werden. Die Abarbeitung des parallelen Programms ist beendet, wenn der Taskpool leer ist und jeder Thread seine Tasks abgearbeitet hat. Der Vorteil dieses Abarbeitungsschemas besteht darin, dass auf der einen Seite nur ein feste Anzahl von Threads erzeugt werden muss und daher der
60
3 Thread-Programmierung
1
Aufwand zur Thread-Erzeugung unabh¨ angig von der Problemgr¨ oße und relativ gering ist, dass aber auf der anderen Seite Tasks dynamisch erzeugt werden k¨ onnen und so auch adaptive und irregul¨ are Anwendungen effizient abgearbeitet werden k¨ onnen.
Daten−
2
bl a A
Produzent 3
e hm
ad
re
re
Th
Konsument 2
puffer
ge
Produzent 2
Konsument 1
a tn En
4
ge
Th
ad
hm e
a bl
3
Th
A
ad
re
re
Produzent 1 En tn a
ad
Th
Ta − sk e ab sk− Ta lag T la en as ge ab sk− me tn k− Ta ah ah Task− tn m en e pool Ta − k ab sk s e Ta lag k− e en Ta lag − tn sk e ab Tas hm ah − a m tn e en
Konsument 3
Abbildung 3.7. Veranschaulichung eines Taskpool-Konzepts (links) und eines Produzenten-Konsumenten-Modells (rechts).
Produzenten-Konsumenten-Modell Das Produzenten-Konsumenten-Modell nutzt ProduzentenThreads und Konsumenten-Threads, wobei die Produzenten-Threads Daten erzeugen, die von Konsumenten-Threads ¨ als Eingabe genutzt werden. F¨ ur die Ubergabe der Daten wird eine gemeinsame Datenstruktur vorgegebener L¨ange als Puffer benutzt, auf die beide Threadtypen schreibend bzw. lesend zugreifen. Die Produzenten-Threads legen die von ihnen erzeugten Eintr¨ age in den Puffer ab, die Konsumenten-Threads entnehmen Eintr¨ age aus dem Puffer und verarbeiten diese weiter, siehe Abb. 3.7. Die Produzenten k¨ onnen nur Eintr¨ age im Puffer ablegen, wenn dieser nicht voll ist. Entsprechend k¨ onnen die Konsumenten nur Eintr¨ age entnehmen, wenn der Puffer nicht leer ist. Zum korrekten Ablauf ist f¨ ur den Zugriff auf die Pufferdatenstruktur eine Synchronisation der zugreifenden Threads erforderlich.
3.5 Parallele Programmierumgebungen
61
3.5 Parallele Programmierumgebungen F¨ ur die parallele Programmierung steht eine Vielzahl von Umgebungen zur Verf¨ ugung. Die verbreitetsten sind: Posix Threads: Posix Threads (auch Pthreads genannt) ist eine portable Thread-Bibliothek, die f¨ ur viele Betriebssysteme nutzbar ist. Mittlerweile ist Pthreads die StandardSchnittstelle f¨ ur Linux und wird auch f¨ ur Unix-Plattformen h¨ aufig genutzt. F¨ ur Windows ist eine Open-Source-Version pthreads-win32 verf¨ ugbar. Die Programmiersprache ist C. Kapitel 4 stellt Pthreads detaillierter vor. Win32/MFC Thread API: Das Win32/MFC API bietet dem Softwareentwickler eine C/C++-Umgebung zur Entwicklung von Windows-Anwendungen. Es werden Mechanismen zur Erzeugung und Verwaltung von Threads zur Verf¨ ugung gestellt sowie Kommunikations- und Synchronisations-Mechanismen. Wir verweisen u.a. auf [3] f¨ ur eine genauere Beschreibung. Threading API f¨ ur Microsoft.NET: Das .NET-Framework bietet umfangreiche Unterst¨ utzung f¨ ur die Programmierung mit Threads f¨ ur die Sprachen C++, Visual Basic, .NET, JScript.NET oder C#. Das Laufzeitsystem wird als Common Language Runtime (CLR) bezeichnet; CLR arbeitet mit einer Zwischencodedarstellung ¨ ahnlich zu Java Bytecode, siehe [3] f¨ ur eine detailliertere Beschreibung. Java-Threads: Die Programmiersprache Java unterst¨ utzt die Erzeugung, Verwaltung und Synchronisation von Threads auf Sprachebene bzw. durch Bereitstellung spezieller Klassen und Methoden. Das Paket java.util.concurrent (ab Java 1.5) stellt eine Vielzahl zus¨ atzlicher Synchronisations-Mechanismen zur Verf¨ ugung. Kapitel 5 enth¨alt eine detailliertere Einf¨ uhrung. OpenMP: OpenMP ist ein API zur Formulierung portierbarer Multithreading-Programme, das Fortran, C und
62
3 Thread-Programmierung
C++ unterst¨ utzt. Das Programmiermodell wird durch eine plattformunabh¨ angige Menge von Compiler-Pragmas und -Direktiven, Funktionsaufrufen und Umgebungsvariablen realisiert, die die Erstellung paralleler Programme verein¨ fachen sollen. Kapitel 6 gibt einen genaueren Uberblick. Message Passing Interface (MPI): MPI [26, 59] wurde als Standard f¨ ur die Kommunikation zwischen Prozessen mit jeweils separatem Adressraum definiert und stellt eine Vielzahl von Kommunikationsoperationen zur Verf¨ ugung, die sowohl Einzeltransfers (mit jeweils zwei Kommunikationspartnern) als auch globale Kommunikationsoperationen wie Broadcast- oder Reduktionsoperationen umfassen. Sprachanbindungen wurden f¨ ur C, C++ und Fortran definiert, es gibt aber auch MPI-Implementierungen f¨ ur Java. Obwohl MPI f¨ ur einen verteilten Adressraum entworfen wurde, kann es im Prinzip auch f¨ ur die Programmierung von Multicore-Prozessoren mit gemeinsamem Adressraum verwendet werden. Dazu wird auf jedem Prozessorkern ein separater Prozess mit privaten Daten gestartet. Der Datenbzw. Informationsaustausch zwischen den Prozessen erfolgt mit MPI-Kommunikationsoperationen, Zugriffe auf den gemeinsamen Speicher und damit auch die damit verbundenen Synchronisationsoperationen entfallen. Im Vergleich zu Threads stellt dies ein v¨ ollig anderes Programmiermodell dar, das je nach Anwendungsprogramm aber durchaus zu einer ¨ ahnlichen Prozessorauslastung wie die Verwendung eines Threadmodells f¨ uhren kann. Das MPI-Modell ist insbesondere f¨ ur solche Programme geeignet, in denen jeder Berechnungsstrom auf einen ihm zuzuordnenden Datenbereich zugreift und relativ selten Daten anderer Datenbereiche braucht. Wir gehen im Folgenden nicht n¨ aher auf MPI ein und verweisen auf [59, 26] f¨ ur eine detaillierte Beschreibung.
4 Programmierung mit Pthreads
Posix Threads (auch Pthreads genannt) ist ein Standard zur Programmierung im Threadmodell mit der Programmiersprache C. Dieser Abschnitt f¨ uhrt in den PthreadsStandard kurz ein; vollst¨ andigere Behandlungen sind in [11, 35, 42, 49, 56] zu finden. Die von einer Pthreads-Bibliothek verwendeten Datentypen, Schnittstellendefinitionen und Makros sind u ¨ blicherweise in der Headerdatei
abgelegt, die somit in jedes Pthreads-Programm eingebunden werden muss. Alle Pthreads-Funktionen liefern den Wert 0 zur¨ uck, wenn sie fehlerfrei durchgef¨ uhrt werden konnten. Wenn bei der Durchf¨ uhrung ein Fehler aufgetreten ist, wird ein Fehlercode aus <error.h> zur¨ uckgegeben. Diese Headerdatei sollte daher ebenfalls eingebunden werden.
4.1 Threaderzeugung und -verwaltung Beim Start eines Pthreads-Programms ist ein Haupt-Thread (main thread) aktiv, der die main()-Funktion des Pro-
64
4 Programmierung mit Pthreads
gramms ausf¨ uhrt. Ein Thread ist in der Pthreads-Bibliothek durch den Typ pthread t dargestellt. Der Haupt-Thread kann weitere Threads erzeugen, indem jeweils die Funktion int pthread create (pthread t *thread, const pthread attr t *attr, void *(*start routine)(void *), void *arg)
aufgerufen wird. Das erste Argument ist ein Zeiger auf ein Datenobjekt vom Typ pthread t. In diesem Argument wird eine Identifikation des erzeugten Threads ablegt, die auch als Thread-Name (thread identifier, TID) bezeichnet wird und mit der dieser Thread in nachfolgenden Aufrufen von Pthreads-Funktionen angesprochen werden kann. Das zweite Argument ist ein Zeiger auf ein Attributobjekt vom Typ pthread attr t, mit dessen Hilfe das Verhalten des Threads (wie z.B. Scheduling, Priorit¨ aten, Gr¨oße des Laufzeitstacks) beeinflusst werden kann. Die Angabe von NULL bewirkt, dass ein Thread mit den Default-Attributen erzeugt wird. Sollen die Attribute abweichend gesetzt werden, muss die Attributdatenstruktur vor dem Aufruf von ¨ pthread create() entsprechend besetzt werden. Ublicherweise reicht die Verwendung der Default-Attribute aus. Das dritte Argument bezeichnet die Funktion start routine(), die der Thread nach seiner Erzeugung ausf¨ uhrt. Diese Funktion hat ein einziges Argument vom Typ void * und liefert einen Wert des gleichen Typs zur¨ uck. Das vierte Argument ist ein Zeiger auf das Argument, mit dem die Funktion uhrt werden soll. start routine() ausgef¨ Um mehrere Argumente an die Startfunktion eines Threads zu u ussen diese in eine Datenstruktur ge¨ bergeben, m¨ packt werden, deren Adresse an die Funktion u ¨ bergeben wird. Sollen mehrere Threads die gleiche Funktion mit unterschiedlichen Argumenten ausf¨ uhren, so sollte jedem
4.1 Threaderzeugung und -verwaltung
65
Thread ein Zeiger auf eine separate Datenstruktur als Argument der Startfunktion mitgegeben werden, um zu vermeiden, dass Argumentwerte zu fr¨ uh u ¨ berschrieben werden oder dass verschiedene Threads ihre Argumentwerte konkurrierend ver¨ andern. Ein Thread wird beendet, indem er die auszuf¨ uhrende Startfunktion vollst¨ andig abarbeitet oder aber die Bibliotheksfunktion void pthread exit (void *valuep) aufruft, wobei valuep den R¨ uckgabewert bezeichnet, der an den aufrufenden Thread oder einen anderen Thread zur¨ uckgegeben wird, wenn dieser mit pthread join() auf die Beendigung des Threads wartet. Wenn ein Thread seine Startfunktion beendet, wird die Funktion pthread exit() implizit aufgerufen, und der R¨ uckgabewert der Startfunktion wird zur¨ uckgegeben. Da nach dem Aufruf von pthread exit() der aufgerufene Thread und damit auch der von ihm verwendete Laufzeitstack nicht mehr existiert, sollte f¨ ur den R¨ uckgabewert valuep keine lokale Variable der Startfunktion oder einer anderen Funktion verwendet werden. Diese werden auf dem Laufzeitstack aufgehoben und k¨onnen nach dessen Freigabe durch einen anderen Thread u ¨ berschrieben werden. Stattdessen sollte eine globale oder eine dynamisch allokierte Variable verwendet werden. Ein Thread kann auf die Beendigung eines anderen Threads warten, indem er die Bibliotheksfunktion int pthread join (pthread t thread, void **valuep)
aufruft, wobei thread die Identifikation des Threads angibt, auf dessen Beendigung gewartet wird. Der aufrufende Thread wird so lange blockiert, bis der angegebene Thread beendet ist. Die Funktion pthread join bietet also eine
66
4 Programmierung mit Pthreads
M¨ oglichkeit der Synchronisation von Threads. Der R¨ uckgabewert des beendeten Threads thread wird dem wartenden Thread in der Variable valuep zur¨ uckgeliefert. Die Pthreads-Bibliothek legt f¨ ur jeden erzeugten Thread eine interne Datenstruktur an, die die f¨ ur die Abarbeitung des Threads notwendigen Informationen enth¨ alt. Diese Datenstruktur wird von der Bibliothek auch nach Beendigung eines Threads aufgehoben, damit ein anderer Thread eine uhren kann. pthread join()-Operation erfolgreich durchf¨ Durch den Aufruf von pthread join() wird auch die interne Datenstruktur des angegebenen Threads freigegeben und kann danach nicht mehr verwendet werden.
4.2 Koordination von Threads Die Threads eines Prozesses teilen sich einen gemeinsamen Adressraum und k¨ onnen daher konkurrierend auf gemeinsame Variablen zugreifen. Um dabei zeitkritische Abl¨aufe zu vermeiden, m¨ ussen die Zugriffe der beteiligten Threads koordiniert werden. Als wichtigste Hilfsmittel stellen Pthreads-Bibliotheken Mutexvariablen und Bedingungsvariablen zur Verf¨ ugung. Eine Mutexvariable bezeichnet eine Datenstruktur des vorgegebenen Typs pthread mutex t, die dazu verwendet werden kann, den wechselseitigen Ausschluss beim Zugriff auf gemeinsame Variablen sicherzustellen. Eine Mutexvariable kann zwei Zust¨ ande annehmen: gesperrt (locked) und ungesperrt (unlocked). Um einen wechselseitigen Ausschluss beim Zugriff auf gemeinsame Datenstrukturen sicherzustellen, m¨ ussen die beteiligten Threads jeweils folgendes Verhalten aufweisen: Bevor ein Thread eine Manipulation der gemeinsamen Datenstruktur startet, sperrt er die zugeh¨ orige Mutexvariable mit einem speziellen Funkti-
4.2 Koordination von Threads
67
onsaufruf. Wenn ihm dies gelingt, ist er der Eigent¨ umer der Mutexvariable und er hat die Kontrolle u ¨ber sie. Nach Beendigung der Manipulation der gemeinsamen Datenstruktur gibt der Thread die Sperre der Mutexvariable wieder frei. Versucht ein Thread die Kontrolle u ¨ ber eine von einem anderen Thread kontrollierte Mutexvariable zu erhalten, wird er so lange blockiert, bis der andere Thread die Mutexvariable wieder freigegeben hat. Die Thread-Bibliothek stellt also sicher, dass jeweils nur ein Thread die Kontrolle u ¨ ber eine Mutexvariable hat. Wenn das beschriebene Verhalten beim Zugriff auf eine Datenstruktur eingehalten wird, wird dadurch eine konkurrierende Manipulation dieser Datenstruktur ausgeschlossen. Sobald jedoch ein Thread die Datenstruktur manipuliert ohne vorher die Kontrolle u ¨ ber die Mutexvariable erhalten zu haben, ist ein wechselseitiger Ausschluss nicht mehr garantiert. Die Zuordnung zwischen einer Mutexvariablen und der ihr zugeordneten Datenstruktur erfolgt implizit dadurch, dass die Zugriffe auf die Datenstruktur durch Sperren bzw. Freigabe der Mutexvariablen gesch¨ utzt werden; eine explizite Zuordnung existiert nicht. Die Lesbarkeit eines Programms kann jedoch dadurch erleichtert werden, dass die Datenstruktur und die f¨ ur deren Kontrolle verwendete Mutexvariable in einer gemeinsamen Struktur gespeichert werden. Mutexvariablen k¨ onnen wie alle anderen Variablen deklariert oder dynamisch erzeugt werden. Bevor eine Mutexvariable benutzt werden kann, muss sie durch Aufruf der Funktion int pthread mutex init (pthread mutex t *mutex, const pthread mutexattr t *attr)
initialisiert werden. F¨ ur attr = NULL wird eine Mutexvariable mit den Default-Eigenschaften zur Verf¨ ugung ge-
68
4 Programmierung mit Pthreads
stellt. Eine statisch deklarierte Mutexvariable mutex kann auch durch die Zuweisung mutex = PTHREAD MUTEX INITIALIZER
mit den Default-Attributen initialisiert werden. Eine initialisierte Mutexvariable kann durch Aufruf der Funktion int pthread mutex destroy (pthread mutex t *mutex)
wieder zerst¨ ort werden. Eine Mutexvariable sollte erst dann zerst¨ ort werden, wenn kein Thread mehr auf ihre Freigabe wartet. Eine zerst¨ orte Mutexvariable kann durch eine erneute Initialisierung weiterverwendet werden. Ein Thread erh¨ alt die Kontrolle u ¨ ber eine Mutexvariable, indem er diese durch Aufruf der Funktion int pthread mutex lock (pthread mutex t *mutex)
sperrt. Wird die angegebene Mutexvariable mutex bereits von einem anderen Thread kontrolliert, so wird der nun die Funktion pthread mutex lock() aufrufende Thread blockiert, bis der momentane Eigent¨ umer die Mutexvariable wieder freigibt. Wenn mehrere Threads versuchen, die Kontrolle u ¨ ber eine Mutexvariable zu erhalten, werden die auf deren Freigabe wartenden Threads in einer Warteschlange gehalten. Welcher der wartenden Threads nach der Freigabe der Mutexvariable zuerst die Kontrolle u ¨ ber diese erh¨alt, kann von den Priorit¨ aten der wartenden Threads und dem verwendeten Scheduling-Verfahren abh¨ angen. Ein Thread kann eine von ihm kontrollierte Mutexvariable mutex durch Aufruf der Funktion int pthread mutex unlock (pthread mutex t *mutex)
4.2 Koordination von Threads
69
wieder freigeben. Wartet zum Zeitpunkt des Aufrufs von pthread mutex unlock() kein anderer Thread auf die Freigabe der Mutexvariable, so hat diese nach dem Aufruf keinen Eigent¨ umer mehr. Wenn andere Threads auf die Freigabe der Mutexvariable warten, wird einer dieser Threads aufgeweckt und Eigent¨ umer der Mutexvariablen. In manchen Situationen ist es sinnvoll, dass ein Thread feststellen kann, ob eine Mutexvariable von einem anderen Thread kontrolliert wird, ohne dass er dadurch blockiert wird. Dazu steht die Funktion int pthread mutex trylock (pthread mutex t *mutex)
zur Verf¨ ugung. Beim Aufruf dieser Funktion erh¨alt der aufrufende Thread die Kontrolle u ¨ ber die Mutexvariable mutex, wenn diese frei ist. Wenn diese zur Zeit von einem anderen Thread gesperrt ist, liefert der Aufruf EBUSY zur¨ uck; dies f¨ uhrt aber nicht wie beim Aufruf von pthread mutex lock() zu einer Blockierung des aufrufenden Threads. Daher kann der aufrufende Thread so lange versuchen, die Kontrolle u ¨ ber die Mutexvariable zu erhalten, bis er erfolgreich ist (spinlock). Beim gleichzeitigen Sperren mehrerer Mutexvariablen durch mehrere Threads besteht die Gefahr, dass Deadlocks auftreten, siehe Kapitel 3. Das Auftreten von Deadlocks kann durch Verwenden einer festen Sperr-Reihenfolge oder das Verwenden einer Backoff-Strategie vermieden werden, vgl. [11, 59]. Mutexvariablen werden in erster Linie dazu verwendet, den wechselseitigen Ausschluss beim Zugriff auf globale Datenstrukturen sicherzustellen. Ist der wechselseitige Ausschluss f¨ ur eine gesamte Funktion sichergestellt, wird sie als thread-sicher bezeichnet. Eine thread-sichere Funktion kann also gleichzeitig von mehreren Threads aufgerufen werden, ohne dass die beteiligten Threads zur Vermeidung
70
4 Programmierung mit Pthreads
von zeitkritischen Abl¨ aufen zus¨ atzliche Operationen beim Funktionsaufruf ausf¨ uhren m¨ ussen. Im Prinzip k¨ onnen Mutexvariablen jedoch auch dazu verwendet werden, auf das Eintreten einer Bedingung zu warten, die vom Zustand globaler Datenstrukturen abh¨angt. Dazu verwendet der zugreifende Thread eine oder mehrere Mutexvariablen zum Schutz des Zugriffs auf die globalen Daten und wertet die gew¨ unschte Bedingung von Zeit zu Zeit aus, indem er mit Hilfe der Mutexvariablen gesch¨ utzt auf die entsprechenden globalen Daten zugreift. Wenn die Bedingung erf¨ ullt ist, kann der Thread die beabsichtigte Operation ausf¨ uhren. Diese Vorgehensweise hat den Nachteil, dass der auf das Eintreten der Bedingung wartende Thread die Bedingung evtl. sehr oft auswerten muss, bis diese erf¨ ullt ist, und dabei CPU-Zeit verbraucht (aktives Warten). Um diesen Nachteil zu beheben, stellt der Pthreads-Standard Bedingungsvariablen zur Verf¨ ugung.
4.3 Bedingungsvariablen Eine Bedingungsvariable ist eine Datenstruktur, die es einem Thread erlaubt, auf das Eintreten einer beliebigen Bedingung zu warten. F¨ ur Bedingungsvariablen wird ein Signalmechanismus zur Verf¨ ugung gestellt, der den wartenden Thread w¨ ahrend der Wartezeit blockiert, so dass er keine CPU-Zeit verbraucht, und wieder aufweckt, sobald die angegebene Bedingung erf¨ ullt ist. Um diesen Mechanismus zu verwenden, muss der ausf¨ uhrende Thread neben der Bedingungsvariablen einen Bedingungsausdruck angeben, der die Bedingung bezeichnet, auf deren Erf¨ ullung der Thread wartet. Eine Mutexvariable wird verwendet, um die Auswertung des Bedingungsausdrucks zu sch¨ utzen. Letzteres ist notwendig, da der Bedingungsausdruck in der
4.3 Bedingungsvariablen
71
Regel auf globale Datenstrukturen zugreift, die von anderen Threads konkurrierend ver¨ andert werden k¨ onnen. Bedingungsvariablen haben den Typ pthread cond t. Nach der Deklaration oder der dynamischen Erzeugung einer Bedingungsvariablen muss diese initialisiert werden, bevor sie verwendet werden kann. Dies kann dynamisch durch Aufruf der Funktion int pthread cond init (pthread cond t *cond, const pthread condattr t *attr)
geschehen. Dabei ist cond ein Zeiger auf die Bedingungsvariable und attr ein Zeiger auf eine Attribut-Datenstruktur f¨ ur Bedingungsvariablen. F¨ ur attr = NULL erfolgt eine Initialisierung mit den Default-Attributen. Die Initialisierung kann auch bei der Deklaration einer Bedingungsvariablen durch Verwendung eines Makros erfolgen: pthread cond t cond = PTHREAD COND INITIALIZER.
Eine mit pthread cond init() dynamisch initialisierte Bedingungsvariable cond sollte durch Aufruf der Funktion int pthread cond destroy (pthread cond t *cond) zerst¨ ort werden, wenn sie nicht mehr gebraucht wird, damit das Laufzeitsystem die f¨ ur die Bedingungsvariable abgelegte Information freigeben kann. Statisch initialisierte Bedingungsvariablen m¨ ussen nicht freigegeben werden. Eine Bedingungsvariable muss eindeutig mit einer Mutexvariablen assoziiert sein. Alle Threads, die zur gleichen Zeit auf die Bedingungsvariable warten, m¨ ussen die gleiche Mutexvariable verwenden, d.h. f¨ ur eine Bedingungsvariable d¨ urfen von verschiedenen Threads nicht verschiedene Mutexvariablen verwendet werden. Eine Mutexvariable kann jedoch verschiedenen Bedingungsvariablen zugeordnet werden. Nach dem Sperren der Mutexvariablen mit
72
4 Programmierung mit Pthreads
pthread mutex lock() kann ein Thread durch Aufruf der Funktion int pthread cond wait (pthread cond t *cond, pthread mutex t *mutex)
auf das Eintreten einer Bedingung warten. Dabei bezeichnet cond die Bedingungsvariable und mutex die assoziierte Mutexvariable. Eine Bedingungsvariable sollte nur mit einer Bedingung assoziiert sein, da sonst die Gefahr von Deadlocks oder zeitkritischen Abl¨ aufen vorliegt [11]. Die typische Verwendung hat folgendes Aussehen: pthread mutex lock (&mutex); while (!Bedingung) pthread cond wait (&cond, &mutex); pthread mutex unlock (&mutex);
Die Auswertung der Bedingung wird zusammen mit dem Aufruf von pthread cond wait() unter dem Schutz der Mutexvariablen mutex ausgef¨ uhrt, um sicherzustellen, dass die Bedingung sich zwischen ihrer Auswertung und dem Aufruf von pthread cond wait() nicht durch Berechnungen anderer Threads ver¨ andert. Daher muss durch das Programm auch gew¨ ahrleistet sein, dass jeder andere Thread eine Manipulation einer in den Bedingungen auftretenden gemeinsamen Variable mit der gleichen Mutexvariablen sch¨ utzt. •
•
Wenn bei der Ausf¨ uhrung des Programmsegments die angegebene Bedingung erf¨ ullt ist, wird die pthread cond wait()-Funktion nicht aufgerufen, und der ausf¨ uhrende Thread arbeitet nach pthread mutex unlock() das nachfolgende Programm weiter ab. Wenn dagegen die Bedingung nicht erf¨ ullt ist, wird pthread cond wait() aufgerufen mit dem Effekt, dass
4.3 Bedingungsvariablen
73
der ausf¨ uhrende Thread T1 gleichzeitig die Kontrolle u uglich ¨ ber die Mutexvariable freigibt und so lange bez¨ der Bedingungsvariable blockiert, bis er von einem anderen Thread T2 mit einer pthread cond signal()Anweisung aufgeweckt wird, siehe unten. Wird Thread T1 durch diese Anweisung wieder aufgeweckt, versucht er automatisch, die Kontrolle u ¨ ber die Mutexvariable mutex zur¨ uckzuerhalten. Hat bereits ein anderer Thread Kontrolle u ¨ ber die Mutexvariable mutex, so wird der aufgeweckte Thread T1 unmittelbar nach dem Aufwecken so lange bzgl. der Mutexvariable blockiert, bis er diese sperren kann. Erst wenn der aufgeweckte Thread die Mutexvariable erfolgreich gesperrt hat, kann er mit der Abarbeitung seines Programms fortfahren, was zun¨ achst die erneute Abarbeitung der Bedingung ist. Das Programm sollte sicherstellen, dass der blockierte Thread nur dann aufgeweckt wird, wenn die angegebene Bedingung erf¨ ullt ist. Trotzdem ist es sinnvoll, die Bedingung nach dem Aufwecken noch einmal zu u ufen, ¨ berpr¨ da ein gleichzeitig aufgeweckter oder zeitgleich arbeitender Thread, der die Kontrolle u ¨ ber die Mutexvariable zuerst erh¨ alt, die Bedingung oder in der Bedingung enthaltene gemeinsame Daten modifizieren kann und so die Bedingung nicht mehr erf¨ ullt ist. Zum Aufwecken von bzgl. einer Bedingungsvariable wartenden Threads stehen die beiden folgenden Funktionen zur Verf¨ ugung: int pthread cond signal (pthread cond t *cond) int pthread cond broadcast (pthread cond t *cond) Ein Aufruf von pthread cond signal() weckt einen bzgl. der Bedingungsvariable cond wartenden Thread auf, wenn die Bedingung erf¨ ullt ist. Wartet kein Thread, so hat der
74
4 Programmierung mit Pthreads
Aufruf keinen Effekt. Warten mehrere Threads, wird ein Thread anhand der Priorit¨ aten der Threads und der verwendeten Scheduling-Strategie ausgew¨ ahlt. Ein Aufruf der Funktion pthread cond broadcast() weckt alle bzgl. der Bedingungsvariablen cond wartenden Threads auf. Dabei kann aber h¨ ochstens einer dieser Threads die Kontrolle u ¨ber die mit der Bedingungsvariablen assoziierten Mutexvariable erhalten; alle anderen bleiben bzgl. der Mutexvariablen blockiert. Als Variante von pthread cond wait() steht die Funktion int pthread cond timedwait (pthread cond t *cond, pthread mutex t *mutex, const struct timespec *time)
zur Verf¨ ugung. Der Unterschied zu pthread cond wait() besteht darin, dass die Blockierung bzgl. der Bedingungsvariable cond aufgehoben wird, wenn die in time angegebene absolute Zeit abgelaufen ist. In diesem Fall wird die Fehlermeldung ETIMEDOUT zur¨ uckgeliefert. Die Datenstruktur vom Typ timespec ist definiert als struct timespec { time t tv sec; long tv nsec; } wobei tv sec die Anzahl der Sekunden und tv nsec die zus¨ atzliche Anzahl von Nanosekunden der verwendeten Zeitscheiben angibt. Der Parameter time von pthread cond timedwait() gibt eine absolute Tageszeit und kein relatives Zeitintervall an. Eine typische Benutzung ist in Abbildung 4.1 angegeben. In diesem Beispiel wartet der ausf¨ uhrende Thread maximal zehn Sekunden auf das Eintreten der Bedingung. Zur
4.4 Erweiterter Sperrmechanismus
75
pthread mutex t m = PTHREAD MUTEX INITIALIZER; pthread cond t c = PTHREAD COND INITIALIZER; struct timespec time; pthread mutex lock (&m); time.tv sec = time (NULL) + 10; time.tv nsec = 0; while (!Bedingung) if (pthread cond timedwait (&c, &m, &time) == ETIMEDOUT) timed out work(); pthread mutex unlock (&m); Abbildung 4.1. Typische Verwendung von Bedingungsvariablen.
Besetzung von time.tv sec wird die Funktion time aus benutzt. (Der Aufruf time (NULL) gibt die absolute Zeit in Sekunden zur¨ uck, die seit dem 1. Januar 1970 vergangen ist.) Wenn die Bedingung nach zehn Sekunden noch nicht erf¨ ullt ist, wird die Funktion timed out work() ausgef¨ uhrt, und die Bedingung wird erneut u uft. ¨ berpr¨
4.4 Erweiterter Sperrmechanismus Bedingungsvariablen k¨ onnen dazu verwendet werden, komplexere Synchronisationsmechanismen zu implementieren. Als Beispiel hierf¨ ur betrachten wir im Folgenden einen Lese/Schreib-Sperrmechanismus, der als Erweiterung des von Mutexvariablen zur Verf¨ ugung gestellten Sperrmechanismus aufgefasst werden kann. Wird eine gemeinsame Datenstruktur von einer normalen Mutexvariable gesch¨ utzt, so kann zu einem Zeitpunkt jeweils nur ein Thread die gemeinsame Datenstruktur lesen bzw. auf die gemeinsame Datenstruktur schreiben. Die Idee des Lese/Schreib-
76
4 Programmierung mit Pthreads
Sperrmechanismus besteht darin, dies dahingehend zu erweitern, dass zum gleichen Zeitpunkt eine beliebige Anzahl von lesenden Threads zugelassen wird, ein Thread zum Beschreiben der Datenstruktur aber das ausschließliche Zugriffsrecht haben muss. Wir werden im Folgenden eine einfache Variante eines solchen modifizierten Sperrmechanismus beschreiben, vgl. auch [50]. F¨ ur eine komplexere und effizientere Implementierung verweisen wir auf [11, 35]. F¨ ur die Implementierung des erweiterten Sperrmechanismus werden RW-Sperrvariablen (read/write lock variables) verwendet, die mit Hilfe einer Mutex- und einer Bedingungsvariablen wie folgt definiert werden k¨onnen: typedef struct rw lock { int num r, num w; pthread mutex t mutex; pthread cond t cond; } rw lock t; Dabei gibt num r die Anzahl der momentan erteilten Leseberechtigungen und num w die Anzahl der momentan erteilten Schreibberechtigungen an. Letztere hat h¨ ochstens den Wert Eins. Die Mutexvariable soll diese Z¨ ahler der Leseund Schreibzugriffe sch¨ utzen. Die Bedingungsvariable regelt den Zugriff auf die neu definierte RW-Sperrvariable. Abbildung 4.2 gibt Funktionen zur Verwaltung von RWSperrvariablen an. Die Funktion rw lock init() dient der Initialisierung einer RW-Sperrvariable vom Typ rw lock t. Die Funktion rw lock rlock() fordert einen Lesezugriff auf die gemeinsame Datenstruktur an. Der Lesezugriff wird nur dann gew¨ ahrt, wenn kein anderer Thread eine Schreibberechtigung erhalten hat. Hat ein anderer Thread eine Schreibberechtigung, wird der anfordernde Thread blockiert, bis die Schreibberechtigung wieder zur¨ uckgegeben wird. Die Funktion rw lock wlock() dient der Anforde-
4.4 Erweiterter Sperrmechanismus
77
int rw lock init (rw lock t *rwl) { rwl->num r = rwl->num w = 0; pthread mutex init (&(rwl->mutex),NULL); pthread cond init (&(rwl->cond),NULL); return 0; } int rw lock rlock (rw lock t *rwl) { pthread mutex lock (&(rwl->mutex)); while (rwl->num w > 0) pthread cond wait(&(rwl->cond),&(rwl->mutex)); rwl->num r ++; pthread mutex unlock (&(rwl->mutex)); return 0; } int rw lock wlock (rw lock t *rwl) { pthread mutex lock (&(rwl->mutex)); while ((rwl->num w > 0) || (rwl->num r > 0)) pthread cond wait(&(rwl->cond),&(rwl->mutex)); rwl->num w ++; pthread mutex unlock (&(rwl->mutex)); return 0; } int rw lock runlock (rw lock t *rwl) { pthread mutex lock (&(rwl->mutex)); rwl->num r --; if (rwl->num r == 0) pthread cond signal (&(rwl->cond)); pthread mutex unlock (&(rwl->mutex)); return 0; } int rw lock wunlock (rw lock t *rwl) { pthread mutex lock (&(rwl->mutex)); rwl->num w --; pthread cond broadcast (&(rwl->cond)); pthread mutex unlock (&(rwl->mutex)); return 0; } Abbildung 4.2. Funktionen zur Verwaltung von RWSperrvariablen (read/write lock variables).
78
4 Programmierung mit Pthreads
rung einer Schreibberechtigung. Diese wird nur gew¨ahrt, wenn kein anderer Thread eine Lese- oder eine Schreibberechtigung erhalten hat. uckgabe Die Funktion rw lock runlock() dient der R¨ einer Leseberechtigung. Sinkt durch die R¨ uckgabe einer Leseberechtigung die Anzahl der lesend zugreifenden Threads auf Null, so wird ein auf eine Schreibberechtigung wartender Thread durch einen Aufruf von pthread cond signal() aufgeweckt. Die Funktion rw lock wunlock() dient entsprechend der R¨ uckgabe einer Schreibberechtigung. Da maximal ein schreibender Thread erlaubt ist, hat nach dieser R¨ uckgabe kein Thread mehr eine Schreibberechtigung, und alle auf einen Lesezugriff wartenden Threads k¨ onnen durch pthread cond broadcast() aufgeweckt werden. Die skizzierte Implementierung von RW-Sperrvariablen gibt Lesezugriffen Vorrang vor Schreibzugriffen: Wenn ein Thread T1 eine Leseerlaubnis erhalten hat und Thread T2 auf eine Schreiberlaubnis wartet, erhalten andere Threads auch dann eine Leseerlaubnis, wenn diese nach der Schreiberlaubnis von T2 beantragt wird. Thread T2 erh¨alt erst dann eine Schreiberlaubnis, wenn kein anderer Thread mehr eine Leseerlaubnis beantragt hat. Je nach Anwendung kann es sinnvoll sein, den Schreibzugriffen Vorrang vor Lesezugriffen zu geben, damit die Datenstruktur immer auf dem aktuellsten Stand ist. Wie dies erreicht werden kann, ist in [11] skizziert.
4.5 Implementierung eines Taskpools Eine naheliegende Gestaltung eines Thread-Programms besteht darin, f¨ ur jede abzuarbeitende Aufgabe oder Funktion (also allgemein Task) genau einen Thread zu erzeugen, der diese Task abarbeitet und anschließend wieder zerst¨ort
4.5 Implementierung eines Taskpools
79
wird. Dies kann je nach Anwendung dazu f¨ uhren, dass sehr viele Threads erzeugt und wieder zerst¨ ort werden, was einen nicht unerheblichen Zeitaufwand verursachen kann, insbesondere wenn jeweils pro Task nur wenige Berechnungen auszuf¨ uhren sind. Eine effizientere parallele Implementierung kann mit Hilfe eines Taskpools erreicht werden, siehe auch Kapitel 3. Die Idee eines Taskpools besteht darin, eine Datenstruktur anzulegen, in der die noch abzuarbeitenden Programmteile (Tasks) abgelegt sind. F¨ ur die Abarbeitung der Tasks wird eine feste Anzahl von Threads verwendet, die zu Beginn des Programms vom Haupt-Thread erzeugt werden und bis zum Ende des Programms existieren. F¨ ur die Threads stellt der Taskpool eine gemeinsame Datenstruktur dar, auf die sie zugreifen und die dort abgelegten Tasks entnehmen und anschließend abarbeiten. W¨ahrend der Abarbeitung einer Task kann ein Thread neue Tasks erzeugen und diese in den Taskpool einf¨ ugen. Die Abarbeitung des parallelen Programms ist beendet, wenn der Taskpool leer ist und jeder Thread seine Tasks abgearbeitet hat. Wir beschreiben im Folgenden eine einfache Implementierung eines Taskpools, vgl. [49]. Weitere Implementierungen sind zum Beispiel in [11, 35, 38, 30] beschrieben. Abbildung 4.3 zeigt die Datenstruktur eines Taskpools und die Funktion tpool init() zur Initialisierung des Taskpools. Der Datentyp work t beschreibt eine einzelne Task des Taskpools. Diese Beschreibung besteht aus je einem Zeiger auf die auszuf¨ uhrende Funktion und auf das Argument dieser Funktion. Die einzelnen Tasks sind durch Zeiger next in Form einer einfach verketteten Liste miteinander verbunden. Der Datentyp tpool t beschreibt die gesamte Datenstruktur eines Taskpools. Dabei bezeichnet num thr die Anzahl der f¨ ur die Abarbeitung verwendeten Threads; das Feld threads enth¨ alt Zeiger auf die abarbeitenden Threads. Die Eintr¨ age max size und current size geben die maxi-
80
4 Programmierung mit Pthreads
male bzw. aktuelle Anzahl von eingetragenen Tasks an. Die Zeiger head und tail zeigen auf den Anfang bzw. das Ende der Taskliste. Die Mutexvariable lock wird verwendet, um den wechselseitigen Ausschluss beim Zugriff auf den Taskpool durch die Threads sicherzustellen. Wenn ein Thread versucht, eine Task aus einem leeren Taskpool zu entnehmen, wird er ugt ein bzgl. der Bedingungsvariable not empty blockiert. F¨ Thread einen Task in einen leeren Taskpool ein, wird ein bzgl. der Bedingungsvariable not empty blockierter Thread aufgeweckt. Wenn ein Thread versucht, eine Task in einen vollen Taskpool einzuf¨ ugen, wird er bzgl. der Bedingungsvariable not full blockiert. Entnimmt ein Thread eine Task aus einem vollen Taskpool, wird ein evtl. bzgl. der Bedingungsvariable not full blockierter Thread aufgeweckt. Die Funktion tpool init() in Abbildung 4.3 initialisiert einen Taskpool, indem sie die Datenstruktur allokiert, mit den als Argument mitgelieferten Werten initialisiert und die zur Abarbeitung vorgesehene Anzahl von Threads tpl->threads[i], i=0,...,num thr-1, erzeugt. Jeder dieser Threads erh¨ alt eine Funktion tpool thread() als Startroutine, die einen Taskpool tpl als Argument hat. Die in Abb. 4.4 angegebene Funktion tpool thread() dient der Abarbeitung von im Taskpool abgelegten Tasks. In jedem Durchlauf der Schleife von tpool thread() wird versucht, eine Task vom Anfang der Taskliste des Taskpools zu entnehmen. Wenn der Taskpool zur Zeit leer ist, wird der ausf¨ uhrende Thread bzgl. der Bedingungsvariable not empty blockiert. Sonst wird eine Task wl vom Anfang der Taskschlange entnommen. War der Taskpool vor der Entnahme voll, werden alle Threads, die blockiert sind, weil sie eine Task abzulegen versuchen, mit einer pthread cond broadcast()-Anweisung aufgeweckt. Die Zugriffe von tpool thread auf den Taskpool werden durch die
4.5 Implementierung eines Taskpools
81
typedef struct work { void (*routine)(); void *arg; struct work *next; } work t; typedef struct tpool { int num thr, max size, current size; pthread t *threads; work t *head, *tail; pthread mutex t lock; pthread cond t not empty, not full; } tpool t; tpool t *tpool init (int num thr, int max size) { int i; tpool t *tpl; tpl = (tpool t *) malloc (sizeof (tpool t)); tpl->num thr = num thr; tpl->max size = max size; tpl->current size = 0; tpl->head = tpl->tail = NULL; pthread mutex init (&(tpl->lock), NULL); pthread cond init (&(tpl->not empty), NULL); pthread cond init (&(tpl->not full), NULL); tpl->threads = (pthread t *) malloc(sizeof(pthread t)*num thr); for (i=0; ithreads[i]), NULL, tpool thread, (void *) tpl); return tpl; } Abbildung 4.3. Implementierung eines Taskpools: Datenstrukturen und Initialisierung.
82
4 Programmierung mit Pthreads void *tpool thread (tpool t *tpl) { work t *wl; for( ; ; ) { pthread mutex lock (&(tpl->lock)); while (tpl->current size == 0) pthread cond wait (&(tpl->not empty), &(tpl->lock)); wl = tpl->head; tpl->current size --; if (tpl->current size == 0) tpl->head = tpl->tail = NULL; else tpl->head = wl->next; if (tpl->current size == tpl->max size - 1) pthread cond broadcast (&(tpl->not full)); pthread mutex unlock (&(tpl->lock)); (*(wl->routine))(wl->arg); free(wl); } }
Abbildung 4.4. Funktion tpool thread() zur Taskpoolimplementierung.
Mutexvariable lock gesch¨ utzt. Die Abarbeitung der Funktion routine einer entnommenen Task wl wird danach ausgef¨ uhrt. Diese Abarbeitung kann die Erzeugung neuer Tasks beinhalten, die durch die Funktion tpool insert in den Taskpool tpl eingetragen werden. ugt eiDie Funktion tpool insert() in Abbildung 4.5 f¨ ne Task in den Taskpool ein. Falls der Taskpool voll ist, wird der ausf¨ uhrende Thread bzgl. der Bedingungsvariable not full blockiert. Ist der Taskpool nicht voll, so wird eine Task mit den entsprechenden Daten belegt und an das Ende der Taskschlange geh¨ angt. War diese vor dem Anh¨angen
4.5 Implementierung eines Taskpools
83
void tpool insert (tpool t *tpl, void (*routine)(), void *arg) { work t *wl; pthread mutex lock (&(tpl->lock)); while (tpl->current size == tpl->max size) pthread cond wait (&(tpl->not full), &(tpl->lock)); wl = (work t *) malloc (sizeof (work t)); wl->routine = routine; wl->arg = arg; wl->next = NULL; if (tpl->current size == 0) { tpl->tail = tpl->head = wl; pthread cond signal (&(tpl->not empty)); } else { tpl->tail->next = wl; tpl->tail = wl; } tpl->current size ++; pthread mutex unlock (&(tpl->lock)); } Abbildung 4.5. Funktion tpool insert() zur Taskpoolimplementierung.
leer, wird ein Thread, der bzgl. der Bedingungsvariable not empty blockiert ist, aufgeweckt. Die Manipulationen des Taskpools tpl werden wieder durch die Mutexvariable gesch¨ utzt. Die skizzierte Implementierung eines Taskpools ist insbesondere f¨ ur ein Master-Slave-Modell geeignet, in dem ein unschte Anzahl Master-Thread mit tpool init() die gew¨
84
4 Programmierung mit Pthreads
von Slave-Threads erzeugt, von denen jeder die Funktion tpool thread() abarbeitet. Die zu bearbeitenden Tasks werden entsprechend der zu realisierenden Anwendung definiert und k¨ onnen vom Master-Thread durch Aufruf von tpool insert() in den Taskpool eingetragen werden. Werden bei der Bearbeitung einer Task neue Tasks erzeugt, k¨ onnen diese auch vom ausf¨ uhrenden Slave-Thread eingetragen werden. Die Beendigung der Slave-Threads nach vollst¨ andiger Abarbeitung aller Tasks wird vom MasterThread u ¨bernommen. Dazu werden alle bzgl. der beiden Bedingungsvariablen not empty und not full blockierten Threads aufgeweckt und beendet. Sollte ein Thread gerade eine Task bearbeiten, wird auf die Beendigung der Abarbeitung gewartet bevor der Thread beendet wird.
5 Java-Threads
Die Entwicklung von aus mehreren Threads bestehenden Programmen wird in der objektorientierten Programmiersprache Java auf Sprachebene unterst¨ utzt. Java stellt dazu u.a. Sprachkonstrukte f¨ ur die synchronisierte Ausf¨ uhrung von Programmbereichen bereit und erlaubt die Erzeugung und Verwaltung von Threads durch Verwendung vordefinierter Klassen. Im Folgenden wird die Verwendung von Java-Threads zur Entwicklung paralleler Programme f¨ ur einen gemeinsamen Adressraum kurz vorgestellt, wobei nur auf f¨ ur Threads wesentliche Aspekte eingegangen wird. F¨ ur eine ausf¨ uhrliche Behandlung der Programmiersprache Java verweisen wir auf [22].
5.1 Erzeugung von Threads in Java Jedes ausgef¨ uhrte Java-Programm besteht aus mindestens einem Thread, dem Haupt-Thread. Dieses ist der Thread, der die main()-Methode der Klasse ausf¨ uhrt, die als Startargument der Java Virtual Machine (JVM) angegeben
86
5 Java-Threads
wird. Weitere Benutzer-Threads werden von diesem HauptThread oder von bereits erzeugten Threads explizit erzeugt und gestartet. Dazu steht die vordefinierte Klasse Thread aus dem Standardpaket java.lang zur Verf¨ ugung, die zur Repr¨ asentation von Threads verwendet wird und die Mechanismen und Methoden zur Erzeugung und Verwaltung von Threads bereitstellt. Das Interface Runnable aus java.lang repr¨ asentiert den von einem Thread auszuf¨ uhrenden Code, der in einer run()-Methode zur Verf¨ ugung gestellt wird. F¨ ur die Definition einer run()-Methode, die von einem Thread asynchron ausgef¨ uhrt wird, gibt es zwei M¨ oglichkeiten: das Erben von der Klasse Thread oder die Implementierung des Interfaces Runnable. Erben von der Klasse Thread Bei diesem Vorgehen wird eine neue Klasse NewClass definiert, die von der vordefinierten Klasse Thread erbt und die enthaltene Methode run() mit den Anweisungen des auszuf¨ uhrenden Threads u atzlich enth¨alt ¨ berschreibt. Zus¨ die Klasse Thread eine Methode start(), die einen neuen Thread erzeugt, der dann die Methode run() ausf¨ uhrt. Der neu erzeugte Thread wird asynchron zum aufrufenden Thread ausgef¨ uhrt. Nach Ausf¨ uhrung von start() wird die Kontrolle direkt an den aufrufenden Thread zur¨ uckgegeben. Dies erfolgt evtl. vor der Beendigung des neu erzeugten Threads, so dass erzeugender und erzeugter Thread asynchron zueinander arbeiten. Der neu erzeugte Thread terminiert, sobald seine run()-Methode vollst¨ andig abgearbeitet ist. Dieses Vorgehen ist in Abbildung 5.1 am Beispiel einer Klasse NewClass illustriert, deren main-Methode ein Objekt der Klasse NewClass erzeugt und dessen run()Methode durch Aufruf von start aktiviert wird.
5.1 Erzeugung von Threads in Java
87
import java.lang.Thread; public class NewClass extends Thread { // Vererbung public void run() { // ¨ Uberschreiben von run() der Thread-Klasse System.out.println(”hello from new thread”); } public static void main (String args[]) { NewClass nc = new NewClass(); nc.start(); } }
¨ Abbildung 5.1. Erzeugung eines Threads durch Uberschreiben der run()-Methode der Klasse Thread.
Bei der gerade beschriebenen Methode zur Erzeugung eines Threads muss die neue Klasse von der Klasse Thread erben. Da Java keine Mehrfach-Vererbung zul¨ asst, hat dies den Nachteil, dass die neue Klasse von keiner weiteren Klasse erben kann, was die Entwicklung von Anwendungsprogrammen einschr¨ ankt. Dieser Nachteil der fehlenden Mehrfach-Vererbung wird in Java durch die Bereitstellung von Interfaces ausgeglichen, wof¨ ur im Falle der Klasse Thread das Interface Runnable genutzt wird. Verwendung des Interface Runnable Das Interface Runnable enth¨ alt eine parameterlose run()Methode: public interface Runnable { public abstract void run(); }
88
5 Java-Threads
Die vordefinierte Klasse Thread implementiert das Interface Runnable, d.h. jede von Thread abgeleitete Klasse implementiert ebenfalls das Interface Runnable. Eine neu erzeugte Klasse NewClass kann daher auch direkt das Interface Runnable implementieren anstatt von der Klasse Thread abgeleitet zu werden. Objekte einer solchen Klasse NewClass sind aber keine Threadobjekte, so dass zur Erzeugung eines Threads immer noch ein Objekt der Klasse Thread erzeugt werden muss, das allerdings als Parameter ein Objekt der neuen Klasse NewClass hat. Dazu enth¨alt die Klasse Thread einen Konstruktor public Thread (Runnable target). Bei Verwendung dieses Konstruktors ruft die start()Methode von Thread die run()-Methode des Parameterobjektes vom Typ Runnable auf. Dies wird durch die run()Methode von Thread erreicht, die wie folgt definiert ist: public void run() { if (target != null) target.run(); } Die run()-Methode wird in einem separaten, neu erzeugten Thread asynchron zum aufrufenden Thread ausgef¨ uhrt. Die Erzeugung eines neuen Threads kann somit in drei Schritten erfolgen: (1) Definition einer neuen Klasse NewClass, die Runnable implementiert und f¨ ur die eine run()-Methode definiert wird, die die von dem neu zu erzeugenden Thread auszuf¨ uhrende Anweisungsfolge enth¨ alt; (2) Erzeugung eines Objektes der Klasse Thread mit Hilfe des Konstruktors Thread(Runnable target) und ei¨ nes Objektes der Klasse NewClass sowie Ubergabe dieses Objektes an den Thread-Konstruktor; (3) Aufruf der start()-Methode des Thread-Objektes.
5.1 Erzeugung von Threads in Java
89
Dieses Vorgehen ist in Abbildung 5.2 am Beispiel einer Klasse NewClass illustriert. Ein Objekt dieser Klasse wird dem Konstruktor von Thread als Parameter u ¨ bergeben. import java.lang.Thread; public class NewClass implements Runnable { public void run() { System.out.println(”hello from new thread”); } public static void main (String args[]) { NewClass nc = new NewClass(); Thread th = new Thread(nc); th.start(); // start() ruft nc.run() auf } } Abbildung 5.2. Erzeugung eines Threads mit Hilfe des Interface Runnable und Verwendung einer neuen Klasse NewClass.
Weitere Methoden der Klasse Thread Ein Java-Thread kann auf die Beendigung eines anderen Java-Threads t warten, indem er t.join() aufruft. Durch diesen Aufruf blockiert der aufrufende Thread so lange, bis der Thread t beendet ist. Die join()-Methode wird in drei Varianten zur Verf¨ ugung gestellt: • •
void join(): der aufrufende Thread wird blockiert, bis der angegebene Thread beendet ist; void join(long timeout): der aufrufende Thread wird blockiert; die Blockierung wird aufgehoben, sobald der
90
•
5 Java-Threads
angegebene Thread beendet ist oder wenn die angegebene Zeit timeout abgelaufen ist (Angabe in Millisekunden); void join(long timeout, int nanos): das Verhalten entspricht dem von void join(long timeout); der zus¨ atzliche Parameter erm¨ oglicht eine genauere Angabe des Zeitintervalls durch die zus¨ atzliche Angabe von Nanosekunden.
Wurde der angegebene Thread noch nicht gestartet, findet bei keiner der join()-Varianten eine Blockierung statt. Die Methode boolean isAlive() der Klasse Thread erm¨ oglicht die Abfrage des Ausf¨ uhrungsstatus eines Threads: die Methode liefert true zur¨ uck, falls der angegebene Thread gestartet wurde, aber noch nicht beendet ist. Weder die isAlive()-Methode noch die verschiedenen Varianten der join-Methode haben einen Einfluss auf den Thread, der Ziel des Aufrufes ist. Nur der ausf¨ uhrende Thread ist betroffen. Die Thread-Klasse definiert einige statische Methoden, die den aktuell ausgef¨ uhrten Thread betreffen oder Informationen u ber das Gesamtprogramm liefern. ¨ Da diese Methoden statisch sind, k¨ onnen sie aufgerufen werden, auch wenn kein Objekt der Klasse Thread verwendet wird. Der Aufruf der Methode static Thread currentThread(); liefert eine Referenz auf das Thread-Objekt des aufrufenden Threads. Diese Referenz kann z.B. dazu verwendet werden, nicht-statische Methoden dieses Thread-Objektes aufzurufen. Die Methode static void sleep (long milliseconds);
5.2 Synchronisation von Java-Threads
91
blockiert den ausf¨ uhrenden Thread vor¨ ubergehend f¨ ur die angegebene Anzahl von Millisekunden, d.h. der Prozessor kann einem anderen Thread zugeteilt werden. Nach Ablauf des Zeitintervalls wird der Thread wieder ausf¨ uhrungsbereit und kann wieder einem Prozessor zur weiteren Ausf¨ uhrung zugeteilt werden. Die Methode static void yield(); ist ein Hinweis an die Java Virtual Machine (JVM), dass ein anderer ausf¨ uhrungsbereiter Thread gleicher Priorit¨at dem Prozessor zugeteilt werden soll. Wenn ein solcher Thread existiert, kann der Scheduler der JVM diesen zur Ausf¨ uhrung bringen. Die Anwendung von yield() ist sinnvoll f¨ ur JVM-Implementierungen ohne Scheduling mit Zeitscheibenverfahren, falls Threads langlaufende Berechnungen ohne Blockierungsm¨ oglichkeit ausf¨ uhren. Die Methode static int enumerate (Thread[] th_array); liefert eine Liste aller Thread-Objekte des Programms. Der R¨ uckgabewert gibt die Anzahl der im Parameterfeld th array abgelegten Thread-Objekte an. Mit der Methode static int activeCount(); kann die Anzahl der Thread-Objekte des Programms bestimmt werden. Die Methode kann z.B. verwendet werden, um vor Aufruf von enumerate() die erforderliche Gr¨oße des Parameterfeldes zu ermitteln.
5.2 Synchronisation von Java-Threads Die Threads eines Java-Programms arbeiten auf einem gemeinsamen Adressraum. Wenn auf Variablen durch mehrere Threads zugegriffen werden kann, m¨ ussen also zur Ver-
92
5 Java-Threads
meidung zeitkritischer Abl¨ aufe geeignete Synchronisationsmechanismen angewendet werden. Zur Sicherstellung des wechselseitigen Ausschlusses von Threads beim Zugriff auf gemeinsame Daten stellt Java synchronized-Bl¨ocke und -Methoden zur Verf¨ ugung. Wird ein Block oder eine Methode als synchronized deklariert, ist sichergestellt, dass keine gleichzeitige Ausf¨ uhrung durch zwei Threads erfolgen kann. Eine Datenstruktur kann also dadurch vor konkurrierenden Zugriffen mehrerer Threads gesch¨ utzt werden, dass alle Zugriffe auf die Datenstruktur in synchronized Methoden oder Bl¨ ocken erfolgen. Die synchronisierte Inkrementierung eines Z¨ ahlers kann beispielsweise durch folgende Methode incr() realisiert werden: public class Counter { private int value = 0; public synchronized int incr() { value = value + 1; return value; } } In der JVM wird die Synchronisation dadurch realisiert, dass jedem Java-Objekt implizit eine Mutexvariable zugeordnet wird. Jedes Objekt der allgemeinen Klasse Object besitzt eine solche implizite Mutexvariable. Da jede Klasse direkt oder indirekt von der Klasse Object abgeleitet ist, besitzt somit jedes Objekt eine Mutexvariable. Der Aufruf einer synchronized-Methode bez¨ uglich eines Objektes Ob hat den folgenden Effekt: •
Beim Start der synchronized-Methode durch einen Thread t wird die Mutexvariable von Ob implizit belegt. Wenn die Mutexvariable bereits von einem anderen Thread belegt ist, wird der ausf¨ uhrende Thread t blockiert. Der blockierte Thread wird wieder ausf¨ uhrungs-
5.2 Synchronisation von Java-Threads
•
93
bereit, wenn die Mutexvariable freigegeben wird. Die aufgerufene synchronized-Methode wird nur bei erfolgreicher Sperrung der Mutexvariablen von Ob ausgef¨ uhrt. Beim Verlassen der Methode wird die Mutexvariable von Ob implizit wieder freigegeben und kann damit von einem anderen Thread gesperrt werden.
Damit kann ein synchronisierter Zugriff auf ein Objekt dadurch realisiert werden, dass alle Methoden, die konkurrierend durch mehrere Threads aufgerufen werden k¨onnen, als synchronized deklariert werden. Zur Sicherstellung des wechselseitigen Ausschlusses ist es wichtig, dass nur u utzende Objekt zuge¨ ber diese Methoden auf das zu sch¨ griffen wird. Neben synchronized-Methoden k¨onnen auch synchronized-Bl¨ ocke verwendet werden. Dies ist dann sinnvoll, wenn nur ein Teil einer Methode auf kritische Daten zugreift, eine synchronisierte Ausf¨ uhrung der gesamten Methode aber nicht notwendig erscheint. Bei synchronizedBl¨ ocken erfolgt die Synchronisation meist bez¨ uglich des Objektes, in dessen Methode der synchronized-Block steht. Die obige Methode zur Inkrementierung eines Z¨ahlers kann mit Hilfe eines synchronized-Blocks folgendermaßen formuliert werden: public int incr() { synchronized (this) { value = value + 1; return value; } } Der Synchronisationsmechanismus von Java kann zur Realisierung voll-synchronisierter Objekte, auch atomare Objekte genannt, verwendet werden, die von einer beliebigen Anzahl von Threads ohne Synchronisation zugegriffen werden k¨ onnen. Damit dabei keine zeitkritischen Abl¨aufe entstehen, muss die Synchronisation in der definierenden
94
5 Java-Threads
Klasse enthalten sein. Diese muss folgende Bedingungen erf¨ ullen: • • • •
alle Methoden m¨ ussen synchronized sein, es d¨ urfen keine public-Felder enthalten sein, die ohne Aufruf einer Methode zugegriffen werden k¨ onnen, alle Felder werden in Konstruktoren der Klasse konsistent initialisiert, der Zustand der Objekte bleibt auch beim Auftreten von Ausnahmen in einem konsistenten Zustand.
Abbildung 5.3 zeigt das Konzept voll-synchronisierter Objekte am Beispiel einer Klasse ExpandableArray, die eine vereinfachte Version der vordefinierten synchronisierten Klasse java.util.Vector ist, vgl. auch [40]. Die Klasse realisiert ein adaptierbares Feld mit beliebigen Objekten, dessen Gr¨ oße entsprechend der Anzahl abgelegter Objekte wachsen oder schrumpfen kann. Dies ist in der Methode add() realisiert: wird beim Hinzuf¨ ugen eines neuen Elementes festgestellt, dass das Feld data voll belegt ist, wird dieses entsprechend vergr¨ oßert. Dazu wird ein gr¨oßeres Feld neu angelegt und das bisherige Feld wird mit Hilfe der Methode arraycopy() aus der System-Klasse umkopiert. Ohne die Synchronisationsoperationen k¨ onnte die Klasse nicht sicher von mehreren Threads gleichzeitig genutzt werden. Ein Konflikt k¨ onnte z.B. auftreten, wenn zwei Threads zum gleichen Zeitpunkt versuchen, eine add-Operation durchzuf¨ uhren. Auftreten von Deadlocks Die Verwendung voll synchronisierter Klassen vermeidet zwar das Auftreten zeitkritischer Abl¨ aufe, es k¨onnen aber Deadlocks auftreten, wenn Threads bzgl. mehrerer Objekte
5.2 Synchronisation von Java-Threads
95
import java.lang.*; import java.util.*; public class ExpandableArray { private Object[] data; private int size = 0; public ExpandableArray(int cap) { data = new Object[cap]; } public synchronized int size() { return size; } public synchronized Object get(int i) throws NoSuchElementException { if (i < 0 || i >= size) throw new NoSuchElementException(); return data[i]; } public synchronized void add(Object x) { if (size == data.length) { // Feld zu klein Object[] od = data; data = new Object[3 * (size + 1) / 2]; System.arraycopy(od, 0, data, 0, od.length); } data[size++] = x; } public synchronized void removeLast() throws NoSuchElementException { if (size == 0) throw new NoSuchElementException(); data[--size] = null; } } Abbildung 5.3. Beispiel f¨ ur eine voll synchronisierte Klasse.
96
5 Java-Threads pulic class Account { private long balance; synchronized long getBalance() {return balance;} synchronized void setBalance(long v) { balance = v; } synchronized void swapBalance(Account other) { long t = getBalance(); long v = other.getBalance(); setBalance(v); other.setBalance(t); } }
Abbildung 5.4. Beispiel f¨ ur das Auftreten eines Deadlocks.
synchronisiert werden. Dies ist in Abb. 5.4 am Beispiel eines Kontos (Klasse Account) veranschaulicht, bei dem die Methode swapBalance() die Kontost¨ ande austauscht, vgl. auch [40]. Bei der Bearbeitung von swapBalance() ist beim Einsatz von zwei Threads T1 und T2 das Auftreten eines Deadlocks m¨ oglich, wenn ein Thread a.swapBalance(b), der andere Thread b.swapBalance(a) aufruft und die beiden Threads auf unterschiedlichen Prozessorkernen eines Prozessors ablaufen. Der Deadlock tritt bei folgender Abarbeitungsreihenfolge auf: (A) Zeitpunkt 1: Thread T1 ruft a.swapBalance(b) auf und erh¨ alt die Mutexvariable von Objekt a; ur Objekt (B) Zeitpunkt 2: Thread T1 ruft getBalance() f¨ a auf und f¨ uhrt die Funktion aus; (C) Zeitpunkt 2: Thread T2 ruft b.swapBalance(a) auf und erh¨ alt die Mutexvariable von Objekt b;
5.2 Synchronisation von Java-Threads
97
(D) Zeitpunkt 3: Thread T1 ruft b.getBalance() auf und blockiert bzgl. der Mutexvariable von Objekt b; (E) Zeitpunkt 3: Thread T2 ruft getBalance() F¨ ur Objekt b auf auf f¨ uhrt die Funktion aus; (F) Zeitpunkt 4: Thread T2 ruft a.getBalance() auf und blockiert bzgl. der Mutexvariable von Objekt a. Der Ablauf ist in Abb. 5.5 veranschaulicht. Zum Zeitpunkt 4 sind beide Thread blockiert: Thread T1 ist bzgl. der Mutexvariable von b blockiert. Diese ist von Thread T2 belegt und kann nur von Thread T2 freigegeben werden. Thread T2 ist bzgl. der Mutexvariablen von a blockiert, die nur von Thread T1 freigegeben werden kann. Somit warten die beiden Threads gegenseitig aufeinander und es ist ein Deadlock eingetreten. Zeitpunkt 1 2 3 4
Thread T1
Thread T2
a.swapBalance(b) t = getBalance() b.swapBalance(a) Blockierung bzgl. b t = getBalance() Blockierung bzgl. a
Abbildung 5.5. Deadlockablauf zu Abb. 5.4.
Deadlocks treten typischerweise dann auf, wenn unterschiedliche Threads die Mutexvariablen derselben Objekte in unterschiedlicher Reihenfolge zu sperren versuchen. Im Beispiel von Abb. 5.5 versucht Thread T1 zuerst a und dann b zu sperren, Thread T2 versucht das Sperren in umgekehrter Reihenfolge. In dieser Situation kann das Auftreten eines Deadlocks dadurch vermieden werden, dass die beteiligten Threads die Objekte immer in der gleichen Reihenfolge zu sperren versuchen. In Java kann diese
98
5 Java-Threads
dadurch realisiert werden, dass die zu sperrenden Objekte beim Sperren eindeutig angeordnet werden; dazu kann z.B. die Methode System.identityHashCode() verwendet werden, die sich immer auf die Default-Implementierung Object.hashCode() bezieht [40]; diese liefert eine eindeutige Indentifizierung des Objektes. Es kann aber auch eine beliebige andere eindeutige Anordnung der Objekte verwendet werden. Damit kann eine alternative Formulierung von swapBalance() angegeben werden, bei der keine Deadlocks auftreten k¨ onnen, vgl. Abb. 5.6. Die neue Formulierung enth¨alt ¨ auch eine Alias-Uberpr¨ ufung, so dass die Operation nur ausgef¨ uhrt wird, wenn unterschiedliche Objekte beteiligt sind. Die Methode swapBalance() ist jetzt nicht mehr als synchronized deklariert. public void swapBalance(Account other) { if (other == this) return; else if (System.identityHashCode(this) < System.identityHashCode(other)) this.doSwap(other); else other.doSwap(this); } protected synchronized void doSwap(Account other) { long t = getBalance(); long v = other.getBalance(); setBalance(v); other.setBalance(t); } Abbildung 5.6. Deadlockfreie Realisierung von swapBalance() aus Abb. 5.4.
5.2 Synchronisation von Java-Threads
99
Bei der Synchronisation von Java-Methoden sollten ein paar Hinweise beachtet werden, die die resultierenden Programme effizienter und sicherer machen: •
•
•
•
•
•
Synchronisation ist teuer. Synchronisierte Methoden sollten daher nur dann verwendet werden, wenn die Methoden von mehreren Threads aufgerufen werden kann und wenn innerhalb der Methoden gemeinsame Objektdaten ver¨ andert werden k¨ onnen. Wenn f¨ ur die Anwendung sichergestellt ist, dass eine Methode jeweils nur von einem Thread zugegriffen wird, kann eine Synchronisation zur Erh¨ ohung der Effizienz vermieden werden. Die Synchronisation sollte auf die kritischen Bereiche beschr¨ ankt werden, um so die Zeit der Sperrung von Objekten zu reduzieren. Anstelle von synchronizedMethoden sollten bevorzugt synchronized-Bl¨ocke verwendet werden. Die Mutexvariable eines Objektes sollte nicht zur Synchronisation nicht zusammenh¨ angender kritischer Bereiche verwendet werden, da dies zu unn¨ otigen Sequentialisierungen f¨ uhren kann. Einige Java-Klassen sind bereits intern synchronisiert; Beispiele sind Hashtable, Vector und StringBuffer. Zus¨ atzliche Synchronisation ist f¨ ur Objekte dieser Klassen also u ussig. ¨ berfl¨ Ist f¨ ur ein Objekt Synchronisation erforderlich, sollten die Daten in private oder protected Feldern abgelegt werden, damit kein unsynchronisierter Zugriff von außen m¨ oglich ist; alle zugreifenden Methoden m¨ ussen synchronized sein. Greifen Threads eines Programms in unterschiedlicher Reihenfolge auf Objekte zu, k¨ onnen Deadlocks durch Verwendung der gleichen Sperr-Reihenfolge verhindert werden.
100
5 Java-Threads
Die Realisierung von synchronized-Bl¨ ocken mit Hilfe der impliziten Mutexvariablen, die jedem Objekt zugeordnet sind, funktioniert f¨ ur alle Methoden, die bzgl. eines Objektes aktiviert werden. Statische Methoden einer Klasse werden jedoch nicht bzgl. eines speziellen Objektes aktiviert und eine implizite Objekt-Mutexvariable existiert daher nicht. Nichtsdestotrotz k¨ onnen auch statische Methoden als synchronized deklariert werden. Die Synchronisation erfolgt dann u ¨ ber die Mutexvariable des zugeh¨origen Klassenobjektes der Klasse java.lang.Class, das f¨ ur die Klasse, in der die statische Methode deklariert wird, automatisch erzeugt wird. Statische und nicht-statische synchronized Methoden einer Klasse verwenden also unterschiedliche Mutexvariablen f¨ ur die Synchronisation. Eine statische synchronized-Methode kann sowohl die Mutexvariable der Klasse als auch die Mutexvariable eines Objektes der Klasse sperren, indem sie eine nicht-statische Methode bzgl. eines Objektes der Klasse aufruft oder ein Objekt der Klasse zur Synchronisation nutzt. Dies wird in Abb. 5.7 anhand der Klasse MyStatic illustriert. Eine nicht-statische synchronized Methode kann durch den Aufruf einer statischen synchronized Methode ebenfalls neben der Objekt-Mutexvariablen auch die KlassenMutexvariable sperren. F¨ ur eine Klasse Cl kann die Synchronisation bzgl. der Klassen-Mutexvariablen auch direkt durch synchronized (Cl.class) erfolgen.
{
/* Rumpf*/ }
5.3 Signalmechanismus in Java
101
public class MyStatic { public static synchronized void staticMethod(MyStatic obj) { // hier wird die Klassen-Sperre verwendet obj.nonStaticMethod(); synchronized(obj) { // zus¨ atzliche Verwendung der Objekt-Sperre } } public synchronized void nonStaticMethod() { // Verwendung der Objekt-Sperre } } Abbildung 5.7. Synchronisation von statischen Methoden.
5.3 Signalmechanismus in Java In manchen Situationen ist es sinnvoll, dass ein Thread auf das Eintreten einer anwendungsspezifischen Bedingung wartet. Sobald die Bedingung erf¨ ullt ist, f¨ uhrt der Thread eine festgelegte Aktion aus. So lange die Bedingung noch nicht erf¨ ullt ist, wartet der Thread darauf, dass ein anderer Thread durch entsprechende Berechnungen das Eintreten der Bedingung herbeif¨ uhrt. In Pthreads konnten f¨ ur solche Situationen Bedingungsvariablen eingesetzt werden. Java stellt u ¨ ber die Methoden wait() und notify(), die in der vordefinierten Klasse Object deklariert sind, einen ¨ahnlichen Mechanismus zur Verf¨ ugung. Diese Methoden stehen somit f¨ ur jedes Objekt zur Verf¨ ugung, da jedes Objekt direkt oder indirekt von der Klasse Object abgeleitet ist. Beide Methoden d¨ urfen nur innerhalb eines synchronizedBlocks oder einer synchronized-Methode aufgerufen werden. Das typische Verwendungsmuster f¨ ur wait() ist:
102
5 Java-Threads
synchronized (lockObject) { while (!Bedingung) { lockObject.wait(); } Aktion; } Der Aufruf von wait() blockiert den aufrufenden Thread so lange, bis er von einem anderen Thread per notify() aufgeweckt wird. Die Blockierung bewirkt auch die Freigabe der impliziten Mutexvariable des Objektes, bzgl. der der Thread synchronisiert. Damit kann diese Mutexvariable von einem anderen Thread gesperrt werden. Ein Aufruf von notify() weckt einen bez¨ uglich des zugeh¨origen Objektes blockierten Thread auf. Der aufgeweckte Thread wird ausf¨ uhrungsbereit und versucht, die Kontrolle u ¨ ber die implizite Mutexvariable des Objektes wieder zu erhalten. Erst wenn ihm dies gelingt, f¨ uhrt er die nach Eintreten der Bedingung durchzuf¨ uhrende Aktion aus. Wenn dies nicht gelingt, blockiert der Thread bzgl. der Mutexvariablen, bis diese von dem Thread, der sie gesperrt hat, wieder freigegeben wird. Die Arbeitsweise von wait() und notify() ¨ahnelt der Arbeitsweise von Pthread-Bedingungsvariablen und den Operationen pthread cond wait() und pthread cond signal(), vgl. Seite 70. Die Implementierung von wait() und notify() erfolgt mit Hilfe einer impliziten Warteliste, in der f¨ ur jedes Objekt eine Menge von wartenden Threads gehalten wird. Die Warteliste enth¨ alt jeweils die Threads, die zum aktuellen Zeitpunkt durch Aufruf von wait() bez¨ uglich dieses Objektes blockiert wurden. Nicht in der Warteliste enthalten sind die Threads, die blockiert wurden, weil sie auf Zuteilung der impliziten Mutexvariable des Objektes warten. Welcher der Threads in der impliziten Warteliste beim Aufruf von notify() aufgeweckt wird, ist von der Java-Sprachspezifikation nicht festgelegt. Mit Hilfe der Me-
5.3 Signalmechanismus in Java
103
thode notifyAll() werden alle in der Warteliste abgelegten Threads aufgeweckt und ausf¨ uhrungsbereit; die analoge Pthreads-Funktion ist pthread cond broadcast(). Ebenso wie notify() muss notifyAll() in einem synchronizedBlock oder einer synchronized-Methode aufgerufen werden. Produzenten-Konsumenten-Muster Der Java-Signalmechanismus kann etwa zur Realisierung eines Produzenten-Konsumenten-Musters mit Ablage- bzw. Entnahmepuffer fester Gr¨ oße verwendet werden, in den Produzenten-Threads Datenobjekte ablegen und aus dem Konsumenten-Threads Daten zur Weiterverarbeitung entnehmen k¨ onnen. Abbildung 5.8 zeigt eine threadsichere Implementierung eines Puffermechanismus mit Hilfe des Java-Signalmechanismus, vgl. auch [40]. Beim Erzeugen eines Objektes vom Typ BoundedBufferSignal wird ein Feld array vorgegebener Gr¨ oße capacity erzeugt, das als Puffer dient. Zentrale Methoden der Klasse sind put() zur Ablage eines Datenobjektes im Puffer und take() zur Entnahme eines Datenobjektes aus dem Puffer. Ein Pufferobjekt kann in einem der drei Zust¨ ande voll, teilweise voll und leer sein, siehe ¨ 5.9 f¨ ur eine Veranschaulichung der Uberg¨ ange zwischen den Zust¨ anden. Die Zust¨ ande sind durch folgende Bedingungen charakterisiert: Zustand
Bedingung
put take m¨ oglich m¨oglich voll size == capacity nein ja teilweise voll 0 < size < capacity ja ja leer size == 0 ja nein
104
5 Java-Threads
public class BoundedBufferSignal { private final Object[] array; private int putptr = 0; private int takeptr = 0; private int numel = 0; public BoundedBufferSignal (int capacity) throws IllegalArgumentException { if (capacity <= 0) throw new IllegalArgumentException(); array = new Object[capacity]; } public synchronized int size() {return numel; } public int capacity() {return array.length;} public synchronized void put(Object obj) throws InterruptedException { while (numel == array.length) wait(); // Puffer voll array [putptr] = obj; putptr = (putptr +1) % array.length; if (numel++ == 0) notifyAll(); // alle Threads aufwecken } public synchronized Object take() throws InterruptedException { while (numel == 0) wait(); // Puffer leer Object x = array [takeptr]; takeptr = (takeptr +1) % array.length; if (numel-- == array.length) notifyAll(); // alle Threads aufwecken return x; } } Abbildung 5.8. Realisierung eines threadsicheren Puffers mit dem Java-Signalmechanismus.
5.3 Signalmechanismus in Java take
voll put
105
take
teilweise voll
leer put
Abbildung 5.9. Veranschaulichung der Zust¨ ande eines threadsicheren Puffermechanismus.
Bei der Ausf¨ uhrung einer put()-Operation durch einen Produzenten-Thread wird dieser mittels wait() blockiert, wenn der Puffer voll ist. Wird eine put()-Operation auf einem vorher leeren Puffer ausgef¨ uhrt, werden nach Ablage des Datenobjektes alle wartenden (Konsumenten)-Threads mit notifyAll() aufgeweckt. Bei der Ausf¨ uhrung einer take()-Operation durch einen Konsumenten-Thread wird dieser mit wait() blockiert, wenn der Puffer leer ist. Wird eine take()-Operation auf einem vorher vollen Puffer ausgef¨ uhrt, werden nach Entnahme des Datenobjektes alle wartenden (Produzenten)Threads mit notifyAll() aufgeweckt. Die Implementierung von put() und take() stellt sicher, dass ein Objekt der Klasse BoundedPufferSignal von einer beliebigen Anzahl von Threads zugegriffen werden kann, ohne dass zeitkritische Abl¨ aufe entstehen. Weitere Methoden Die Klasse Object stellt zwei Varianten von wait() zur Verf¨ ugung, die die Angabe einer maximalen Wartezeit in Millisekunden bzw. zus¨ atzlichen Nanosekunden erlauben: void wait (long msecs) void wait (long msecs, int nanos) Beide Varianten haben den gleichen Effekt wie wait() ohne Parameter mit dem Unterschied, dass die Blockierung des
106
5 Java-Threads
Threads automatisch aufgehoben wird, sobald das als Parameter angegebene Zeitintervall msecs abgelaufen ist. Da diese beiden Varianten ebenfalls in einem synchronizedBlock oder einer synchronized-Methode stehen m¨ ussen, versucht ein wegen des Ablaufs des Zeitintervalls aufgeweckter Thread nach dem Aufwecken zuerst, die Kontrolle u ¨ ber die implizite Mutexvariable des Objektes zu erhalten. Wenn dies nicht gelingt, wird er bzgl. dieser Mutexvariable blockiert. Durch die daraus evtl. resultierende Wartezeit besteht keine Garantie daf¨ ur, dass der vorher blockierte Thread nach Ablauf des angegebenen Zeitintervalls tats¨ achlich wieder zur Ausf¨ uhrung kommt. Es kann auch keine Obergrenze f¨ ur die zus¨ atzliche Wartezeit angegeben werden. Es gibt f¨ ur den aufgeweckten Thread auch keine M¨ oglichkeit festzustellen, ob er durch Ablauf des angegebenen Zeitintervalls oder durch Aufruf von notify() durch einen anderen Thread aufgeweckt wurde. Die Aufrufe wait(0) bzw. wait(0,0) sind ¨ aquivalent zum Aufruf wait() ohne Parameter. Ein durch einen Aufruf von wait(), sleep() oder join() blockierter Thread kann auch dadurch wieder aufgeweckt werden, dass er von einem anderen Thread unterbrochen wird. Dazu steht die Methode void interrupt() der Klasse Thread zur Verf¨ ugung. Durch Aufruf dieser Methode wird der blockierte Thread mit der Ausnahme InterruptedException aufgeweckt, die gem¨ aß der u ¨ blichen Regeln f¨ ur die Ausnahmebehandlung verarbeitet werden kann. Dies wird von den Methoden put() und take() in Abb. 5.8 ber¨ ucksichtigt. Auf einen nicht blockierten Thread t hat der Aufruf von t.interrupt() den Effekt, dass das Interrupt-Flag des Threads t auf true gesetzt
5.3 Signalmechanismus in Java
107
wird. Ist das Interrupt-Flag eines Threads t auf true gesetzt, wird bei einem Aufruf von wait(), join() oder sleep() durch diesen Thread direkt die Ausnahme InterruptedException ausgel¨ ost. Ein Thread kann seinen eigenen Interrupt-Status durch Aufruf der statischen Methode static boolean interrupted() der Klasse Thread u ufen. Der Interrupt-Status eines ¨berpr¨ beliebigen Threads kann durch Aufruf der nicht-statischen Methode boolean isInterrupted() f¨ ur das entsprechende Objekt der Klasse Thread abgefragt werden. Es ist zu beachten, dass das Unterbrechen eines Threads mit interrupt() nicht unbedingt seine Terminierung nach sich zieht, obwohl dies f¨ ur die meisten Anwendungen der Normalfall ist. Ein vorher nicht blockierter Thread kann aber trotz gesetztem Interrupt-Flag weiterarbeiten, um dadurch z.B. vor seiner Terminierung einen konsistenten Zustand zu hinterlassen. Die Methoden static void sleep (long msecs) static void sleep (long msecs, int nanos) der Klasse Thread suspendieren den ausf¨ uhrenden Thread f¨ ur das angegebene Zeitintervall. Im Unterschied zu wait() muss sleep() aber nicht in einem synchronized-Block stehen. Ein Aufruf von sleep() hat auch keinen Einfluss auf eine entl. vom ausf¨ uhrenden Thread gesperrte implizite Mutexvariable eines Objektes. Wenn sleep() in einem synchronized Block steht, f¨ uhrt der Aufruf von sleep() also nicht zur impliziten Freigabe der Mutexvariable des zugeh¨ origen Objektes, und die Mutexvariable bleibt in diesem Fall w¨ ahrend der Wartezeit des Threads gesperrt. Nach Ablauf des Zeitintervalls muss der ausf¨ uhrende Thread, im
108
5 Java-Threads
Unterschied zu wait(), also auch nicht versuchen, die Kontrolle u ¨ber die Mutexvariable des Objektes zu erhalten, sondern wird direkt ausf¨ uhrungsbereit. Die Methoden wait() und notify() sind nicht-statische Methoden der Klasse Object und k¨ onnen daher durch statische nicht direkt aufgerufen werden, da es f¨ ur statische Methoden keine zugeh¨ orige Objektreferenz gibt. Um wait() bzw. notify() in statischen Methoden verwenden zu k¨ onnen, muss ein zus¨ atzliches Objekt erzeugt werden, bez¨ uglich dem die Synchronisation in Form von wait() und notify() durchgef¨ uhrt werden kann. Dies kann ein beliebiges Objekt der Klasse Object sein, aber auch das Class-Objekt der Klasse, in der die zu synchronisierenden statischen Methoden enthalten sind. Dies ist in Abbildung 5.10 am Beispiel einer Klasse mit zwei statischen Methoden illustriert. public class MyStaticClass { public static void staticWait() throws InterruptedException { synchronized(MyStaticClass.class) { MyStaticClass.class.wait(); } } public static void staticNotify() { synchronized(MyStaticClass.class) { MyStaticClass.class.notify(); } } } Abbildung 5.10. Beispiel zur Synchronisation statischer Methoden mit wait() und notify().
5.4 Erweiterte Synchronisationsmuster
109
5.4 Erweiterte Synchronisationsmuster Die vorgestellten Synchronisationsmechanismen f¨ ur JavaThreads k¨ onnen dazu verwendet werden, komplexere Synchronisationsmuster zu realisieren, die h¨ aufig in parallelen Anwendungsprogrammen eine Rolle spielen. Dies wird am Beispiel eines Semaphor-Mechanismus (vgl. S. 50) gezeigt. Ein Semaphor-Mechanismus kann mit Hilfe von wait() und notify() in Java realisiert werden. Abb. 5.11 zeigt eine einfache Realisierung, vgl. auch [40, 52]. Die Methode acquire() wartet (wenn notwendig), bis der interne Z¨ ahler des Semaphor mindestens den Wert 1 angenommen hat. Sobald dies der Fall ist, wird der Z¨ ahler dekrementiert. Die Methode release() inkrementiert den Z¨ahler und weckt mit notify() einen wartenden Thread auf, der in acquire() durch den Aufruf von wait() blockiert wurde. Einen wartenden Thread kann es nur geben, wenn der Z¨ ahler vor dem Inkrementieren den Wert 0 hatte, denn nur dann wird ein Thread in acquire() blockiert. Da der Z¨ ahler nur um 1 inkrementiert wurde, reicht es aus, einen wartenden Thread aufzuwecken. Die Alternative w¨are der Einsatz von notifyAll(), wodurch alle wartenden Threads aufgeweckt w¨ urden. Von diesen k¨ onnte aber nur einer den Z¨ ahler dekrementieren. Da danach der Z¨ ahler wieder den Wert 0 hat, w¨ urden alle anderen Threads durch den Aufruf von wait wieder blockiert. Der in Abb. 5.11 beschriebene Semaphor-Mechanismus kann f¨ ur die Synchronisation eines Produzenten-Konsumenten-Verh¨ altnisses zwischen Threads verwendet werden. Ein ¨ ahnlicher Mechanismus wurde in Abb. 5.8 direkt mit wait() und notify() realisiert. Abb. 5.13 zeigt eine alternative Realisierung mit Semaphoren, vgl. auch [40]. Der Produzent legt die von ihm erzeugten Objekte in einem Puffer fester Gr¨ oße ab, der Konsument entnimmt Objekte
110
5 Java-Threads public class Semaphore { private long counter; public Semaphore(long init) { counter = init; } public void acquire() throws InterruptedException { if (Thread.interrupted()) throw new InterruptedException(); synchronized (this) { try { while (counter <= 0) wait(); counter--; } catch (InterruptedException ie) { notify(); throw ie; } } } public synchronized void release() { counter++; notify(); } }
Abbildung 5.11. Realisierung eines Semaphor-Mechanismus.
aus dem Puffer und verarbeitet sie weiter. Der Produzent kann nur Objekte im Puffer ablegen, wenn dieser nicht voll ist, der Konsument kann nur Objekte entnehmen, wenn der Puffer nicht leer ist. Die eigentliche Pufferverwaltung wird durch eine separate Klasse BufferArray realisiert, die Methoden insert() bzw. extract() zum Einf¨ ugen bzw. Entnehmen von Objekten zur Verf¨ ugung stellt, vgl. Abb.
5.4 Erweiterte Synchronisationsmuster
111
5.12. Beide Methoden sind synchronized, so dass mehrere Threads konkurrierend auf Objekte der Klasse zugreifen k¨ onnen. Ein Mechanismus zur Kontrolle eines eventuellen Puffer¨ uberlaufs ist nicht enthalten. public class BufferArray { private final Object[] array; private int putptr = 0; private int takeptr = 0; public BufferArray (int n) { array = new Object[n]; } public synchronized void insert (Object obj) { array[putptr] = obj; putptr = (putptr +1) % array.length; } public synchronized Object extract() { Object x = array[takeptr]; array[takeptr] = null; takeptr = (takeptr +1) % array.length; return x; } } Abbildung 5.12. Klasse BufferArray zur Pufferverwaltung.
Die Klasse BoundedBufferSema in Abb. 5.13 stellt Methoden put() und take() zur Ablage bzw. Entnahme eines Objektes im Puffer zur Verf¨ ugung. Zur Kontrolle des Puffers werden zwei Semaphore putPermits() und takePermits() verwendet, die zu jedem Zeitpunkt die erlaubte Anzahl von Ablagen (Produzent) bzw. Entnahmen (Konsument) angeben; putPermits() wird mit der Puffergr¨ oße, takePermits() mit 0 initialisiert. Beim Ab-
112
5 Java-Threads pulic class BoundedBufferSema { private final BufferArray buff; private final Semaphore putPermits; private final Semaphore takePermits; public BoundedBufferSema(int capacity) throws IllegalArgumentException { if (capacity <= 0) throw new IllegalArgumentException(); buff = new BufferArray(capacity); putPermits = new Semaphore(capacity); takePermits = new Semaphore(0); } public void put(Object x) throws InterruptedException { putPermits.acquire(); buff.insert(x); takePermits.release(); } public Object take() throws InterruptedException { takePermits.acquire(); Object x = buff.extract(); putPermits.release(); return x; } } Abbildung 5.13. Pufferverwaltung mit Semaphoren.
legen eines Objektes mittels put() wird der Semaphor putPermits() mit acquire() dekrementiert; bei vollem Puffer wird der ablegende Thread dabei eventuell blockiert. Nach Ablage eines Objektes mit insert() wird ein eventuell wartender Konsumenten-Thread mittels release() bzgl. dem Semaphor takePermits() aufgeweckt. Die Ent-
5.5 Thread-Scheduling in Java
113
nahme eines Objektes mittels take() arbeitet analog mit vertauschter Rolle der Semaphoren. Im Vergleich zur Realisierung aus Abb. 5.8 verwendet die Semaphor-Implementierung aus Abb. 5.13 zwei separate Objekte (vom Typ Semaphore) zur Kontrolle des Pufferstatus. Dies kann je nach Situation zu einer Reduktion des Synchronisationsaufwands f¨ uhren: Bei der Implementierung aus Abb. 5.8 werden in put() bzw. take() alle wartenden Threads aufgeweckt. Von diesen kann jedoch nur einer weiterarbeiten, indem er ein abgelegtes Objekt entnimmt oder einen frei werdenden Eintrag zur Ablage eines Objektes nutzt. Alle anderen Threads werden erneut blockiert. Bei der Implementierung aus Abb. 5.13 wird hingegen nur ein Thread aufgeweckt, der darauf wartet, dass der Puffer nicht mehr leer bzw. nicht mehr voll ist.
5.5 Thread-Scheduling in Java Ein Java-Programm besteht typischerweise aus mehreren Threads, die auf einem oder mehreren Prozessoren ausgef¨ uhrt werden. Die ausf¨ uhrungsbereiten Threads konkurrieren dabei um die Ausf¨ uhrung auf einem freiwerdenden Prozessor. Die jeweilige Zuordnung von Threads an Prozessoren wird vom Scheduler der JVM durchgef¨ uhrt. Der Programmierer kann die Zuordnung von Threads an Prozessoren dadurch beeinflussen, dass er Threads Priorit¨aten zuordnet. Die minimalen, maximalen und Default-Priorit¨aten von Java-Threads sind in statischen Konstanten der Klasse Thread festgelegt: public static final int MIN PRIORITY // Default 1 public static final int MAX PRIORITY // Default 10 public static final int NORM PRIORITY // Default 5
114
5 Java-Threads
Dabei entspricht ein großer Priorit¨ atswert einer hohen Priorit¨ at. Der die main()-Methode einer Klasse ausf¨ uhrende Hauptthread hat per Default die Priorit¨ at Thread.NORM PRIORITY. Ein neu erzeugter Thread hat per Default die gleiche Priorit¨ at wie der erzeugende Thread. Die aktuelle Priorit¨ at eines Threads kann mit Hilfe der Methoden public int getPriority() public int setPriority(int prio) abgefragt bzw. dynamisch ge¨ andert werden. Gibt es mehr ausf¨ uhrungsbereite Threads als Prozessoren, bringt der Scheduler vorzugsweise Threads mit einer h¨ oheren Priorit¨ at zur Ausf¨ uhrung. Der exakte Mechanismus zur Auswahl der auszuf¨ uhrenden Threads kann von der speziellen Implementierung der JVM abh¨ angen. Die Programmiersprache Java legt keinen genauen Mechanismus f¨ ur das Scheduling fest, um die Flexibilit¨ at der Realisierung der JVM auf verschiedenen Plattformen und Betriebssystemen nicht zu beeintr¨ achtigen. Der Scheduler kann immer den Thread mit der h¨ ochsten Priorit¨ at zur Ausf¨ uhrung bringen, er kann aber auch einen Alterungsmechanismus integrieren, der sicherstellt, dass auch Threads mit geringerer Priorit¨ at ab und zu zur Ausf¨ uhrung kommen. Da das genaue Scheduling von Threads unterschiedlicher Priorit¨at nicht festgelegt ist, k¨ onnen Priorit¨ aten nicht dazu verwendet werden, Synchronisationsmechanismen zu ersetzen. Bei Verwendung von Threads mit unterschiedlichen Priorit¨ aten kann das Problem der Priorit¨ atsinversion auftreten: Eine Priorit¨ atsinversion tritt auf, wenn ein Thread hoher Priorit¨ at blockiert und auf einen Thread niedriger Priorit¨ at wartet, weil dieser z.B. eine Mutexvariable gesperrt hat. Der Thread niedriger Priorit¨ at kann aber von einem Thread mittlerer Priorit¨ at am Weiterarbeiten und an der Freigabe der Mutexvariable gehindert werden mit dem
5.6 Paket java.util.concurrent
115
Effekt, dass der Thread hoher Priorit¨ at m¨ oglicherweise lange Zeit blockiert. Das Problem der Priorit¨ atsinversion kann durch Verwendung von Priorit¨ atsvererbung gel¨ost werden: wenn ein Thread hoher Priorit¨ at blockiert, wird die Priorit¨ at des Threads, der das kritische Objekt zur Zeit kontrolliert auf die Priorit¨ at des Threads hoher Priorit¨at angehoben. Damit kann kein Thread mittlerer Priorit¨at den Thread hoher Priorit¨ at vom Weiterarbeiten abhalten. Viele JVM setzen daher diese Methode ein; dies ist jedoch nicht vom Java-Standard festgelegt.
5.6 Paket java.util.concurrent Ab der Java2 Platform (Java 2 Standard Edition 5.0, J2SE5.0) stehen durch das Paket java.util.concurrent zus¨ atzliche Synchronisationsmechanismen zur Verf¨ ugung, die auf den bisher besprochenen Mechanismen, also synchronized-Bl¨ ocke, wait() und notify(), aufbauen. Die zus¨ atzlichen Mechanismen stellen abstraktere und flexiblere Synchronisationsoperationen zur Verf¨ ugung. Diese beinhalten u.a. atomare Variablen, Sperrvariablen, Barrier-Synchronisation, Bedingungsvariablen und Semaphore sowie verschiedene threadsichere Datenstrukturen. Die zus¨atzlichen Klassen sind ¨ ahnlich zu den in [40] besprochenen Klas¨ sen. Wir geben im Folgenden einen kurzen Uberblick und verweisen f¨ ur eine detailliertere Behandlung auf [25]. Semaphor-Mechanismus Die Klasse Semaphore stellt einen Semaphor-Mechanismus ahnlich zu Abb. 5.11 zur Verf¨ ugung. Intern enth¨alt die ¨ Klasse einen Z¨ ahler, der die Anzahl der Zugriffserlaubnisse z¨ ahlt. Die wichtigsten Methoden der Klasse sind:
116
5 Java-Threads
void acquire(); void release(); boolean tryAcquire() boolean tryAcquire(int permits, long timeout, TimeUnit unit) Die Methode acquire() erfragt eine Zugriffserlaubnis und blockiert, falls keine vorhanden ist. Ist eine Zugriffserlaubnis vorhanden, wird die Anzahl der vorhandenen Zugriffserlaubnisse dekrementiert und die Kontrolle wird direkt wieder dem aufrufenden Thread u ¨bergeben. Die Methode release() f¨ ugt eine Zugriffserlaubnis zum Semaphor hinzu. Wartet zu diesem Zeitpunkt ein anderer Thread auf eine Zugriffserlaubnis, wird er aufgeweckt. Die Methode tryAcquire() versucht, eine Zugriffserlaubnis zu erhalten. Ist dies erfolgreich, wird true zur¨ uckgeliefert. Ist dies nicht erfolgreich, wird false zur¨ uckgeliefert; im Unterschied zu acquire() erfolgt also keine Blockierung des ausf¨ uhrenden Threads. Die Methode tryAcquire() mit Parametern erlaubt die zus¨ atzliche Angabe einer Anzahl von Zugriffserlaubnissen (permits) und einer Wartezeit (timeout) mit einer Zeiteinheit (unit). Sind nicht gen¨ ugend Zugriffserlaubnisse verf¨ ugbar, wird der ausf¨ uhrende Thread blockiert, bis eine der folgenden Bedingungen eintritt: • •
die Anzahl der angefragten Zugriffserlaubnisse wird verf¨ ugbar, indem andere Threads release() ausf¨ uhren (R¨ uckgabewert true); die angegebene Wartezeit ist abgelaufen (R¨ uckgabewert false);
Barrier-Synchronisation Die Klasse CyclicBarrier aus java.util.concurrent liefert einen Barrier-Synchronisationsmechanismus, wobei sich
5.6 Paket java.util.concurrent
117
die Bezeichnung Cyclic darauf bezieht, dass ein Objekt der Klasse wiederverwendet werden kann, wenn alle Threads die Barrier passiert haben. Die Konstruktoren der Klasse public CyclicBarrier (int n) public CyclicBarrier (int n, Runnable action) erlauben die Angabe der Anzahl n von Threads, die die Barrier passieren m¨ ussen sowie die Angabe einer Aktion action, die ausgef¨ uhrt wird, sobald alle Threads die Barrier passiert haben. Durch Aufruf der Methode await() wartet ein Thread an der Barrier, bis die angegebene Anzahl von Threads die Barrier erreicht haben. Durch Aufruf der Methode reset() wird ein Barrierobjekt wieder in den urspr¨ unglichen Zustand zur¨ uckgesetzt. Sperrmechanismus Das Paket java.util.concurrent.locks enth¨alt Interfaces und Klassen f¨ ur Sperren und das Warten auf das Eintreten von Bedingungen. Das Interface Lock definiert u ¨ ber synchronized-Bl¨ocke und -Methoden hinausgehende Sperrmechanismen, die nicht nur auf eine Synchronisation bzgl. der impliziten Mutexvariablen der jeweiligen Objekte beschr¨ ankt sind. Die wichtigsten definierten Methoden sind: void lock() boolean tryLock() boolean tryLock(long time, TimeUnit unit) void unlock() Die Methode lock() f¨ uhrt einen Sperrversuch durch. Ist die Sperre bereits von einem anderen Thread gesetzt, wird der ausf¨ uhrende Thread blockiert, bis der andere Thread
118
5 Java-Threads
ihn mit unlock() wieder aufweckt. Ist die Sperre nicht gesetzt, wird der ausf¨ uhrende Thread Eigent¨ umer der Sperre. Die Methode tryLock() f¨ uhrt ebenfalls einen Sperrversuch durch. Bei Erfolg wird true als R¨ uckgabewert zur¨ uckgeliefert. Bei Misserfolg wird false zur¨ uckgeliefert, der ausf¨ uhrende Thread wird aber nicht blockiert. Die Methode tryLock() mit Parametern erlaubt die zus¨ atzliche Angabe einer Wartezeit analog zu tryAcquire(). Die Methode unlock() gibt eine vorher gesetzte Sperre wieder frei. Dabei wird ein auf die Sperre wartender Thread aufgeweckt. Eine Realisierung des Interface Lock wird durch die Klasse ReentrantLock zur Verf¨ ugung gestellt. Der Konstruktor der Klasse erlaubt die Angabe eines optionalen Fairness-Parameters: ReentrantLock() ReentrantLock(boolean fairness) Wird dieser auf true gesetzt, erh¨ alt im Zweifelsfall der am l¨ angsten wartende Thread Zugriff auf das Sperrobjekt. Ohne Verwendung des Fairness-Parameters kann von keiner speziellen Zugriffsreihenfolge ausgegangen werden. Die Verwendung des Fairness-Parameters kann zu einem erh¨ohten Verwaltungsaufwand und dadurch verringertem Durchsatz f¨ uhren. Eine typische Benutzung der Klasse ReentrantLock ist in Abb. 5.14 skizziert. Signalmechanismus Das Interface Condition aus java.util.concurrent.lock spezifiziert einen Signalmechanismus mit Bedingungsvariablen, so dass ein Thread auf das Eintreten einer Bedingung warten kann, deren Eintreten ihm durch ein Signal eines anderen Threads mitgeteilt wird, wie dies auch in Pthreads durchgef¨ uhrt wird (vgl. S. 70). Eine Bedingungsvariable wird immer an eine Sperrvariable (vgl. Interface
5.6 Paket java.util.concurrent
119
import java.util.concurrent.locks.*; pulic class NewClass { private ReentrantLock lock = new ReentrantLock(); //... public void method() { lock.lock(); try { //... } finally { lock.unlock(); } } } Abbildung 5.14. Illustration ReentrantLock-Objekten.
der
Verwendung
von
Lock) gebunden. Eine Bedingungsvariable zu einer Sperrvariable kann mit der Methode Condition newCondition() von Objekten, die das Interface Lock implementieren, erzeugt werden. Die zur¨ uckgelieferte Bedingungsvariable ist fest an die Sperrvariable gebunden, bzgl. der die Methode newCondition() aufgerufen wird. Auf eine Bedingungsvariable k¨ onnen die folgenden Methoden angewendet werden: void void void void
await() await(long time, TimeUnit unit) signal() signalAll()
Die Methode await() blockiert den ausf¨ uhrenden Thread, bis er von einem anderen Thread wieder mit einem Signal aufgeweckt wird. Gleichzeitig wird die zugeh¨ orige Sperrvariable atomar freigegeben. Vor dem Aufruf von await()
120
5 Java-Threads
muss der ausf¨ uhrende Thread also die zugeh¨ orige Sperrvariable erfolgreich gesperrt haben. Nach dem Aufwecken durch ein Signal eines anderen Threads muss der vorher blockierte Thread zuerst wieder die Kontrolle u ¨ber die Sperrvariable erhalten, bevor der Thread weiterarbeiten kann. Wird await() mit Parametern verwendet, wird der Thread nach Ablauf der angegebenen Wartezeit aufgeweckt, auch wenn noch kein Signal eines anderen Threads eingetroffen ist. Mit signal() kann ein Thread einen bzgl. einer Bedingungsvariable wartenden Thread wieder aufwecken. Mit signalAll() werden alle bzgl. der Bedingungsvariable wartenden Threads aufgeweckt. Die Verwendung von Bedingungsvariablen f¨ ur die Realisierung eines Puffermechanismus ist in Abb. 5.15 illustriert. Die Bedingungsvariablen werden ¨ ahnlich wie der Semaphor in Abb. 5.13 verwendet. Atomare Operationen Das Paket java.util.concurrent.atomic stellt f¨ ur elementare Datentypen atomare Operationen zur Verf¨ ugung, die einen sperrfreien Zugriff auf einzelne Variablen erlauben. Ein Beispiel ist die Klasse AtomicInteger, die u.a. die Methoden boolean compareAndSet (int expect, int update) int getAndIncrement()
enth¨ alt. Die erste Methode setzt den Wert der Variablen auf update, falls der Wert vorher expect war, und liefert true bei erfolgreicher Ausf¨ uhrung zur¨ uck. Die Operation erfolgt atomar, d.h. w¨ ahrend der Ausf¨ uhrung kann der Thread nicht unterbrochen werden. Die zweite Methode inkrementiert den Wert der Variablen atomar um 1 und liefert den
5.6 Paket java.util.concurrent
121
import java.util.concurrent.locks.*; pulic class BoundedBufferCondition { private Lock lock = new ReentrantLock(); private Condition notFull = lock.newCondition(); private Condition notEmpty = lock.newCondition(); private Object[] items = new Object[100]; private int putptr, takeptr, count; public void put (Object x) throws InterruptedException lock.lock(); try { while (count == items.length) notFull.await(); items[putptr] = x; putptr = (putptr +1) % items.length; ++count; notEmpty.signal(); } finally { lock.unlock(); } } public Object take() throws InterruptedException { lock.lock(); try { while (count == 0) notEmpty.await(); Object x = items[takeptr]; takeptr = (takeptr +1) % items.length; --count; notFull.signal(); return x; } finally {lock.unlock();} } } Abbildung 5.15. Realisierung eines Puffermechanismus mit Hilfe von Bedingungsvariablen.
122
5 Java-Threads
fr¨ uheren Wert der Variablen als Ergebnis zur¨ uck. Die Klasse stellt eine Vielzahl ¨ ahnlicher Methoden zur Verf¨ ugung. Taskbasierte Ausf¨ uhrung von Programmen Das Paket java.util.concurrent stellt auch einen Mechanismus f¨ ur eine taskbasierte Formulierung von Programmen bereit. Eine Task ist dabei eine durchzuf¨ uhrende Berechnungsfolge des Programms, die von einem beliebigen Thread ausgef¨ uhrt werden kann. Eine Abarbeitung von Tasks wird durch das Interface Executor unterst¨ utzt: public interface Executor { void execute (Runnable command); } Dabei beschreibt command die auszuf¨ uhrende Task, der durch den Aufruf von execute() zur Ausf¨ uhrung gebracht wird. F¨ ur Multicore-Prozessoren stehen dabei u ¨blicherweise mehrere Threads zur Ausf¨ uhrung von Tasks zur Verf¨ ugung. Diese k¨ onnen in einem Thread-Pool zusammengefasst werden, wobei jeder Thread eine beliebige Task ausf¨ uhren kann. Im Vergleich zu einer Ausf¨ uhrung jeder Task in einem eigenen Thread f¨ uhrt der Einsatz von Thread-Pools typischerweise zu einem geringeren Verwaltungsaufwand, insbesondere wenn die Tasks wenige Berechnungen umfassen. Zur Organisation von Thread-Pools kann die Klasse Executors eingesetzt werden, die Methoden zur Erzeugung und Verwaltung von Thread-Pools bereitstellt. Die wichtigsten sind static ExecutorService newFixedThreadPool(int n) static ExecutorService newCachedThreadPool() static ExecutorService newSingleThreadExecutor()
5.6 Paket java.util.concurrent
123
Die erste Methode erzeugt einen Thread-Pool, der neue Threads bei Einf¨ ugen von Tasks startet, bis die angegebene maximale Anzahl n von Threads erreicht ist. Die zweite Methode erzeugt einen Thread-Pool, bei dem die Anzahl der Threads dynamisch an die Anzahl der Tasks angepasst wird, wobei Threads wieder terminiert werden, wenn sie f¨ ur eine bestimmte Zeit (60 Sekunden) nicht genutzt werden. Die letzte Methode erzeugt einen einzelnen Thread, der eine Menge von Tasks abarbeitet. Zur Unterst¨ utzung der Abarbeitung taskbasierter Anwendungen definiert das von Executor abgeleitete Interface ExecutorService u.a. Methoden zur Terminierung von Thread-Pools. Die wichtigsten dieser Methoden sind: void shutdown(); List shutdownNow(); Die Methode shutdown() bewirkt, dass der Thread-Pool keine weiteren Tasks mehr annimmt; die bereits enthaltenen Tasks werden aber noch ausgef¨ uhrt. Die Methode shutdownNow() stoppt zus¨ atzlich alle im Moment ausgef¨ uhrten Tasks; wartende Tasks werden nicht mehr ausgef¨ uhrt. Die Liste der wartenden Tasks wird als R¨ uckgabewert zur¨ uckgeliefert. Die Klasse ThreadPoolExecutor stellt eine Realisierung des Interfaces ExecutorService zur Verf¨ ugung. Abb. 5.16 illustriert die Verwendung eines Thread-Pools am Beispiel eines Webservers, der u ¨ ber einen ServerSocket auf Verbindungsanfragen von Clients wartet und diese als Tasks mit execute() von den Threads eines Thread-Pools bearbeiten l¨ aßt. Jede abzuarbeitende Task wird als Objekt vom Typ Runnable erzeugt und spezifiziert die durchzuf¨ uhrende Berechnung handleRequest() als run()-Methode. Die Gr¨ oße des Thread-Pools ist auf 10 Threads begrenzt.
124
5 Java-Threads import java.io.IOException; import java.net.*; import java.util.concurrent.*; pulic class TaskWebServer { static class RunTask implements Runnable { private Socket myconnection; public RunTask (Socket connection) { myconnection = connection; } public void run() { // handleRequest(myconnection); } } public static void main (String[] args) throws IOException { ServerSocket s = new ServerSocket(80); ExecutorService pool = Executors.newFixedThreadPool(10); try { while (true) { Socket connection = s.accept(); Runnable task = new RunTask(connection) pool.execute(task); } } catch (IOException ex) { pool.shutdown(); } } } Abbildung 5.16. Skizze eines taskbasierten Webservers.
6 OpenMP
¨ OpenMP ist eine Spezifikation von Ubersetzerdirektiven, Bibliotheksfunktionen und Umgebungsvariablen, die von einer Gruppe von Soft- und Hardwareherstellern mit dem Ziel entworfen wurde, einen einheitlichen Standard f¨ ur die Programmierung von Parallelrechnern mit gemeinsamem Adressraum zur Verf¨ ugung zu stellen [53]. Unterst¨ utzt werden Schnittstellen f¨ ur C, C++ und FORTRAN. OpenMP erweitert diese sequentiellen Sprachen um Konstrukte zur SPMD-Programmierung, zur Aufteilung von Arbeit, zur Synchronisation und zur Deklaration von gemeinsamen (shared) und privaten (private) Variablen. Die Auswahl der Konstrukte ist auf den Anwendungsbereich des wissenschaftlichen Rechnens ausgerichtet. Es k¨ onnen aber auch andere Anwendungen in OpenMP realisiert werden.
6.1 Programmiermodell Das Programmiermodell von OpenMP basiert auf parallel arbeitenden Threads, die nach einem fork-join-Prinzip
126
6 OpenMP
erzeugt und beendet werden. Die Abarbeitung eines mit Hilfe von OpenMP formulierten Programms beginnt mit der Ausf¨ uhrung eines Master-Threads, der das Programm sequentiell ausf¨ uhrt, bis das erste parallel-Konstrukt auftritt. Bei Auftreten dieses Konstrukts, das weiter unten n¨ aher beschrieben wird, erzeugt der Master-Thread ein Team von Threads und wird zum Master des Teams (fork). Alle Threads des Teams, zu dem auch der Master selber geh¨ ort, f¨ uhren das auf das parallel-Konstrukt folgende Programmst¨ uck parallel zueinander aus, indem entweder alle Threads des Teams den gleichen Programmtext mit evtl. unterschiedlichen privaten Variablen im SPMD-Stil abarbeiten oder indem die Arbeit explizit durch geeignete Konstrukte auf die Threads verteilt wird. Dabei wird ein gemeinsamer Adressraum f¨ ur das Gesamtprogramm zugrundegelegt, d.h. wenn ein Mitglied des Teams eine Daten¨ struktur ¨ andert, ist die Anderung nicht nur f¨ ur die anderen Mitglieder des Teams, sondern auch f¨ ur alle anderen Threads des Programms sichtbar. Nach Beendigung der Abarbeitung des parallel auszuf¨ uhrenden Programmst¨ uckes werden die Threads des Teams synchronisiert und nur der Master des Teams wird weiter ausgef¨ uhrt; die anderen Threads werden beendet (join). Mit den zur Verf¨ ugung stehenden Mechanismen zur Steuerung der Parallelit¨ at k¨ onnen Programme formuliert werden, die sowohl sequentiell als auch parallel ausgef¨ uhrt werden k¨ onnen. Dabei ist es jedoch auch m¨ oglich, Programme zu schreiben, die nur bei einer parallelen Ausf¨ uhrung das gew¨ unschte Ergebnis errechnen. Der Programmierer ist daf¨ ur verantwortlich, dass die Programme korrekt arbeiten. Dies gilt auch f¨ ur die Vermeidung von Konflikten, Deadlocks oder zeitkritischen Abl¨ aufen. Die meisten Mechanismen zur Steuerung der parallelen Abarbeitung von Programmteilen werden in OpenMP
6.2 Spezifikation der Parallelit¨ at
127
¨ durch Ubersetzer-Direktiven zur Verf¨ ugung gestellt, deren Syntax auf den in C und C++ verwendeten #pragmaDirektiven basiert. Zus¨ atzlich stehen Laufzeitfunktionen zur Steuerung des Verhaltens der Direktiven zur Verf¨ ugung. Jede Direktive wirkt nur auf die der Direktive direkt folgende Anweisung. Sollen mehrere Anweisungen von der Direktive gesteuert werden, so m¨ ussen diese zwischen { und } stehen und so in einem Anweisungsblock zusammengefasst sein. F¨ ur OpenMP-Programme muss die Datei eingebunden werden. ¨ Der Rest des Kapitels enth¨ alt einen kurzen Uberblick u uhrende Informationen k¨onnen in ¨ ber OpenMP. Weiterf¨ [13, 53, 59] nachgelesen und u ¨ ber die OpenMP-Webseite (http://www.openmp.org) erhalten werden.
6.2 Spezifikation der Parallelit¨ at Die wichtigste Direktive zur Steuerung der Parallelit¨at ist die parallel-Direktive mit der Syntax: #pragma omp parallel [Parameter [Parameter] ... ] Anweisungsblock
Diese Direktive bewirkt, dass der angegebene Anweisungsblock parallel ausgef¨ uhrt wird. Wird die Arbeit nicht explizit verteilt, so f¨ uhren alle Threads die gleichen Berechnungen mit evtl. unterschiedlichen privaten Daten im SPMD-Stil aus. Der parallel ausgef¨ uhrte Anweisungsblock wird auch als paralleler Bereich bezeichnet. Zur parallelen Abarbeitung wird ein Team von Threads erzeugt, dessen Master der die Direktive ausf¨ uhrende Thread ist. Die genaue Anzahl der zu erzeugenden Threads kann u ¨ber Laufzeitfunktionen oder Umgebungsvariablen beeinflusst werden. Nach der Erzeugung des Teams bleibt die Anzahl der
128
6 OpenMP
Threads, die den Anweisungsblock ausf¨ uhren, konstant. F¨ ur verschiedene parallele Bereiche k¨ onnen jedoch verschiedene Thread-Anzahlen verwendet werden. Ein paralleler Bereich wird von allen Threads des erzeugten Teams einschließlich des Master-Threads gemeinsam abgearbeitet. Dabei k¨ onnen gemeinsame und private Variablen der beteiligten Threads u ¨ ber die Parameter der parallel-Direktive definiert werden. Private Variablen der Threads werden durch den private-Parameter der Form private(list of variables) spezifiziert, wobei list of variables eine beliebige Liste von bereits deklarierten Programmvariablen ist. Der Effekt besteht darin, dass auf dem Laufzeitstack jedes Threads des Teams eine uninitialisierte Kopie der angegebenen Variablen angelegt wird, die nur dieser Thread w¨ ahrend seiner Ausf¨ uhrung als globale Variable zugreifen und ver¨andern kann. Gemeinsame Variablen der Threads eines Teams werden durch den shared-Parameter der Form shared(list of variables) spezifiziert. Der Effekt besteht darin, dass jeder Thread des Teams beim Lesen oder Beschreiben der angegebenen Variablen auf denselben Datenbereich zugreift. Mit Hilfe des default-Parameters kann der Programmierer festlegen, ob die Programmvariablen des parallelKonstrukts per Default gemeinsame oder private Variablen sind. Die Angabe default(shared) bewirkt, dass alle außer den vom private-Parameter explizit angegebenen Programmvariablen gemeinsame Variablen der Threads des Teams sind. Die Angabe
6.2 Spezifikation der Parallelit¨ at
129
default(none) bewirkt, dass jede in dem parallelen Bereich verwendete Variable explizit u ¨ ber einen shared- oder private-Parameter als gemeinsame oder private Variable gekennzeichnet sein muss. Das Programmfragment in Abbildung 6.1 zeigt die Verwendung einer parallel-Direktive zur parallelen Verarbeitung eines Feldes x. Wir nehmen an, dass die zu verarbeitenden Werte in der Funktion initialize() vom MasterThread eingelesen werden. In der parallel-Direktive werden die Variablen x und npoints als gemeinsame Variable der den parallelen Bereich ausf¨ uhrenden Threads spezifiziert. Die restlichen Variablen iam, np und mypoints sind private Variablen. Zu Beginn der Ausf¨ uhrung des parallelen Bereiches bestimmt jeder beteiligte Thread durch den Aufruf der Funktion np = omp get num threads() die Gesamtanzahl der Threads des Teams. Durch den Aufruf der alt jeder Thread des Funktion omp get thread num() erh¨ Teams eine Nummer zur¨ uck, die als Thread-Name dient und im Beispiel in iam gespeichert wird. Der Master-Thread hat die Thread-Nummer 0, die Thread-Nummern der anderen Threads liegen fortlaufend zwischen 1 und np-1. Jeder der Threads ruft die Funktion compute subdomain() auf, in der die Eintr¨ age des Feldes x verarbeitet werden. Beim Aufruf von compute subdomain() wird neben dem Feldnamen x and dem Thread-Namen iam auch die Anzahl mypoints der von jedem Thread zu verarbeitenden Feldelemente angegeben. Welche Feldelemente dies speziell sind, ist innerhalb von compute subdomain anzugeben. Nach Abarbeitung eines parallelen Bereiches werden alle Threads des Teams außer dem Master-Thread terminiert. Anschließend f¨ uhrt der Master-Thread die dem parallelen Bereich folgenden Anweisungen alleine aus. Das En-
130
6 OpenMP
#include <stdio.h> #include int npoints, iam, np, mypoints; double *x; int main() { scanf("%d", &npoints); x = (double *) malloc(npoints * sizeof(double)); initialize(); #pragma omp parallel shared(x,npoints) private(iam,np,mypoints) { np = omp get num threads(); iam = omp get thread num(); mypoints = npoints / np; compute subdomain(x, iam, mypoints); } } Abbildung 6.1. Parallele Verarbeitung einer Datenstruktur mit Hilfe einer OpenMP parallel-Direktive.
de eines parallelen Bereiches stellt somit einen impliziten Synchronisationspunkt dar. Prinzipiell k¨ onnen parallele Bereiche geschachtelt werden, d.h. in einem parallelen Bereich kann eine weitere parallel-Direktive auftreten. Per Default wird der innere parallele Bereich von einem Team ausgef¨ uhrt, dem nur der Thread angeh¨ ort, der die innere parallel-Direktive ausf¨ uhrt. Dies kann durch Aufruf der Bibliotheksfunktion void omp set nested(int nested) mit nested != 0 ge¨ andert werden. In diesem Fall kann der die geschachtelte parallel-Direktive ausf¨ uhrende Thread
6.2 Spezifikation der Parallelit¨ at
131
ein Team mit mehr als einem Thread erzeugen. Die genaue Anzahl der in diesem Fall erzeugten Threads ist implementierungsabh¨ angig. Parallele Schleife Innerhalb eines parallelen Bereiches k¨ onnen die durchzuf¨ uhrenden Berechnungen mit Hilfe von speziellen Direktiven zur Verteilung der Arbeit auf die ausf¨ uhrenden Threads verteilt werden. Die wichtigste Direktive zur Verteilung der Arbeit ist die for-Direktive mit der folgenden Syntax: #pragma omp for [Parameter [Parameter] ... ] for (i = lower bound; i op upper bound; incr expr) { Schleifenrumpf }
Die for-Schleife ist auf solche Schleifen beschr¨ ankt, bei denen sichergestellt ist, dass die durch den Schleifenrumpf gegebenen Berechnungen der verschiedenen Iterationen unabh¨ angig voneinander sind und die Gesamtzahl der Iterationen beim Betreten der for-Schleife im voraus bestimmt werden kann. Der Effekt der for-Direktive besteht darin, dass die einzelnen Iterationen der Schleife auf die den umgebenden parallelen Bereich ausf¨ uhrenden Threads verteilt und unabh¨ angig berechnet werden. Es soll sich also um eine parallele Schleife fester L¨ ange handeln. Die Variable i bezeichnet eine Integervariable, die im Rumpf der Schleife nicht ver¨ andert werden darf und die innerhalb der Schleife als private Variable des die zugeh¨orige Iteration der for-Schleife ausf¨ uhrenden Threads behandelt wird. lower bound und upper bound bezeichnen Integerausdr¨ ucke, deren Werte durch Ausf¨ uhrung der Schleife nicht ge¨ andert werden, op bezeichnet einen Vergleichsoperator, also op ∈ { <, <=, >, >= }. Der InkrementierungsAusdruck incr expr kann folgende Formen annehmen:
132
6 OpenMP
++i, i++, --i, i--, i += incr, i -= incr, i = i + incr, i = incr + i, i = i - incr, wobei incr ebenfalls ein schleifenunabh¨ angiger Integerausdruck ist. Die Aufteilung der Schleifeniterationen auf die ausf¨ uhrenden Threads kann durch den scheduleParameter gesteuert werden. Folgende Steuerungsm¨oglichkeiten sind vorgesehen: •
•
•
schedule(static, block size). Diese ParameterAngabe bedeutet, dass eine statische Aufteilung der Iterationen auf die Threads verwendet wird, indem die Iterationen in Bl¨ ocken der Gr¨ oße block size reihum (round-robin) auf die Threads verteilt werden. Ist keine Blockgr¨ oße angegeben, so erh¨ alt jeder Thread einen Block fortlaufender Iterationen ungef¨ ahr gleicher Gr¨oße, d.h. es wird eine blockweise Verteilung verwendet. schedule(dynamic, block size). Diese ParameterAngabe bedeutet, dass eine dynamische Zuteilung von Iterationsbl¨ ocken an die Threads vorgenommen wird, d.h. nach Abarbeitung der zugewiesenen Iterationen erh¨ alt ein Thread dynamisch einen neuen Block mit block size Iterationen zugeteilt. Ist keine Blockgr¨oße angegeben, werden dynamisch einzelne Iterationen zugeteilt, d.h. es wird die Blockgr¨ oße 1 verwendet. schedule(guided, block size). Diese ParameterAngabe bedeutet, dass ein dynamisches Scheduling mit abnehmender Blockgr¨ oße verwendet wird. F¨ ur die Angabe block size = 1 wird jedem Thread, der seine zugewiesenen Iterationen beendet hat, dynamisch ein neuer Block von Iterationen zugewiesen, dessen Gr¨oße sich aus dem Quotient der noch nicht bearbeiteten Iterationen und der Anzahl der Threads ergibt, so dass die Blockgr¨ oße der zugewiesenen Iterationen linear mit der
6.2 Spezifikation der Parallelit¨ at
•
133
Anzahl der ausgef¨ uhrten Iterationen abnimmt. F¨ ur die Angabe block size = k mit k > 1 nimmt die Blockgr¨ oße exponentiell zu k ab, der letzte Block kann jedoch eine kleinere Gr¨ oße haben. Die Angabe block size gibt also die minimale Blockgr¨ oße an, die (bis auf die eben erw¨ ahnte Ausnahme) gew¨ ahlt werden kann. Ist kein Wert f¨ ur block size angegeben, wird als Defaultwert 1 verwendet. schedule(runtime). Diese Parameter-Angabe bedeutet, dass das Scheduling der Threads zur Laufzeit des Programms festgelegt wird. Dies kann dadurch geschehen, dass vor dem Start des Programms die Umgebungsvariable OMP SCHEDULE durch Angabe von SchedulingTyp und Blockgr¨ oße gesetzt wird, also beispielsweise als setenv OMP SCHEDULE "dynamic, 4" setenv OMP SCHEDULE "guided" Wird dabei keine Blockgr¨ oße angegeben, wird der Defaultwert verwendet. Außer f¨ ur das statische Scheduling (static) ist dies block size = 1. Wenn die Umgebungsvariable OMP SCHEDULE nicht gesetzt ist, h¨angt das verwendete Scheduling von der Implementierung der OpenMP-Bibliothek ab.
Fehlt die Angabe eines schedule-Parameters bei der forDirektive, wird ein Default-Schedulingverfahren verwendet, das von der Implementierung der OpenMP-Bibliothek abh¨ angt. Die einer for-Direktive zugeordnete parallele Schleife darf nicht durch eine break-Anweisung beendet werden. Am Ende der parallelen Schleife findet eine implizite Synchronisation der beteiligten Threads statt, d.h. die der parallelen Schleife folgenden Anweisungen werden erst ausgef¨ uhrt, wenn alle beteiligten Threads die parallele Schleife beendet haben. Diese Synchronisation kann durch
134
6 OpenMP
#include double MA[100][100], MB[100][100], MC[100][100]; int i, row, col, size = 100; int main() { read input(MA, MB); #pragma omp parallel shared(MA,MB,MC,size) { #pragma omp for schedule(static) for (row = 0; row < size; row++) { for (col = 0; col < size; col++) MC[row][col] = 0.0; } #pragma omp for schedule(static) for (row = 0; row < size; row++) { for (col = 0; col < size; col++) for (i = 0; i < size; i++) MC[row][col] += MA[row][i] * MB[i][col]; } } write output(MC); } Abbildung 6.2. OpenMP-Programm zur parallelen Berechnung einer Matrix-Matrix-Multiplikation unter Verwendung eines parallelen Bereiches mit einem Anweisungsblock aus zwei aufeinanderfolgenden parallelen Schleifen.
die Angabe eines nowait-Parameters in der Parameterliste der for-Direktive vermieden werden. Abbildung 6.2 zeigt als Beispiel f¨ ur die Anwendung einer for-Direktive eine Programmskizze zur Realisierung einer Matrix-Matrix-Multiplikation zweier Matrizen MA und MB in OpenMP. Der parallele Bereich des Programms be-
6.2 Spezifikation der Parallelit¨ at
135
steht aus zwei Phasen, die durch eine implizite Synchronisation voneinander getrennt sind. In der ersten Phase wird die Ergebnismatrix MC mit 0 initialisiert, in der zweiten Phase wird die eigentliche Matrix-Multiplikation durchgef¨ uhrt. Die Aufteilung der Berechnung der Ergebnismatrix auf die auszuf¨ uhrenden Threads erfolgt durch ein statisches Scheduling, wobei jeder Thread einen Block von Zeilen initialisiert bzw. berechnet. Da jeder Eintrag und damit auch jede Zeile der Ergebnismatrix gleichen Berechnungsaufwand hat, ist ein solches statisches Scheduling sinnvoll. Bei der Berechnung der Ergebnismatrix MC in der zweiten Phase besteht jeder Schleifenrumpf der durch die for-Direktive bezeichneten parallelen Schleife aus einer doppelten (sequentiellen) Schleife, wobei die ¨ außere Schleife u ¨ber die Eintr¨age der jeweiligen Zeile l¨ auft und die innere Schleife zur Berechnung der Multiplikation von Zeile und Spalte dient. Das Schachteln von for-Direktiven innerhalb eines parallelen Bereiches ist nicht erlaubt. Zur Schachtelung paralleler Schleifen m¨ ussen also auch die parallelen Bereiche so geschachtelt werden, dass in jedem parallelen Bereich h¨ ochstens eine for-Direktive enthalten ist. Nichtiterative parallele Bereiche Eine nichtiterative Verteilung der innerhalb eines parallelen Bereiches durchzuf¨ uhrenden Berechnung kann durch Verwendung einer sections-Direktive erfolgen, deren Syntax wie folgt definiert ist: #pragma omp sections [Parameter [Parameter] ... ] { [#pragma omp section] Anweisungsblock [#pragma omp section Anweisungsblock
136
6 OpenMP .. . ]
}
Innerhalb einer sections-Direktive werden durch section-Direktiven Abschnitte bezeichnet, die unabh¨angig voneinander sind und daher parallel zueinander von verschiedenen Threads abgearbeitet werden k¨ onnen. Jeder Abschnitt beginnt mit #pragma omp section und kann ein beliebiger Anweisungsblock sein. F¨ ur den ersten innerhalb der sections-Direktive definierten Anweisungsblock kann die Angabe der section-Direktive entfallen. Am Ende einer sections-Direktive findet eine implizite Synchronisation statt, die durch die Angabe eines nowait-Parameters vermieden werden kann. Syntaktische Abk¨ urzungen Zur Vereinfachung der Schreibweise f¨ uhrt OpenMP Abk¨ urzungen f¨ ur parallele Bereiche ein, in denen nur eine einzelne for- bzw. sections-Direktive enthalten ist. F¨ ur einen parallelen Bereich mit einer einzelnen for-Direktive kann die folgende Abk¨ urzung verwendet werden: #pragma omp parallel for [Parameter [Parameter] · · · ] for(i = lower bound; i op upper bound; incr expr) { Schleifenrumpf }
Dabei sind als Parameter alle f¨ ur die parallel- und f¨ ur die for-Direktive zugelassenen Parameter erlaubt. Analog kann als Abk¨ urzung f¨ ur eine einzelne in einem parallelen Bereich enthaltene sections-Direktive folgendes Konstrukt verwendet werden:
6.2 Spezifikation der Parallelit¨ at
137
#pragma omp parallel sections [Parameter [Parameter]· · ·] { [#pragma omp section] Anweisungsblock [#pragma omp section Anweisungsblock . . . ] }
Thread-Anzahl Ein paralleler Bereich wird von einer Anzahl von Threads ausgef¨ uhrt. Der Programmierer hat die M¨ oglichkeit, diese Anzahl u ¨ ber mehrere Laufzeitfunktionen zu beeinflussen. Mit Hilfe der Funktion void omp set dynamic (int dynamic threads) kann der Programmierer die Anpassung der ThreadAnzahl durch das Laufzeitsystem beeinflussen, wobei die Funktion außerhalb eines parallelen Bereiches aufgerufen werden muss. F¨ ur dynamic threads = 0 wird die dynamische Anpassung durch das Laufzeitsystem erlaubt, d.h. das Laufzeitsystem kann die Anzahl der Threads, die f¨ ur nachfolgende parallele Bereiche verwendet werden, an die Systemgegebenheiten anpassen. W¨ahrend der Ausf¨ uhrung desselben parallelen Bereiches wird die Anzahl der ausf¨ uhrenden Threads aber stets konstant gehalten. F¨ ur dynamic threads = 0 wird die dynamische Anpassung der Thread-Anzahl ausgeschaltet, d.h. das Laufzeitsystem verwendet f¨ ur nachfolgende parallele Bereiche die derzeit eingestellte Anzahl von Threads. Welche der beiden Varianten den Default darstellt, h¨ angt von der speziellen
138
6 OpenMP
OpenMP-Bibliothek ab. Der Status der Thread-Anpassung kann durch Aufruf der Funktion int omp get dynamic (void) abgefragt werden. Der Aufruf liefert 0 zur¨ uck, wenn keine dynamische Anpassung vorgesehen ist. Ansonsten wird ein Wert = 0 zur¨ uckgeliefert. Der Programmierer kann durch Aufruf der Funktion void omp set num threads (int num threads) die Anzahl der Threads beeinflussen, die f¨ ur die Ausf¨ uhrung nachfolgender paralleler Bereiche verwendet werden. Auch dieser Aufruf muss außerhalb eines parallelen Bereiches stattfinden. Der genaue Effekt des Aufrufes h¨ angt davon ab, ob die automatische Thread-Anpassung durch das Laufzeitsystem eingeschaltet ist oder nicht. Wenn die automatische Anpassung eingeschaltet ist, gibt num threads die maximale Anzahl von Threads an, die im Folgenden verwendet wird. Wenn keine automatische Anpassung erlaubt ist, gibt achliche Anzahl von Threads an, die num threads die tats¨ f¨ ur alle nachfolgenden parallelen Bereiche verwendet wird. Wie oben dargestellt wurde, erlaubt OpenMP geschachtelte parallele Bereiche. Die Anzahl der zur Abarbeitung verwendeten Threads eines geschachtelten parallelen Bereiches h¨ angt vom Laufzeitsystem ab, kann jedoch vom Programmierer durch Aufruf der Funktion void omp set nested (int nested) beeinflusst werden. F¨ ur nested = 0 wird die Abarbeitung des inneren parallelen Bereiches sequentialisiert und nur von einem Thread vorgenommen. Dies ist auch die DefaultEinstellung. F¨ ur nested = 0 wird eine geschachtelte parallele Abarbeitung erlaubt, d.h. das Laufzeitsystem kann zur
6.3 Koordination von Threads
139
Abarbeitung des inneren parallelen Bereiches zus¨atzliche Threads verwenden. Die genaue Abarbeitung h¨angt auch hier wieder vom Laufzeitsystem ab und kann auch aus der Abarbeitung durch nur einen Thread erfolgen. Durch Aufruf der Funktion int omp get nested (void) kann der aktuelle Status zur Behandlung von geschachtelten parallelen Bereichen abgefragt werden.
6.3 Koordination von Threads Ein paralleler Bereich wird in OpenMP-Programmen in der Regel von mehreren Threads ausgef¨ uhrt, deren Zugriff auf gemeinsame Variablen koordiniert werden muss. Zur Koordination stellt OpenMP mehrere Direktiven zur Verf¨ ugung, die innerhalb von parallelen Bereichen verwendet werden k¨ onnen. Kritische Bereiche, die zu jedem Zeitpunkt nur von jeweils einem Thread ausgef¨ uhrt werden sollten, k¨ onnen durch die critical-Direktive mit der folgenden Syntax #pragma omp critical [(name)] Anweisungsblock realisiert werden. Der optional anzugebende Name name kann dabei zur Identifikation des kritischen Bereiches verwendet werden. Der Effekt der critical-Direktive besteht darin, dass ein Thread beim Erreichen der Direktive so lange wartet, bis kein anderer Thread den Anweisungsblock des kritischen Bereiches ausf¨ uhrt. Erst wenn dies erf¨ ullt ist, f¨ uhrt der Thread den Anweisungsblock aus. Die Threads eines Teams k¨ onnen mit einer barrierDirektive
140
6 OpenMP
#pragma omp barrier synchronisiert werden, d.h. erst wenn jeder Thread des Teams diese Direktive erreicht hat, beginnen die Theads des Teams die Abarbeitung der nachfolgenden Anweisungen. Durch Angabe der atomic-Direktive k¨ onnen bestimmte Speicherzugriffe als atomare Operationen durchgef¨ uhrt werden. Die Syntax dieser Direktive ist #pragma omp atomic Zuweisung Die Zuweisung muss dabei eine der folgenden Formen annehmen: x binop= E, x++, ++x, x--, --x, wobei x einen beliebigen Variablenzugriff, E einen beliebigen skalaren Ausdruck, der x nicht enth¨ alt, und binop ∈ {+, -, *, /, &, ^, |, <<, >>} einen bin¨ aren Operator bezeichnet. Der Effekt besteht darin, dass nach Auswertung des Ausdrucks E die angegebene Aktualisierung von x als atomare Operation erfolgt, d.h. w¨ ahrend der Aktualisierung kann kein anderer Thread x lesen oder manipulieren. Die Auswertung von E erfolgt nicht als atomare Operation. Prinzipiell kann der Effekt einer atomic-Direktive auch durch eine critical-Direktive erreicht werden, die vereinfachte Form der atomic-Direktive kann aber evtl. vom Laufzeitsystem f¨ ur eine effiziente Implementierung ausgenutzt werden. Auch ist es m¨ oglich mit der atomic-Direktive einzelne Feldelemente anzusprechen, wohingegen die criticalurde. Beispiele f¨ ur Direktive das gesamte Feld sch¨ utzen w¨ die Verwendung von atomaren Operationen sind:
6.3 Koordination von Threads
141
extern float a[], *p=a, b; int index[]; #pragma omp atomic a[index[i]] += b; #pragma omp atomic p[i] -= 1.0; Reduktionsoperationen Um globale Reduktionsoperationen zu erm¨oglichen, stellt OpenMP f¨ ur die parallel-, sections- und forDirektiven einen reduction-Parameter mit der Syntax reduction (op: list) zur Verf¨ ugung. Dabei bezeichnet op ∈{+, -, *, /, &, ^, |, &&, ||} den anzuwendenden Reduktionsoperator, list ist eine mit Kommata getrennte Liste von Reduktionsvariablen, die im umgebenden Kontext als gemeinsame Variable deklariert sein m¨ ussen. Der Effekt des Parameters besteht darin, dass bei der Bearbeitung der zugeh¨ origen Direktive f¨ ur jede der angegebenen Reduktionsvariablen f¨ ur jeden Thread eine private Kopie der Variablen angelegt wird, die entsprechend der angegebenen Reduktionsoperation mit dem neutralen Element dieser Operation initialisiert wird. Den Reduktionsvariablen k¨ onnen w¨ ahrend der Abarbeitung des zugeh¨ origen parallelen Bereiches von den verschiedenen Threads Werte zugewiesen werden, die entsprechend der angegebenen Operation op akkumuliert werden. Am Ende der Direktive, f¨ ur die der reduction-Parameter angegeben wurde, werden die (gemeinsamen) Reduktionsvariablen aktualisiert. Dies geschieht dadurch, dass der urspr¨ ungliche Wert der Reduktionsvariablen und die von den Threads w¨ ahrend der Abarbeitung der zugeh¨ origen Direktive errechneten Werte der privaten Kopien entsprechend der Reduktionsoperation verkn¨ upft werden. Der so errechnete Wert
142
6 OpenMP
wird der Reduktionsvariablen als neuer Wert zugewiesen. Typischerweise wird der reduction-Parameter zur Akkumulation von Werten verwendet. Das folgende Programmfragment dient der Akkumulation von Werten in den Akkumulationsvariablen a, y und am: #pragma omp parallel for reduction (+: a,y) reduction (||: am) for (i=0; i
Die angegebenen Akkumulations-Operationen werden von den Threads, die den parallelen Bereich ausf¨ uhren, f¨ ur verschiedene Iterationen der parallelen Schleife durchgef¨ uhrt. Dabei kann eine Reduktionsoperation auch in einem Funktionsaufruf ausgef¨ uhrt werden, wie dies f¨ ur die Berechnung von y der Fall ist. Nach Beendigung der parallelen Schleife werden die von den verschiedenen Threads akkumulierten Werte global in den angegebenen Reduktionsvariablen akkumuliert. Sperrmechanismus F¨ ur den Zugriff auf gemeinsame Variablen stellt OpenMP einen Sperrmechanismus zur Verf¨ ugung, der u ¨ ber Laufzeitfunktionen verwaltet werden kann. Dabei unterscheidet OpenMP zwischen einfachen Sperrvariablen vom Typ omp lock t und schachtelbaren Sperrvariablen vom Typ omp nest lock t. Der Unterschied besteht darin, dass eine einfache Sperrvariable nur einmal belegt werden kann, w¨ ahrend eine schachtelbare Sperrvariable vom gleichen Thread mehrfach belegt werden kann. Dazu wird f¨ ur die
6.3 Koordination von Threads
143
schachtelbare Sperrvariable ein Z¨ ahler gehalten, der die Anzahl der Belegungen mitz¨ ahlt. Vor Benutzung einer Sperrvariablen muss diese initialisiert werden, wozu die beiden folgenden Funktionen void omp init lock (omp lock t *lock) void omp init nest lock (omp nest lock t *lock) zur Verf¨ ugung stehen. Nach der Initialisierung einer Sperrvariablen ist diese nicht belegt. Zur Zerst¨ orung einer initialisierten Sperrvariablen stehen die Funktionen void omp destroy lock (omp lock t *lock) void omp destroy nest lock (omp nest lock t *lock) zur Verf¨ ugung. Nach Initialisierung einer Sperrvariablen kann diese wie u ¨blich zur Koordination des konkurrierenden Zugriffs auf gemeinsame Daten benutzt werden. Zur Belegung einer Sperrvariablen werden die Funktionen void omp set lock (omp lock t *lock) void omp set nest lock (omp nest lock t *lock) verwendet. Beide Funktionen blockieren den ausf¨ uhrenden Thread so lange, bis die angegebene Sperrvariable verf¨ ugbar ist. Eine einfache Sperrvariable ist verf¨ ugbar, wenn sie von keinem anderen Thread belegt ist. Eine schachtelbare Sperrvariable ist verf¨ ugbar, wenn sie entweder von keinem Thread oder vom ausf¨ uhrenden Thread belegt ist. Wenn die angegebene Sperrvariable verf¨ ugbar ist, wird sie vom ausf¨ uhrenden Thread belegt. F¨ ur schachtelbare Sperrvariablen wird der assoziierte Z¨ ahler inkrementiert. Die Belegung einer Sperrvariablen kann durch Aufruf der Funktionen void omp unset lock (omp lock t *lock) void omp unset nest lock (omp nest lock t *lock)
144
6 OpenMP
wieder freigegeben werden. Dabei kann nur der Thread, der die Sperrvariable belegt hat, diese auch freigeben. Eine normale Sperrvariable wird durch Aufur eine ruf von omp unset lock() freigegeben. F¨ schachtelbare Sperrvariable dekrementiert der Aufruf ahler. Wenn omp unset nest lock() den zugeordneten Z¨ der Z¨ ahler dadurch den Wert 0 erreicht, wird die Sperrvariable freigegeben. Soll beim Versuch der Belegung einer von einem anderen Thread belegten Sperrvariablen die Blockierung des ausf¨ uhrenden Threads vermieden werden, k¨ onnen die Funktionen void omp test lock (omp lock t *lock) void omp test nest lock (omp nest lock t *lock) verwendet werden. Wenn die angegebene Sperrvariable verf¨ ugbar ist, wird sie wie bei Aufruf von omp set lock() bzw. omp set nest lock() belegt. Wenn die Sperrvariable nicht verf¨ ugbar ist, wird jedoch der aufrufende Thread nicht blockiert. Stattdessen liefern die Funktionsaufrufe den R¨ uckgabewert 0 an den aufrufenden Thread zur¨ uck. Bei erfolgreicher Belegung der Sperrvauck, riablen liefert omp test lock() einen Wert = 0 zur¨ omp test nest lock() liefert den neuen Wert des zugeordneten Z¨ ahlers zur¨ uck.
7 Weitere Ans¨ atze
Bereits jetzt gibt es eine Reihe von erprobten Programmierumgebungen und -bibliotheken zur Programmierung von Multicore-Prozessoren, die aus der Programmierung mit gemeinsamem Adressraum oder dem Multithreading stammen. Einige wurden in den letzten Kapiteln vorgestellt. Der Einsatz popul¨ arer Bibliotheken zur Programmierung eines verteilten Speichers wie z.B. MPI ist durch Portierungen ebenfalls bereits m¨ oglich. F¨ ur bestehende parallele Programme und Programmierer mit Erfahrung in der parallelen Programmierung stellt die Nutzung von MulticoreProzessoren also einen eher kleinen Schritt in der Programmiertechnik dar; ein wesentlicher Unterschied liegt in m¨ oglicherweise ver¨ anderten Effekten der parallelen Laufzeit. F¨ ur die weit gr¨ oßere Klasse der sequentiellen Programme ist der Schritt zur parallelen Programmierung mit Threads jedoch schwierig und stellt eine große Umstellung dar [41]. Dies ist auch darin begr¨ undet, dass die ThreadProgrammierung mit Sperrmechnismen und anderen Synchronisationsformen sowie Folgeproblemen wie Deadlocks einen Programmierstil auf niedriger Ebene darstellt und
146
7 Weitere Ans¨ atze
auch mit einer Assembler-Programmierung der Parallelverarbeitung verglichen wird [63]. Mit solchen Mitteln sind große Softwareprojekte schwer zu bew¨ altigen. Die Entwicklung zu Multicore-Prozessoren zieht daher eine Forschungswelle nach sich, die sich mit der nebenl¨aufigen und parallelen Programmierung auf h¨ oherer Ebene besch¨ aftigt. Nicht zu vergessen sind dabei Sprachans¨atze, die durchaus seit einigen Jahren bestehen und durch die Multicore-Entwicklung an neuer Bedeutung gewinnen. Einige dieser Sprachen sowie neu entwickelte Sprachen stellen wir in diesem Kapitel vor. Ein breit diskutierter Programmieransatz ist dabei der Transaktionsmechanismus, der die neueste Entwicklungsrichtung darstellt und mit der wir die Beschreibung des derzeitigen Standes der Programmierung von Multicore-Prozessoren abschließen wollen [57, 1].
7.1 Sprachans¨ atze ¨ Dieser Abschnitt gibt einen kurzen Uberblick u ¨ ber neuere Programmiersprachen. Diese Sprachen wurden f¨ ur den Bereich des Hochleistungsrechnens (High Performance Computing) entworfen, k¨ onnen aber auch zur Programmierung von Multicore-Systemen eingesetzt werden. Unified Parallel C Unified Parallel C (UPC) wurde als Erweiterung von C f¨ ur den Einsatz auf Parallelrechnern oder Clustersystemen entworfen [21]. UPC basiert auf dem Modell eines partitionierten, globalen Adressraums (partitioned global address space, PGAS) [16], in dem gemeinsame Variablen abgelegt werden k¨ onnen. Jede Variable ist dabei mit einem bestimmten
7.1 Sprachans¨ atze
147
Thread assoziiert, kann aber von jedem anderen Thread gelesen oder manipuliert werden. Die Zugriffszeit auf die Variable ist jedoch f¨ ur den assoziierten Thread typischerweise geringer als f¨ ur einen anderen Thread. Zus¨ atzlich k¨onnen f¨ ur einen Thread private Daten definiert werden, auf die nur er zugreifen kann. Parallelit¨ at wird in UPC-Programmen dadurch erreicht, dass beim Programmstart eine festgelegte Anzahl von Threads gestartet wird. Die UPC-Spracherweiterungen von C beinhalten ein explizit paralleles Ausf¨ uhrungsmodell, Speicherkonsistenzmodelle f¨ ur den Zugriff auf gemeinsame Variablen, Synchronisationsoperationen und parallele Schleifen. F¨ ur eine detailliertere Beschreibung verweisen wir auf [59, 21]. UPC-Compiler sind f¨ ur viele Plattformen verf¨ ugbar. Freie UPC-Compiler f¨ ur Linux sind z.B. der Berkeley UPC-Compiler (upc.nersc.gov) oder der GCC UPC-Compiler (www.intrepid.com/upc). Weitere Sprachen, die PGAS realisieren sind Co-Array Fortran Language (CAF), eine auf Fortran basierende parallele Sprache, und Titanium, eine auf Java basierende Sprache ¨ahnlich zu UPC. DARPA HPCS Programmiersprachen Im Rahmen des DARPA HPCS-Programms (High Productivity Computing Systems) wurden neue Programmiersprachen vorgeschlagen und implementiert, die die Programmierung eines gemeinsamen Adressraums mit Sprachkonstrukten unterst¨ utzen sollen. Zu diesen Sprachen geh¨oren Fortress, X10 und Chapel. Fortress wurde von Sun entwickelt und ist eine an Fortran angelehnte neue objektorientierte Sprache, die die Programmierung paralleler Systeme durch Verwendung einer mathematischen Notation erleichtern soll [4]. Fortress
148
7 Weitere Ans¨ atze
unterst¨ utzt eine parallele Abarbeitung von Programmen durch parallele Schleifen oder die parallele Auswertung von Funktionsargumenten durch mehrere Threads. Viele Konstrukte sind dabei implizit parallel, d.h. die erforderlichen Threads werden ohne explizite Steuerung im Programm erzeugt. So wird z.B. f¨ ur jeden Parameter eines Funktionsaufrufs implizit ein separater Thread zur Auswertung eingesetzt, ohne dass dies im Programm angegeben werden muss. Zus¨ atzlich zu diesen impliziten Threads k¨onnen explizite Threads zur Verarbeitung von Programmteilen abgespalten werden. Die Synchronisation dieser Threads erfolgt mit atomic-Ausdr¨ ucken; diese stellen sicher, dass der Effekt auf den Speicher erst nach kompletter Abarbeitung des Ausdrucks atomar sichtbar wird, vgl. auch Abschnitt 7.2 u ¨ ber Transaktionsmechanismen. X10 wurde von IBM als Erweiterung von Java f¨ ur ¨ den Bereich des Hochleistungsrechnens entwickelt. Ahnlich zu UPC basiert X10 auf dem PGAS-Speichermodell und erweitert dieses zum GALS-Modell (globally asynchronous, locally synchronous) durch Einf¨ uhrung von logischen Ausf¨ uhrungsorten (places genannt) [14]. Threads eines Ausf¨ uhrungsortes haben eine lokal synchrone Sicht auf einen gemeinsamen Adressraum, Threads unterschiedlicher Ausf¨ uhrungsorte werden dagegen asynchron zueinander ausgef¨ uhrt. X10 beinhaltet eine Vielzahl von Operationen zur Manipulation von Feldvariablen und Teilen von Feldvariablen. Mithilfe von Feldverteilungen kann die Aufteilung von Feldern auf unterschiedliche Ausf¨ uhrungsorte im globalen Speicher spezifiziert werden. F¨ ur die Synchronisation von Threads stehen atomic-Bl¨ ocke zur Verf¨ ugung, die eine atomare Ausf¨ uhrung von Anweisungen bewirken. Die korrekte Verwendung von Sperrmechanismen, z.B. durch synchronized-Bl¨ ocke oder -Methoden, wird dadurch dem Laufzeitsystem u ¨bertragen.
7.1 Sprachans¨ atze
149
Chapel wurde von Cray Inc. als neue parallele Programmiersprache f¨ ur Hochleistungsrechnen entworfen [18]. Die verwendeten Konstrukte sind teilweise an HighPerformance Fortran (HPF) angelehnt. Chapel basiert wie Fortress und X10 auf dem Modell eines globalen Adressraums, in dem Datenstrukturen wie z.B. Felder abgelegt und zugegriffen werden k¨ onnen. Die unterst¨ utzte Parallelit¨ at ist threadbasiert: bei Programmstart gibt es einen Haupt-Thread; durch Verwendung spezieller Sprachkonstrukte (parallele Schleifen) k¨ onnen weitere Threads erzeugt werden, die dann vom Laufzeitsystem verwaltet werden. Ein explizites Starten und Beenden von Threads durch den Programmierer entf¨ allt damit. F¨ ur die Koordination von Berechnungen auf gemeinsamen Daten stehen Synchronisationsvariablen und atomic-Bl¨ ocke zur Verf¨ ugung. Global Arrays Zur Unterst¨ utzung der Programmierung von Anwendungen des wissenschaftlichen Rechnens, die feldbasierte Datenstrukturen wie z.B. Matrizen verwenden, wurde der GAAnsatz (Global Arrays) entwickelt [51]. Dieser wird als Bibliothek mit Sprachanbindung f¨ ur C, C++ und Fortran f¨ ur unterschiedliche Plattformen zur Verf¨ ugung gestellt. Der GA-Ansatz basiert auf einem gemeinsamen Adressraum, in dem Felddatenstrukturen (globale Felder) so abgelegt werden k¨ onnen, dass jedem Prozess ein logischer Block des globalen Feldes zugeteilt ist; auf diesen Block kann der Prozess schneller zugreifen als auf die anderen Bl¨ ocke. Die GABibliothek stellt Basisoperationen f¨ ur den gemeinsamen Adressraum (put, get, scatter, gather) sowie atomare Operationen und Sperrmechanismen f¨ ur den Zugriff auf globale Felder zur Verf¨ ugung. Der Datenaustausch zwischen Prozessoren kann u ¨ber die globalen Felder, aber auch u ¨ ber eine
150
7 Weitere Ans¨ atze
Message-Passing-Bibliothek wie MPI erfolgen. Ein wichtiges Anwendungsgebiet des GA-Ansatzes liegt im Bereich chemischer Simulationen.
7.2 Transaktionsspeicher F¨ ur die Synchronisation von Threads beim Zugriff auf gemeinsame Daten werden in den meisten Ans¨ atzen Sperrvariablen (Mutexvariablen) und kritische Bereiche verwendet. Dabei wird typischerweise wie folgt vorgegangen: • •
der Programmierer identifiziert kritische Bereiche im Programm und sch¨ utzt diese explizit mit Sperrvariablen (lock/unlock-Mechanismus); der Sperrvariablen-Mechanismus sorgt daf¨ ur, dass ein kritischer Bereich jeweils nur von einem Thread ausgef¨ uhrt wird.
Der Ansatz mit Sperrvariablen kann zu einer Sequentialisierung der Abarbeitung von kritischen Bereichen f¨ uhren was je nach Anwendung die Skalierbarkeit erheblich beeintr¨ achtigt, da die kritischen Bereiche zum Flaschenhals werden k¨ onnen. Dies gilt insbesondere dann, wenn viele Threads verwendet werden und die kritischen Bereiche eine grobe Granularit¨ at haben, also relativ lang sind. F¨ ur heutige Multicore-Prozessoren spielt dieses Problem noch eine untergeordnete Rolle, da nur wenige Prozessorkerne verwendet werden. F¨ ur zuk¨ unftige MulticoreProzessoren mit Dutzenden von Prozessorkernen oder beim Zusammenschalten mehrere Multicore-Prozessoren zu Clustersystemen muss das Problem sehr wohl beachtet werden. Als alternativer Ansatz zum Sperrmechanismus wurde daher die Verwendung eines sogenannten Transaktionsspeichers (transactional memory) vorgeschlagen, siehe
7.2 Transaktionsspeicher
151
z.B. [1, 7, 29]. Eine Transaktion wird dabei als eine endliche Folge von Instruktionen definiert, die von einem einzelnen Thread ausgef¨ uhrt wird und bei deren Ausf¨ uhrung folgende Eigenschaften gelten: •
•
Serialisierbarkeit: Die Transaktionen eines Programms erscheinen f¨ ur alle beteiligten Threads sequentiell angeordnet; kein Thread beobachtet eine Verschr¨ ankung von Instruktionen verschiedener Transaktionen; f¨ ur jeden Thread erscheinen die Transaktionen in der gleichen Reihenfolge. Atomarit¨ at: Die von den Instruktionen einer Trans¨ aktion durchgef¨ uhrten Anderungen des gemeinsamen Speichers werden f¨ ur die die Transaktion nicht ausf¨ uhrenden Threads erst am Ende der Transaktion atomar sichtbar (commit); eine abgebrochene Transaktion hat keinen Effekt auf den gemeinsamen Speicher (abort).
Die mit einem Sperrmechanismus definierten kritischen Bereiche sind in diesem Sinne nicht atomar, da der Effekt auf den gemeinsamen Speicher direkt sichtbar wird. Die Verwendung des Transaktionsmechanismus ist also nicht nur eine Programmiertechnik, sondern kann auch andere Ergebnisse als ein Sperrmechanismus bewirken. Die Verwendung von Transaktionen erfordert die Einf¨ uhrung neuer Konstrukte, etwa auf Sprachebene. Daf¨ ur wurde die Einf¨ uhrung von atomic-Bl¨ ocken zur Identifikation von Transaktionen vorgeschlagen [1]: anstatt der Verwendung einer Sperrvariablen wird ein Sprachkonstrukt atomic{B} vorgeschlagen, das die Anweisungen in Block B als Transaktion ausf¨ uhrt. Die im Rahmen des HPCS-Projektes entwickelten Sprachen - Fortress von Sun [4], X10 von IBM [14] und Cha-
152
7 Weitere Ans¨ atze
pel von Cray [18] - enthalten solche Konstrukte zur Unterst¨ utzung von Transaktionen. Der Unterschied zwischen der Verwendung von Sperrvariablen und atomaren Bl¨ ocken ist in Abb. 7.1 am Beispiel eines threadsicheren Kontozugriffs veranschaulicht. Ein sperrorientierter Zugriff wird durch die Klasse LockAccount mit Hilfe eines Java synchronized-Blocks realisiert. Ein Aufruf von add() leitet den Aufruf einfach an die gleichnamige Methode der nicht-threadsicheren Account-Klasse weiter, die wir hier als gegeben voraussetzen. Die Ausf¨ uhrung des synchronized-Blocks bewirkt die Aktivierung eines Sperrmechanismus bzgl. des Objekts mutex; dieser stellt eine Sequentialisierung des Zugriffs sicher. Ein transaktionsorientierter Zugriff k¨ onnte durch die Klasse AtomicAccount realisiert werden, die einen atomic-Befehl verwendet, um die Aktivierung der nicht-threadsicheren add()-Methode der Account-Klasse als Transaktion zu identifizieren. Damit w¨ are das Laufzeitsystem f¨ ur die Sicherstellung der Serialisierbarkeit und Atomarit¨ at verantwortlich, m¨ usste aber nicht unbedingt eine Sequentialisierung erzwingen, wenn dies nicht notwendig ist. Dabei ist zu beachten, dass atomicBl¨ ocke (noch) nicht Teil der Java-Sprache sind. Der Vorteil der Verwendung von Transaktionen liegt darin, dass das Laufzeitsystem auch mehrere Transaktionen parallel zueinander ausf¨ uhren kann, wenn das Speicherzugriffsmuster der Transaktionen dies zuließe. Bei Verwendung einfacher Sperrvariablen ist dies nicht ohne weiteres m¨ oglich. Sperrvariablen k¨ onnen zwar verwendet werden, um komplexere Synchronisationsmechanismen zu definieren, die den gleichzeitigen Zugriff mehrerer Threads erlauben, dies erfordert aber einen erheblichen zus¨ atzlichen Programmieraufwand. Ein Beispiel sind Lese-Schreibsperren, die mehrere Lesezugriffe gleichzeitig, aber jeweils nur einen Schreibzugriff erlauben, siehe Abschnitt 4.4. F¨ ur die Ver-
7.2 Transaktionsspeicher
153
class LockAccount implements Account { Object mutex; Account a; LockAccount (Account a) { this.a = a; mutex = New Object(); } public int add (int x) { synchronized (mutex) { return a.add(x); } } ... } class AtomicAccount implements Account { Account a; AtomicAccount (Account a) { this.a = a; } public int add (int x) { atomic { return a.add(x); } } ... } Abbildung 7.1. Vergleich zwischen sperrorientierter und transaktionsorientierter (Vorschlag) Realisierung eines Kontozugriffs.
154
7 Weitere Ans¨ atze
wendung von Transaktionen wird damit eine bessere Skalierbarkeit erwartet als bei der Verwendung von Sperrvariablen. Die Verarbeitung von Transaktionen stellt bestimmte Anforderungen an das Laufzeitsystem: •
•
Versionskontrolle: Der Effekt einer Transaktion darf erst am Ende der Transaktion sichtbar werden. Damit muss das Laufzeitsystem w¨ ahrend der Abarbeitung einer Transaktion auf einem separaten Datensatz arbeiten. Wird die Transaktion abgebrochen, bleibt der alte Datensatz erhalten. Bei erfolgreicher Ausf¨ uhrung der Transaktion wird der neue Datensatz am Ende der Transaktion global sichtbar. Erkennen von Konflikten: Sollen mehrere Transaktionen zur Verbesserung der Skalierbarkeitseigenschaften konkurrierend ausgef¨ uhrt werden, muss sichergestellt sein, dass sie nicht gleichzeitig auf dieselben Daten zugreifen. Dazu ist eine Analyse der Speicherzugriffsmuster der Transaktionen durch das Laufzeitsystem notwendig.
Die Verarbeitung von Transaktionen ist zur Zeit ein aktives Forschungsgebiet und so wird sicherlich eine geraume Zeit vergehen, bis der Ansatz in Standard-Programmiersprachen verwendet werden kann. Der Ansatz wird jedoch als vielversprechend eingesch¨ atzt, da er einen abstrakteren Mechanismus als Sperrvariablen zur Verf¨ ugung stellt, der Probleme wie Deadlocks vermeidet, zu einer guten Skalierbarkeit von threadbasierten Anwendungsprogrammen f¨ uhren kann und durch den Programmierer leichter anwendbar ist.
Literatur
1. A. Adl-Tabatabai, C. Kozyrakis, and B. Saha. Unlocking concurrency. ACM Queue, 4(10):24–33, Dec 2006. 2. A. Aho, M. Lam, R. Sethi, and J. Ullman. Compilers: Principles, Techniques & Tools. Pearson-Addison Wesley, 2007. 3. S. Akhter and J. Roberts. Multi-Core Programming – Increasing Performance through Software Multi-threading. Intel Press, 2006. 4. Eric Allen, David Chase, Joe Hallett, Victor Luchangco, Jan-Willem Maessen, Sukyoung Ryu, Guy L. Steele, Jr., and Sam Tobin-Hochstadt. The Fortress Language Specification, version 1.0beta, March 2007. 5. R. Allen and K. Kennedy. Optimizing Compilers for Modern Architectures. Morgan Kaufmann, 2002. 6. G. Amdahl. Validity of the Single Processor Approach to Achieving Large-Scale Computer Capabilities. In AFIPS Conference Proceedings, volume 30, pages 483–485, 1967. 7. K. Asanovic, R. Bodik, B.C. Catanzaro, J.J. Gebis, P. Husbands, K. Keutzer, D.A. Patterson, W.L. Plishker, J. Shalf, S.W. Williams, and K.A. Yelick. The Landscape of Parallel Computing Research: A View from Berkeley. Technical Report UCB/EECS-2006-183, EECS Department, University of California, Berkeley, December 2006.
156
Literatur
8. R. Bird. Introduction to Functional Programming using Haskell. Prentice Hall, 1998. 9. A. Birrell. An introduction to programming with threads. Technical Report Research Report 35, Compaq Systems Research center, Palo Alto, 1989. 10. A. Bode and W. Karl. Multicore: Architektur. Springer Verlag, 2007. 11. D. R. Butenhof. Programming with POSIX Threads. Addison-Wesley, 1997. 12. N. Carriero and D. Gelernter. Linda in Context. Commun. ACM, 32(4):444–458, 1989. 13. R. Chandra, L. Dagum, D. Koher, D. Maydan, J. McDonald, and R. Menon. Parallel Programming in OpenMP. Morgan Kaufmann, 2001. 14. P. Charles, C. Grothoff, V.A. Saraswat, C. Donawa, A. Kielstra, K. Ebcioglu, C. von Praun, and V. Sarkar. X10: an object-oriented approach to non-uniform cluster computing. In R. Johnson and R.P. Gabriel, editors, Proceedings of the 20th Annual ACM SIGPLAN Conference on ObjectOriented Programming, Systems, Languages, and Applications (OOPSLA), pages 519–538. ACM, October 2005. 15. M.E. Conway. A Multiprocessor System Design. In Proc. AFIPS 1963 Fall Joint Computer Conference, volume 24, pages 139–146. NewYork: Spartan Books, 1963. 16. D.E. Culler, A.C. Arpaci-Dusseau, S.C. Goldstein, A. Krishnamurthy, S. Lumetta, T. van Eicken, and K.A. Yelick. Parallel programming in Split-C. In Proceedings of Supercomputing, pages 262–273, 1993. 17. D.E. Culler, J.P. Singh, and A. Gupta. Parallel Computer Architecture: A Hardware Software Approach. Morgan Kaufmann, 1999. 18. D. Callahan and B. L. Chamberlain and H. P. Zima. The Cascade High Productivity Language. In IPDPS, pages 52– 60. IEEE Computer Society, 2004. 19. E.W. Dijkstra. Cooperating Sequential Processes. In F. Genuys, editor, Programming Languages, pages 43–112. Academic Press, 1968.
Literatur
157
20. J. Doweck. Intel Smart Memory Access: Minimizing Latency on Intel Core Microarchitecture. Technology@IntelMagazine, September 2006. 21. T. El-Ghazawi, W. Carlson, T. Sterling, and K. Yelick. UPC: Distributed Sahred Memory Programming. Wiley, 2005. 22. D. Flanagan. Java in a Nutshell. O’Reilly, 2005. 23. M.J. Flynn. Some Computer Organizations and their Effectiveness. IEEE Transactions on Computers, 21(9):948–960, 1972. 24. S. Gochman, A. Mendelson, A. Naveh, and E. Rotem. Introduction to Intel Core Duo Processor Architecture. Intel Technology Journal, 10(2):89–97, May 2006. 25. B. Goetz. Java Concurrency in Practice. Addison Wesley, 2006. 26. W. Gropp, E. Lusk, and A. Skjellum. MPI – Eine Einf¨ uhrung. Oldenbourg Verlag, 2007. 27. J. Held and J. Bautista ans S. Koehl. From a Few Cores to Many – A Tera-Scale Computing Research Overview. Intel White Paper, Intel, 2006. 28. J. L. Hennessy and D. A. Patterson. Computer Architecture — A Quantitative Approach. Morgan Kaufmann, 2007. 29. M. Herlihy and J.E.B. Moss. Transactional Memory: Architectural Support for Lock-free Data Stractures. In Proc. of the 20th Ann. Int. Symp. on Computer Architecture (ISCA’93), pages 289–300, 1993. 30. J. Hippold and G. R¨ unger. Task Pool Teams: A Hybrid Programming Environment for Irregular Algorithms on SMP Clusters. Concurrency and Computation: Practice and Experience, 18(12):1575–1594, 2006. 31. C.A.R. Hoare. Monitors: An Operating Systems Structuring Concept. Commun. ACM, 17(10):549–557, 1974. 32. R.W. Hockney. The Science of Computer Benchmarking. SIAM, 1996. 33. P. Hudak and J. Fasel. A Gentle Introduction to Haskell. ACM SIGPLAN Notices, 27, No.5, May 1992.
158
Literatur
34. J.A. Kahle, M.N. Day, H.P. Hofstee, C.R. Johns, T.R. Maeurer, and D. Shippy. Introduction to the Cell Multiprocessor. IBM Journal of Research and Development, September 2005. 35. St. Kleiman, D. Shah, and B. Smaalders. Programming with Threads. Prentice Hall, 1996. 36. G. Koch. Discovering Multi-Core:Extending the Benefits of Moore’s Law. Intel White Paper, Technology@Intel Magazine, 2005. 37. P.M. Kogge. An Exploitation of the Technology Space for Multi-Core Memory/Logic Chips for Highly Scalable Parallel Systems. In Proceedings of the Innovative Architecture for Future Generation High-Performance Processors and Systems. IEEE, 2005. 38. M. Korch and T. Rauber. A comparison of task pools for dynamic load balancing of irregular algorithms. Concurrency and Computation: Practice and Experience, 16:1–47, January 2004. 39. K. Krewell. Cell moves into the Limelight. Microprocessor Report, Reed Business Information, February 2005. www.MPRonline.com. 40. D. Lea. Concurrent Programming in Java: Design Principles and Patterns. Addison Wesley, 1999. 41. E.A. Lee. The Problem with Threads. IEEE Computer, 39(5):33–42, 2006. 42. B. Lewis and D. J. Berg. Multithreaded Programming with Pthreads. Prentice Hall, 1998. 43. D.T. Marr, F. Binns, D. L. Hill, G. Hinton, D.A. Koufaty, J.A. Miller, and M. Upton. Hyper-threading technology architecture and microarchitecture. Intel Technology Journal, 6(1):4–15, February 2002. 44. D.T. Marr, F. Binus, D.L. Hill, G. Hinton, D.A. Konfaty, J.A. Miller, and M. Upton. Hyper-Threading Technology Architecture and Microarchitecture. Intel Technology Journal, 6(1):4–15, 2002. 45. T. Mattson, B. Sandor, and B. Massingill. Pattern for Parallel Programming. Pearson – Addison Wesley, 2005.
Literatur
159
46. M.K. McKusick, K. Bostic, M.J. Karels, and J.S. Quarterman. The Design and Implementation of the 4.4 BSD Operating System. Addison-Wesley, 1996. 47. A. Mendelson, J. Mandelblat, S. Gochman, A. Shemer, R. Chabukswar, E. Niemeyer, and A. Kumar. CMP Implementation in Systems Based on the Intel Core Duo Processor. Intel Technology Journal, 10(2):99–107, May 2006. 48. A. Naveh, E. Rotem, A. Mendelson, S. Gochman, R. Chabukswar, K. Krishnan, and A. Kumar. Power and Thermal Management in the Intel Core Duo Processor. Intel Technology Journal, 10(2):109–122, May 2006. 49. B. Nichols, D. Buttlar, and J. Proulx Farrell. Pthreads Programming. O’Reilly & Associates, 1997. 50. M.A. Nichols, H.J. Siegel, and H.G. Dietz. Data Management and Control–Flow Aspects of an SIMD/SPMD Parallel Language/Compiler. IEEE Transactions on Parallel and Distributed Systems, 4(2):222–234, 1993. 51. J. Nieplocha, J. Ju, M.K. Krishnan, B. Palmer, and V. Tipparaju. The Global Arrays User’s Manual. Technical Report PNNL-13130, Pacific Northwest National Laboratory, 2002. 52. S. Oaks and H. Wong. Java Threads. 3. Auflage, O’Reilly, 2004. 53. OpenMP Application Program Interface, Version 2.5, May 2005. 54. D.A. Patterson and J.L. Hennessy. Computer Organization & Design — The Hardware/Software Interface. Morgan Kaufmann, 2006. 55. C.D. Polychronopoulos. Parallel Programming and Compilers. Kluwer Academic Publishers, 1988. 56. S. Prasad. Multithreading Programming Techniques. McGraw-Hill, 1997. 57. R. Rajwar and J. Goodman. Transactional Execution: Towards Reliable, High-Performance Multithreading. IEEE Micro, pages 117–125, 2003. 58. R.M. Ramanathan. Intel Multi-core Processors: Leading the Next Digital Revaluation. Intel White Paper, TechnologyIntel Magazine, 2005.
160
Literatur
59. T. Rauber and G. R¨ unger. Parallele Programmierung, 2te Auflage. eXamens.press. Springer, 2007. 60. D. Skillicorn and D. Talia. Models and Languages for Parallel Computation. ACM Computing Surveys, 30(2):123–169, 1998. 61. M. Snir, S. Otto, S. Huss-Ledermann, D. Walker, and J. Dongarra. MPI: The Complete Reference. MIT Press, Camdridge, MA, 1996. Zugreifbar u ¨ ber: www.netlib.org/utk/papers/mpi book/mpi book.html. 62. H. Sutter. The free lunch is over – a fundamental turn toward concurrency in software. Dr.Dobb’s Jouernal, 30(3), 2005. 63. H. Sutter and J. Larus. Software and the Concurrency Revolution. 2005, 3(7):54–62, ACM Queue. 64. S. Thompson. Haskell – The Craft of Functional Programming. Addison Wesley, 1999. 65. L.G. Valiant. A Bridging Model for parallel Computation. Commun. ACM, 33(8):103–111, 1990. 66. M. Wolfe. High Performance Compilers for Parallel Computing. Addison-Wesley, 1996. 67. S.N. Zheltov and S.V. Bratanov. Measuring HT-Enabled Multi-Core: Advantages of a Thread-Oriented Approach. Technology & Intel Magazine, December 2005.
Index
Granularitat, 23 Mutex-Variable, 49 Amdahlsches Gesetz, 37 atomares Objekt, 93 Atomarit¨ at, 151 atomic-Block, 148 Barrier-Synchronisation, 47 BedingungsSynchronisation, 49 Bedingungsausdruck, 70 Bedingungsvariable, 50, 101, 118 in java.util.concurrent, 118 in Pthreads, 70 Benutzer-Thread, 42
Betriebssystem-Thread, 42 Cell-Prozessor, 17 schematischer Aufbau, 19 Chapel, 149 Chip-Multiprocessing, 25 Client-Server-Modell, 57 Datenparallelit¨ at, 56 Deadlock, 52, 69 Effizienz, 37 False Sharing, 26 Flynnsche Klassifikation, 27 Fork-Join, 55 in OpenMP, 125 Fortress, 147 Gesetz von Moore, 1
162
Index
Global Arrays, 149 HPCS Programmiersprachen, 147 Hyperthreading, 5 Intel Core 2, 13 Intel Tera-scale Computing, 11 Java Interface Executor, 122 Thread-Pool, 122 atomare Operation, 120 Barrier, 116 Interface Condition, 118 Interface Lock, 117 Semaphore, 115 Java-Threads, 61, 85–123 Klasse Thread, 86 Mutexvariable, 92 Scheduling, 113 Signalmechanismus, 101 Synchronisation, 91 java.util.concurrent, 115 Kommunikation, 62 Kontextwechsel, 40 Koordination, 46 Kosten eines parallelen Programmes, 36 kritischer Bereich, 48, 150 in OpenMP, 139 logischer Prozessor, 5 Mapping, 23
Master-Slave, 57 Master-Worker, 57 Matrix-Multiplikation in OpenMP, 134 Microsoft.NET, 61 MIMD, 28 MISD, 27 Monitor, 51 Moore Gesetz, 1 MPI, 57, 62 Multicore Cell-Prozessor, 17 Hierarchischen Design, 8 Intel Core 2, 13 Netzwerkbasierten Design, 10 Pipeline-Design, 9 Multicore-Prozessor, 6 Multiprocessing, 25 Multitasking, 24 Multithreading Hyperthreading, 5 simultanes, 5 Mutexvariable, 150 in Java, 92 in Pthreads, 66 mutual exclusion, 48 Nebenl¨ aufigkeit, 25 nichtdeterministisches Verhalten, 48 OpenMP, 61, 125–144 atomare Operation, 140 default Parameter, 128 kritischer Bereich, 139
Index parallele Schleife, 131 paralleler Bereich, 127, 135 private Parameter, 128 reduction Parameter, 141 schedule Parameter, 132 shared Parameter, 128 Sperrmechanismus, 142 parallele Laufzeit, 35 parallele Schleife, 31 in OpenMP, 131 paralleler Bereich, 56 in OpenMP, 127 paralleles Programmiermodell, 29 paralleles System, 29 Parbegin-Parend, 55 Parbegin-ParendKonstrukt, 55 Pipelining, 58 Priorit¨ atsinversion, 114 ProduzentenKonsumenten, 60 Programmiermodell Master-Slave, 57 Prozess, 40 Prozessorkern, 7 Pthreads, 61 Bedingungsvariable, 70 Deadlock, 69 Erzeugung von Threads, 64 Mutexvariable, 66
163
Sperrmechanismus, 68 Puffermechanismus, 120 Rechenressourcen, 23 Rechner mit gemeinsamem Speicher, 28 Rechner mit verteilten Speicher, 28 Reduktionsoperation in OpenMP, 141 Scheduling, 23 Java-Threads, 113 Priorit¨ atsinversion, 114 Semaphor, 50 Sequentialisierung, 49, 52 Serialisierbarkeit, 151 Signalmechanismus in Java, 101 SIMD, 27, 56 simultanes Multithreading, 5, 25 SISD, 27 Skalierbarkeit, 38 SMT, 5 Speedup, 36 Sperrmechanismus, 49 in Java, 92 in java.util.concurrent, 117 in OpenMP, 142 in Pthreads, 66 Sperrvariable, 49, 66, 92, 117, 150 SPMD, 56 Synchronisation, 46
164
Index
mit Java-Threads, 91 Task, 23, 122 Taskpool, 59 PthreadImplementierung, 79 Thread, 33 in Java, 85 in OpenMP, 125 in Pthreads, 63 Zustand, 45 Threads, 41 1:1-Abbildung, 44 N:M-Abbildung, 45
N:1-Abbildung, 44 Transaktionsspeicher, 150 Unified Parallel C, 146 voll-synchr. Objekt, 93 von-Neumann-Rechner, 27 wechselseitiger Ausschluss, 48 Win32 Threads, 61 X10, 148 zeitkritischer Ablauf, 47