Heinz Kredel1
Teile dieses Artikels wurden wärend der Zusammenarbeit mit Akitoshi Yoshida (jetzt SAP AG Walldorf) an dem Buch [6] entwickelt.
Auch wenn paralleles Programmieren schon einige Zeit erforscht ist und geeignete Werkzeuge zur Implementierung vorhanden sind, werden diese doch nur von wenigen Programmieren konsequent genutzt. In diesem Artikel wollen wir zeigen, wie mit der Programmiersprache Java [1] und der entsprechenden Bibliotheksumgebung parallele Programme geschrieben werden können. Dabei können wir in der Kürze nur die wichtigsten Sprachkonstrukte und Klassen vorstellen. Es wird aber ausreichen, um Sie von der Leistungsfähigkeit von Java für dieses Gebiet zu überzeugen. Auch wenn die Java Programme hinterher nicht die Performance von numerischen FORTRAN oder C++ Programmen erreichen, so erleichtert Java und seine Umgebung doch sehr die Entwicklung von vielen parallelen Anwendungen. Für Java spricht auch die allgemeine Verfügbarkeit, und die gute Integration aller für die parallele Programmierung wichtigen Hilfsmittel. Somit können auch Auszubildende, ohne Zugang zu einem Parallelrechner zu benötigen, die Grundlagen der parallelen Programmierung lernen.
Im Rest dieses Abschnitts führen wir kurz in die Problematik des parallelen Programmierens ein. Dann besprechen wir im nächsten Abschnitt 2 zunächst Threads und ihre Programmierung und anschließend in Abschnitt 3 die Programmierung der Kommunikation. In Abschnitt 4 gehen wir auf Strategien und Rezepte zur Parallelisierung von rechenintensiven Anwendungen ein. Eine ausführlichere Besprechung aller notwendigen Konzepte und ihrer Implementierung mit Java finden Sie in unserem Buch [6]. Um den Umfang für diesen Artikel nicht zu überschreiten, setzen wir im folgenden elementare Kenntnisse in Java voraus.
Die Hardware-Entwicklung der letzten Jahre und Jahrzehnte hat zu einer weiten Verbreitung von Multitasking-Betriebssystemen, Multiprozessor Rechnern sowie zu Netzwerken von Workstations und PCs geführt. Eine Konsequenz dieser Entwicklung war es, daß nun verschiedene (Berechnungs-) Aufgaben gleichzeitig und nebeneinander bearbeitet werden können. Die Programmierung dieser Systeme wird als Parallele oder Nebenläufige Programmierung bezeichnet; im Englischen wird etwas treffender von `concurrent programming' gesprochen. Die Software mit der die Systeme programmiert werden, hat folgende Hilfsmittel, um die gleichzeitige Bearbeitung auszudrücken:
Aus der Sicht eines Programmierers besteht die Aufgabe während der Entwicklung von Programmen in der geeigneten wechselseitigen Abstimmung des Algorithmus und der verwendeten Datenstrukturen. In der Parallelen Programmierung gilt es - unabhängig von der zugrundeliegenden Hardware - die verschiedenen Möglichkeiten der Datenhaltung zu berücksichtigen: Daten im gemeinsamen (Haupt-)Speicher oder Daten verteilt auf lokale Speicher in vernetzten Rechnern. Im ersten Fall muß der gemeinsame Zugriff mehrerer Programme auf diese Daten synchronisiert werden; im zweiten Fall müssen die Daten zwischen den Rechnern transportiert werden. Die Optimierung der Programme bedeutet im ersten Fall eine Verringerung der Zugriffskonflikte, im zweiten Fall eine Verringerung des Datentransports.
Zur Implementierung paralleler Programme stehen im Wesentlichen zwei Techniken bereit: Prozesse oder Threads. Prozesse sind eigenständige Programme, die vom jeweiligen Betriebssystem unabhängig voneinander zur Ausführung gebracht werden. Unter DOS und Windows sind das also die EXE-Dateien und unter Unix die normalen Binaries (a.out-Dateien). Prozesse können über verschiedene Hilfsmittel mit anderen Prozessen in Kontakt treten: TCP/IP Sockets, Message Passing Bibliotheken (wie PVM [2] oder MPI [4]), Pipelines (über die Standard-Eingabe und -Ausgabe) oder über gemeinsame Speicherbereiche, die vom Betriebssystem angefordert werden. Threads, auch Ausführungsfäden genannt, sind keine eigenständigen Programme, sondern vielmehr Teile von Prozessen. In jedem Thread wird ein Unterprogramm ausgeführt, das uneingeschränkten Zugriff auf alle globalen Daten des umgebenden Prozesses hat. Zu den globalen Daten gehören globale Variablen, Datei-Handles und auch Netzverbindungen. Nur die lokalen Daten der Unterprogramme sind in jedem Thread verschieden.
Die Thread Funktionalität kann in Programmiersprachen durch eigene Sprachkonstrukte oder durch externe Bibliotheken realisiert sein.
In Java stehen Threads als Basisklassen zusammen mit Spracherweiterungen zur Verfügung. In Ada gehören Threads, hier ``Tasks'' genannt, zum Sprachumfang. Als minimale Lösung werden in C, C++, Modula-2, FORTRAN und anderen Programmiersprachen Threads durch sprachunabhängige Programmbibliotheken angeboten. Eine standardisierte Bibliothekslösung stellt die POSIX Threads Bibliothek ``Pthread'' dar. Sie ist in OSF DCE (Open Software Foundation, Distributed Computing Environment) enthalten, wird aber auch auf verschiedenen anderen Systemen angeboten.
Auch die Java Threads werden - je nach Plattform - mit Hilfe von POSIX Threads implementiert. Thread Bibliotheken mit ähnlicher Funktionalität wie POSIX Threads sind z.B. implementiert auf OS/2 und Windows NT. Wichtig für eine effiziente Implementierung von Threads ist, daß Threads schon vom Betriebssystem bereitgestellt werden und somit auch tatsächlich auf mehrere Prozessoren verteilt werden können.
In jedem Prozeß (siehe Abbildung 1) wird zunächst vom Betriebssystem ein Hauptthread erzeugt und gestartet. Dieser kann nun weitere Threads erzeugen, die wiederum weitere Threads generieren können. Alle Threads können auf die gemeinsamen Ressourcen (Speicher, Dateien, Netzverbindungen) zugreifen und diese auch modifizieren. Falls ein Thread alle seine ihm zugedachten Aufgaben getan hat, kann er terminieren. Die Zustände, in denen sich Threads (wie auch Prozesse) befinden können, sind: running, blocked, ready oder terminated.
Die Java-Implementierung von Threads, die in dem Java-Package java.lang enthalten ist, besteht aus folgenden Teilen:
In Java muß zur Erzeugung von Threads in üblicher Weise ein Objekt einer geeignete Klasse erzeugt werden, und dann wird im richtigen Moment eine Methode dieses Objekts aufgerufen. Für die Klasse gibt es zwei Möglichkeiten
Der Thread-Konstruktor und die Thread-Methoden start() und join() haben die folgenden Spezifikationen.
public Thread(Runnable target) public Thread(Runnable target, String name) public Thread(ThreadGroup group, Runnable target, String name) public synchronized void start() public final void join() throws InterruptedExceptionMit einer ThreadGroup lassen sich Gruppen von Threads definieren. Auf die gesamte Gruppe lassen sich dann Operationen zur Steuerung der Mitglieder-Threads anwenden. Falls der Thread keiner besonderen ThreadGroup angehören muß und er auch keinen Namen haben braucht, reicht die erste Variante Thread(Runnable target). Mit der start()-Methode von Thread wird dann die run()-Methode der Klasse myRunnable gestartet. Mit der join()-Methode von Thread kann auf die normale Beendigung des Threads gewartet werden. Thread kennt noch die stop()-Methode, mit der ein laufender Thread abgebrochen werden kann. Normalerweise wird man stop() selten benutzen und lieber ein Verfahren implementieren, das zu einer normalen Terminierung der Threads führt. stop() soll ab Java 1.2 nicht mehr verwendet werden.
Um einen ersten Eindruck eines parallelen Java-Programms zu bekommen, betrachten wir folgende Programmteile.
class Action implements Runnable { int var; public Action(int v) { var = v; } public void run() { doSomeWork(var); } }Der erste Teil definiert eine Klasse, die Runnable implementiert. Der Konstruktor nimmt einen Parameter entgegen, der dann in der Funktion doSomeWork() in der run()-Methode verwendet wird. Die run()-Methode wird später von der jeweiligen start()-Methode gestartet.
Thread t1 = new Thread(new Action(1)); Thread t2 = new Thread(new Action(2)); Thread t3 = new Thread(new Action(3)); try { t1.start(); t2.start(); t3.start(); t1.join(); t2.join(); t3.join(); } catch (InterruptedException e) { ... }Der zweite Teil zeigt die Erzeugung von drei Threads t1, t2 und t3. Die Threads werden jeweils mit einer neuen Instanz der Klasse Action erzeugt. Dann werden die Threads der Reihe nach mit ti.start() gestartet. Anschließend wird mit drei ti.join() auf die Terminierung der Threads gewartet.
Da innerhalb der verschiedenen run()-Methoden globale Variablen vorkommen können, kann es passieren, daß gleichzeitig auf ein und dieselbe Variable zugegriffen wird. An diesen Stellen ist einige Vorsicht geboten, da sonst die Werte dieser Variablen am Programmende falsche oder zufällige Werte enthalten können. Um diese unerwünschten Überlappungen zu vermeiden, ist es erforderlich, daß sich Programmteile gegenseitig ausschließen ( mutual exclusion) oder gegebenenfalls die Abarbeitungsschritte aufeinander abstimmen ( condition synchronization).
Da wir nicht verhindern können, daß Schreib- oder Lese-Operationen auf den globalen Speicher in nebenläufigen Prozessen stattfinden und sichtbar werden, benötigen wir einen Trick. Dieser besteht darin, den Thread in der Ausführung anzuhalten, der die Zustandsänderungen nicht sehen soll. Dies setzt voraus, daß wir bei Bedarf nicht nur lokal ein Haltekonstrukt einfügen, sondern wir müssen alle gefährdeten ``kritischen'' Bereiche in allen Threads ausfindig machen und dort ebenfalls ein passendes Haltekonstrukt einfügen. Dies ist keine so große Einschränkung wie es zunächst aussehen mag, denn wir können die Variablen, die in kritischen Statements vorkommen, oft in einer Klasse isolieren und ihre Modifizierung durch geeignete Methoden kontrollieren. Das Anhalten der Threads erreichen wir durch das synchronized-Sprachkonstrukt.
Das Java-Sprachkonstrukt synchronized hat die folgenden Varianten.
synchronized (object) { ... } synchronized (static object) { ... } synchronized type methodName(...) { ... } static synchronized type methodName(...) { ... }Die erste Variante benutzt eine Objektinstanz object einer beliebigen, von der Object-Klasse abgeleiteten Klasse als Haltepunkt. Das heißt: wird in verschiedenen Threads ein synchronized (object) auf ein bestimmtes Objekt object ausgeführt, so stellt das Java-Laufzeitsystem sicher, daß immer nur maximal ein Thread die Statements
{...}
ausführen kann.
Man spricht dann auch davon, daß man einen ``lock''
das Objekt setzt (es also ``verschließt'').
Ein ``lock'' kann immer nur einmal zu einem
gegebenen Zeitpunkt aktiv sein. Man spricht dann
auch von gegenseitigem Ausschluß (``mutual exclusion'').
Die ``lock''-Objekte werden dann auch als ``mutex''
bezeichnet.
Falls das Objekt als static deklariert ist, findet ein ``lock'' Systemweit nur einmal statt, da das Objekt (nämlich die Klasse) nur einmal vorhanden ist, sonst bezieht sich der ``lock'' nur auf ein Exemplar des Objekts. Das heißt, mehrere Exemplare eines Objekts haben dann verschiedene ``locks'', die untereinander nicht synchronisiert sind.
Die Semantik von
synchronized type methodName(...) { S1; ...; Sn; }
entspricht der von
type methodName(...) { synchronized(this) { S1; ...; Sn; } }Dies zeigt auch, daß Methoden einer Klasse, die nicht als synchronized deklariert sind, frei auf die Objektvariablen zugreifen können es findet keine Synchronisation statt. Damit stellt Java auch keine echten Monitore, wie sie von Hoare entwickelt wurden, zur Verfügung. Nur wenn alle Nicht- private-Methoden synchronized sind und es nur private-Variablen gibt, erhält man etwas ähnliches wie einen Monitor.
Nachdem wir die kritischen Bereiche im Griff haben, stellen wir uns dem letzten Problem. Wenn wir zum Beispiel eine Summe in einer globalen Variablen bilden wollen, müssen wir den Zugriff darauf mit synchronized() kontrollieren, aber bevor wir überhaupt anfangen zu summieren, muß sicher sein, daß die Variable initialisiert ist. Bei dem Summen-Beispiel können wir das machen, in dem wir die Threads erst nach der Initialisierung erzeugen. Bei komplexeren Datenstrukturen werden wir aber unter Umständen die Initialisierung mit in die Threads einbeziehen. In dieser Situation benötigen wir eine Möglichkeit zu warten, bis dieser Schritt abgeschlossen ist.
Zur Verfügung stehen uns die Object-Funktionen wait() und notify() mit den folgenden Spezifikationen:
public final void wait() throws InterruptedException public final void wait(long timeout) throws InterruptedException public final void notify() public final void notifyAll()Die Methode wait() darf nur innerhalb eines Abschnitts aufgerufen werden, der mit synchronized geschützt ist.
Bei Aufruf von wait() wird der aufrufende Thread in den Wartezustand versetzt (``blocked''), und gleichzeitig wird der Lock ( synchronized) auf diesen Abschnitt freigegeben. Der Thread wartet nun, bis ein anderer Thread die notify()-Methode dieses Objekts aufruft oder bis ein Timeout stattfindet. Dann wartet der Thread eventuell noch einmal, bis er den Lock auf den Abschnitt wieder erhält, und wird dann wieder ``ready'' (``runnable''). notify() weckt genau einen Thread im wait-Zustand auf. Falls mehrere Threads warten, ist nicht vorhersehbar oder bestimmbar, welcher Thread aufgeweckt wird.
Die notify entsprechenden Funktionen in anderen Thread-Paketen (wie signal in Pthreads) garantieren nicht, daß nur genau ein Thread aufgeweckt wird. Dort wird spezifiziert, daß mindestens ein Thread aufgeweckt wird.
notifyAll weckt alle an diesem Objekt wartenden Threads auf. Varianten von wait() mit timeout sind das `timed wait', bei dem eine Zeit angegeben wird, wie lange maximal auf das Eintreten einer Bedingung gewartet werden soll. Falls die Zeit verstrichen ist, terminiert die Methode, als ob ein notify() stattgefunden hätte. Das heißt, auch der Lock wird dann wieder von diesem Thread gehalten. Es muß nun die Bedingung erneut getestet werden, um festzustellen, ob nur die Zeit abgelaufen ist oder die Bedingung tatsächlich erfüllt ist.
Wenn wir auf die Erfüllung eines beliebigen Booleschen Ausdrucks warten wollen, müssen wir für jede semantisch verschiedene Bedingung eine eigene Bedingungsvariable (d.h. ein eigenes Objekt) einführen.
Für jede Bedingung wird dann ihr Test, ob sie wahr ist, an alle (wichtigen) Stellen in das Programm verlegt, an denen die Bedingung wahr geworden sein könnte, und dort wird, falls ja, mit notify (oder notifyAll) der Eintritt der Bedingung signalisiert. Da von mehreren Threads gleichzeitig oder kurz hintereinander ein notify ausgelöst werden kann, ist es für den wartenden Thread wichtig, die Verzögerungsbedingung erneut zu testen, da ein weiterer Thread sie in der Zwischenzeit schon wieder ungültig gemacht haben könnte.
Ein weiteres Problem entsteht dadurch, daß z.B. notify() vor wait() ausgeführt werden könnte, was den einen Thread auf immer blockieren würde (sogenannte ``lost signals''). Eine Lösung zu diesem Problem stellen Semaphore dar, die im Buch [6] in Abschnitt 3.4 besprochen werden.
Wir haben die wichtigsten Java Sprachkonstrukte zur Thread Programmierung vorgestellt. Anwendungen sind zum Beispiel Semaphore, Barrieren und das Bounded-Buffer-Problem, sowie die Verwendung von Threads in der Applet-Programmierung, auf die wir hier aber in der Kürze nicht eingehen können. Auch in den Enterprise Java Beans und in den neuen Java Swing Klassen werden Threads extensiv angewendet.
Mehrprozessorsysteme ohne gemeinsamen Hauptspeicher (wie z.B. Workstation-Cluster) benötigen spezielle Leitungen zur Kommunikation. (Ethernet und TCP/IP bei Workstation-Clustern, Dateien (bzw. Pipes) bei Unix-Prozessen).
Die Programmierung solcher Systeme erfordert daher die Definition von geeigneten Leitungen und Übertragungsprotokollen. Zur Kommunikation werden explizite Befehle zum Senden und Empfangen von Daten benötigt. Der Schutz von gemeinsamen Variablen ist nicht mehr erforderlich, da alle Programmteile als kritische Bereiche ausgeführt werden. Das Schema des Nachrichtenaustauschs ist in Abbildung 2 dargestellt.
Die Java-Socket-Kommunikation basiert auf den durch das Internet bekannten TCP/IP Sockets. Diese Sockets werden mittlerweile von praktisch allen Betriebssystemen unterstützt. Auch nahezu jede Treiber-Software von Netzwerk Hardware (Leitungen und Rechnerkarten) bietet Unterstützung für TCP/IP und damit für Sockets. Insbesondere unterstützen auch High Performance Netzwerke TCP/IP - wenn auch manchmal mit schlechterer Performance als speziell angepaßte Software.
Sockets stellen aus Programmiersicht die Software-Schnittstelle zu einem Netzwerk dar. Sie entsprechen etwa den Filehandles, die für den Zugriff auf Plattendateien angelegt und verwaltet werden müssen. Java-Netzwerk-Support befindet sich im Package java.net.
Wir beschränken uns in diesem Artikel auf
die Erläuterung des einfachsten Falls
einer einzigen Punkt-zu-Punkt Verbindung.
Zum Beispiel können wir damit nicht direkt
-zu-
- oder
-zu-
-Verbindungsnetzwerke
realisieren.
Zur Implementierung von Verbindungskanälen verwenden wir von Java die Klassen ServerSocket und Socket. Um eine Kontrolle über einen korrekten Aufbau eines Kanals zu bekommen, wird die Socket-Verbindung unsymmetrisch aufgebaut. Ein Kanalende (der Server Socket) baut seine Datenstrukturen auf und wartet dann auf einen Verbindungswunsch vom anderen Ende. Das andere Kanalende (der Client Socket) baut ebenfalls zuerst seine Datenstrukturen auf und versucht dann, eine Verbindung zur anderen Seite aufzubauen. Gelingt dies auf beiden Seiten, besteht ein zuverlässiger Verbindungskanal. Es wird also nicht erst bei einem folgenden Senden und Empfangen festgestellt, daß eine Verbindung gar nicht zustande kam.
Die Spezifikation des ServerSocket-Konstruktors und der benötigten Methode ist wie folgt.
public ServerSocket(int port) throws IOException public Socket accept() throws IOExceptionDer Konstruktor ServerSocket erzeugt einen neuen Server Socket an dem angegebenen port. Eine Portnummer 0 erzeugt einen Socket an einem beliebigen freien Port. Die Funktion accept() wartet auf einen Verbindungswunsch auf dem entsprechenden Port und gibt dann einen Socket für die Verbindung zurück. Die Funktion blockiert solange, bis eine Verbindung zustande kommt.
Es folgen die Spezifikationen des Socket-Konstruktors und der benötigten Methoden.
public Socket(String host, int port) throws UnknownHostException, IOException public InputStream getInputStream() throws IOException public OutputStream getOutputStream() throws IOExceptionDer Konstruktor Socket erzeugt einen neuen Client Socket zu dem angegebenen host und port. Der Socket stellt einen Eingabe- und einen Ausgabe-Strom zur Verfügung, auf die mit den Methoden getInputStream() und getOutputStream() zugegriffen werden kann. Ein Strom ist eine unformatierte und unstrukturierte Folge von Daten. Die Ströme InputStream und OutputStream bestehen aus Folgen von Bytes.
Zu den Eingabe- und Ausgabe-Strömen kann nun ein zu den Anforderungen passender Datenstrom zu geordnet werden. Für Folgen von reinen Unicode Zeichen könnten wir die Ströme Reader und Writer einsetzen. Wir verwenden in diesem Abschnitt ObjectStreams, da damit sehr flexibel beliebige (serialisierbare, serializable) Objekte über den Kanal ausgetauscht werden können. Ein Objekt-Strom kann einfache und zusammengesetzte Java-Objekte in einen Daten-Strom umwandeln und typsicher versenden oder empfangen. Daten-Ströme können sowohl Datei-Ströme als auch Netzwerk-Socket-Ströme sein. Es werden nur Objekte, die das java.io.Serializable Interface implementieren in den Objekt-Strömen akzeptiert. Objekt-Serialisierung ist eins der wesentlichen neuen Features von Java 1.1.
Falls eine Klasse Serializable implementiert, wird die Objekt-Serialisierung von Java automatisch durchgeführt. Überschreibt man die automatische Serialisierung, muß man die Klasse selbst kodieren und senden. Bei manchen Objekten wie Filehandles macht die Serialisierung natürlich keinen Sinn, denn ein Filehandle zeigt vielleicht auf eine Datei, die auf einem anderen Rechner nicht existiert.
Die Spezifikation der benötigten Konstruktoren und Methoden ist wie folgt.
public ObjectOutputStream(OutputStream out) throws IOException public void flush() throws IOException public ObjectInputStream(InputStream in) throws IOException, StreamCorruptedExceptionDer Konstruktor ObjectOutputStream erzeugt einen neuen Objekt-Ausgabestrom, zu einem gegebenen OutputStream. Zuerst wird ein Strom-Header geschrieben, der eine eindeutige Identifikation der Objekt-Strom-Klasse enthält. Mit flush() wird sichergestellt, daß dieser Header unmittelbar verschickt wird, damit die Gegenseite die Daten sofort einlesen kann.
Der Konstruktor ObjectInputStream erzeugt einen neuen Objekt-Eingabestrom, zu einem gegebenen InputStream. Zuerst wird ein Strom-Header gelesen. Passt die Identifikation im Header nicht zu der eigenen, weil beispielsweise die Daten von einem anderen Rechner mit einer inkompatiblen JDK-Version über das Netz kommen, so wird ein Fehler ausgelöst (hier ist das StreamCorruptedException). Der Konstruktor blockiert, bis ein Objekt-Ausgabe-Strom die entsprechenden Daten gesendet hat.
Zur eigentlichen Datenübertragung benötigen wir auf einer Seite eine Send-Operation ( send) und auf der anderen Seite eine Empfangs-Operation ( recieve).
Zur Implementierung der Send-Operation mit Java stehen uns die Funktion writeObject() aus der Klasse ObjectOutputStream mit der folgenden Spezifikation zur verfügung.
public final void writeObject(Object obj) throws IOExceptionDie Methode writeObject() schreibt ein Objekt auf den entsprechenden Ausgabe-Strom. Der gesamte Graph des Objekts wird zerlegt (serialisiert), verpackt und zusammen mit dem Klassennamen und der Klassensignatur geschrieben.
Für die Empfangs-Operation steht uns die Java-Funktion readObject(), aus der Klasse ObjectInputStream zur verfügung, die die folgenden Spezifikation hat.
public final Object readObject() throws OptionalDataException, ClassNotFoundException, IOExceptionDie Methode readObject() liest ein Objekt von dem entsprechenden Eingabe-Strom. Der gesamte Graph des Objekts wird gelesen, entpackt und rekonstruiert.
Da wir bereits alle Zutaten für die Implementierung der Klasse SocketChannel besprochen haben, starten wir gleich mit dem Konstruktor. Der Konstruktor nimmt einen Socket als Eingabe und setzt die Objekt-Ströme aus den entsprechenden Strömen auf. Die Methode close() dient zum schließen des Kanals. Die send()- und receive()-Methoden werden im nächsten Abschnitt besprochen.
import java.io.*; import java.net.*; public class SocketChannel { private ObjectInputStream in; private ObjectOutputStream out; private Socket soc; public SocketChannel(Socket s) throws IOException { soc = s; if (checkOrder(s)) { in = new ObjectInputStream(s.getInputStream()); out = new ObjectOutputStream( s.getOutputStream()); } else { out = new ObjectOutputStream( s.getOutputStream()); in = new ObjectInputStream(s.getInputStream()); } } public void close() { if (in != null) { try { in.close(); } catch (IOException e) { } } if (out != null) { try { out.close(); } catch (IOException e) { } } if (soc != null) { try { soc.close(); } catch (IOException e) { } } } private boolean checkOrder(Socket s) throws IOException { int p1 = s.getLocalPort(); int p2 = s.getPort(); if (p1 < p2) return true; else if (p1 > p2) return false; int a1 = s.getLocalAddress().hashCode(); int a2 = s.getInetAddress().hashCode(); if (a1 < a2) return true; else if (a1 > a2) return false; throw new IOException(); // this shouldn't happen } }Hier ist noch zu beachten, daß die Reihenfolge der Erzeugung der Objekt-Ströme out und in gegenüber der Reihenfolge in dem Server-Konstruktor vertauscht sein müssen. Dies liegt daran, daß sich Objekt-Ströme bei dem Verbindungsaufbau über die Verwendung der gleichen Klassen und JDK-Versionen einigen müssen, bevor sie aktiv werden. Das Vertauschen wird mit Hilfe der Methode checkOrder durchgeführt. Falls die Klasse auf dem gleichen Host verwendet wird, sind die Ports verschieden, sonst die IP-Adressen.
Die Sende- und Empfangs-Methoden sind wie folgt.
public void send(Object v) throws IOException { out.writeObject(v); }Im Fehlerfall wird eine IOException ausgelöst, die dann vom Aufrufer behandelt werden muß.
public Object receive() throws IOException, ClassNotFoundException { return in.readObject(); }Neben einer IOException kann auch noch eine ClassNotFoundException ausgelöst werden, falls das Objekt zu einer dem Empfänger unbekannten Klasse gehört.
Nachdem wir ein einfaches Verfahren zur Erzeugung von Kanälen gesehen haben, wollen wir jetzt eine nützliche Klasse zum gleichzeitigen Aufbau von mehreren Punkt-zu-Punkt Verbindungen vorstellen. Da Sockets immer eine Unterscheidung in Clients und Server erzwingen, macht ihre Verwendung in parallelen Programmen etwas Mühe.
Stellen Sie sich vor, wir würden 100 Prozesse starten wollen, die in einer 2d-Torus-Topologie kommunizieren sollen. In dieser Situation benötigen wir pro Prozeß mindestens vier Kanäle (links, rechts, oben und unten). Wenn dann die Reihenfolge des Verbindungsaufbaus (also der Ausführung der Konstruktoren) nicht richtig aufeinander abgestimmt ist, erhalten wir einen Deadlock, noch bevor das Programm irgend etwas sinnvolles getan hat. Zum Beispiel können nicht alle Prozesse in einer Reihe den linken Kanal gleichzeitig aktivieren. Ein anderes Problem besteht darin, daß ein Client erst dann versuchen kann, eine Verbindung aufzubauen, wenn der entsprechende Server-Teil auf einen Verbindungsaufbau wartet.
Die zwei Grundideen zur Lösung bestehen darin, die accept()-Aufrufe in einen eigenen Thread auszulagern und die Aufrufe von new Socket() zu wiederholen, falls der Server noch nicht bereit ist. Beides implementieren wir in einer Klasse ChannelFactory. Mit zwei Methoden getChannel() lassen wir uns entweder einen SocketChannel für die Client-Seite oder für die Server-Seite geben. Die Bezeichnung ``Factory'' für diese Klasse kommt daher, daß ihre Methoden Kanäle wie in einer Fabrik erzeugen. Wir unterscheiden die Server- und die Client-Seite des Kanals, indem wir einmal bei der Methode getChannel() einen Hostnamen angeben (für die Client-Seite) und einmal den Hostnamen nicht angeben, wenn wir einen Socket auf der Server-Seite erzeugen wollen.
Der im folgenden gezeigte Konstruktor der Klasse ChannelFactory erzeugt einen Server-Socket und startet sich dann selbst als Thread.
import java.io.*; import java.net.*; public class ChannelFactory extends Thread { public final static int DEFAULT_PORT = 4711; private int port; private BoundedBuffer buf = new BoundedBuffer(10); private ServerSocket srv = null; public ChannelFactory(int p) { if (p<=0) { port = DEFAULT_PORT; } else { port = p; } try { srv = new ServerSocket(port); this.start(); System.out.println( "server started on port "+port); } catch (IOException e) { System.out.println(e); } } public ChannelFactory() { this(DEFAULT_PORT); }
In der run()-Methode wird in einer Endlosschleife mit accept() auf das Eintreffen von Verbindungswünschen gewartet. Trifft eine Anfrage ein, wird aus dem Socket ein SocketChannel erzeugt. Dieser wird in einem Puffer buf für die weitere Verwendung gespeichert. Dann wird auf die nächste Anfrage gewartet. Die Methode getChannel() liefert dann beim Aufruf einen der gespeicherten SocketChannel. Falls noch keine Verbindung gespeichert wurde, blockiert die Methode bei buf.get().
public void run() { while (true) { try { System.out.println( "waiting for connection"); Socket s = srv.accept(); System.out.println("connection accepted"); SocketChannel c = new SocketChannel(s); buf.put( (Object) c ); } catch (IOException e) { System.out.println(e); } } } public SocketChannel getChannel() throws IOException { return (SocketChannel)buf.get(); }
Für die Client-Seite ist die Methode getChannel(String h, int p) etwas umfangreicher. Es wird versucht, mit new Socket(host,port) eine Socket-Verbindung aufzubauen. Dies wird in einer while-Schleife solange versucht, bis die Verbindung zustande kommt, d.h. bis der gewünschte Server-Teil bereit ist.
public SocketChannel getChannel(String h, int p) throws IOException { if (p<=0) { p = port; } SocketChannel c = null; System.out.println("connecting to "+h); while (c == null) { try { c = new SocketChannel( new Socket(h, p) ); } catch (IOException e) { System.out.println(e); // wait server ready System.out.println("Server on "+h+ " not ready"); try { Thread.currentThread().sleep( (int)(5000)); } catch (InterruptedException e1) { } } } System.out.println("connected"); return c; } }
Die Klasse ChannelFactory löst das Reihenfolge-Problem und das Deadlock-Problem. Das Reihenfolge-Problem wird durch Warten und Neuversuch in der Methode getChannel(String h, int p) gelöst, denn falls alle Partner irgendwann gestartet werden, kommen auch die Verbindungen zustande.
Die Lösung des Deadlock-Problems können wir wie folgt einsehen. Wir setzen voraus, daß für jeden Prozeß genau einmal der Konstruktor ChannelFactory() und für jeden Kanal genau einmal die Methode getChannel() und die Methode getChannel(String h, int p) aufgerufen wird. Dann betrachten wir die drei Programmzustände.
Die Java Remote Method Invocation Technik (RMI), erlaubt die Ausführung von Methoden auf einem entfernten Rechner. Die Eingabeparameter werden über eine Netzverbindung zu einem entfernten Rechner geschickt, die entsprechende Methode wird dort ausgeführt und anschließend wird der Rückgabewert über die Netzverbindung zurück geschickt. Java RMI ist das Analogon zu Remote Procedure Call (RPC), das in der Kommunikationstechnik schon viele Jahre eingesetzt wird.
RMI ist ein Spezialfall der allgemeinen Kommunikation. Anstelle von beliebig im Programm verteilten Sende- und Empfangsoperationen, verläuft eine RMI Operation immer nach dem gleichen Muster: senden der Eingabeparameter, warten während der Berechnung und dann empfangen des Rückgabeparameters. Durch Aufteilen der Sendeoperation auf eine Methode und der Empfangsoperation auf eine zweite Methode läßt sich sogar eine beliebige Send-Receive Kommunikationsarchitektur von einem Client zu einem Server realisieren. Wenn dann zusätzlich noch Objekte versendet werden, deren innere Zustände, zum Beispiel über setValue und getValue, verändert und festgestellt werden können, dann ist sogar der Server eingeschränkt in der Lage mit dem Client zu kommunizieren.
Einige Autoren, zum Beispiel mein Koautor in [6], sind der Auffassung, daß Socket-Kommunikation gänzlich überflüssig ist, und RMI alleine ausreichen würde. Andere Autoren, zum Beispiel [8], sehen in wissenschaftlichen Codes doch einige Anwendungsfälle, bei denen RMI alleine nicht ausreicht, oder zumindest eine viel schlechtere Performance als Socket-Kommunikation bietet. Wieder andere Autoren konzentrieren sich auf eine Optimierung der RMI- und Serialisierungs-Implementierung [7].
Die RMI Architektur wird in Abbildung 3 gezeigt. Computer 'A' bezeichnet den Rechner, von dem aus eine Methode auf dem entfernten Rechner 'B' aufgerufen werden soll. 'Client' bezeichnet den Benutzerprozess in Rechner A. 'Server' bezeichnet den Prozeß, der die aufrufbare Methode bereitstellt.
Damit die verwendbaren Methoden nicht im Programmcode auf der Server Seite fest angegeben werden müssen, wird ein Verzeichnis, genannt 'Repository', auf Rechner B geführt. Dieses Verzeichnis muß vor beginn aller RMI Aktivitäten gestartet sein. Server, die Methoden bereitstellen wollen verwenden rmi.Naming.bind() oder rmi.Naming.rebind(), um eine Klasseninstanz mit einem Namen bei dem Repository zu registrieren. Clients, die Methoden verwenden wollen sehen mit rmi.Naming.lookup() bei dem Repository nach ob ein gewünschtes Objekt vorhanden ist und verknüpfen es mit einem lokalen Objekt.
Beim Aufruf einer entfernten Methode im Client wird zunächst eine sogenannte 'Stub' Methode aktiviert. Diese sendet die Eingabeparameter (mit einer Bezeichnung der entfernten Methode) an eine 'Skeleton' genannte Methode auf dem Server. Die Skeleton Methode ruft dann auf dem Rechner B die gewünschte Methode auf und schickt den Returnwert nach der Terminierung der Methode an die Stub Methode zurück. Diese übergibt den Wert dann an die aufrufende Prozedur. Der Einsatz von Stub und Skeleton erfolgt transparent, d.h. ohne spezielle Aufrufe seitens der Programmierers.
Die wesentlichen Schritte in der Realisierung einer RMI-Kommunikation seien hier kurz zusammengefaßt.
"name",
object)
beim Repository anmelden.
"rmi://host/method" )
Mit dem neuen Java 2, aka JDK 1.2, wird ein ausgefeiltes und flexibles Sicherheitsmanagement angeboten. Damit ist der RMI Server gezwungen einen speziellen RMISecurityManager() zu installieren. Dieser greift bei RMI-Anfragen auf eine Datei mit der Spezifikation der gewünschten Sicherheitsrestriktionen zurück, um festzustellen ob ein bestimmter Client die Methode aufrufen darf oder nicht. Eine Datei die alles erlaubt sieht wie folgt aus.
grant { permission java.security.AllPermission; };
Wir haben in diesem Abschnitt die wichtigsten Java-Hilfsmittel zur Programmierung der Kommunikation zwischen Prozessen kennengelernt. Für parallele Anwendungen, die über das Client-Server-Schema hinausgehen, werden allerdings komplexere Implementierungen benötigt, die wir im nächsten Abschnitt noch kurz ansprechen.
In diesem Abschnitt besprechen wir die wesentlichen Ansätze zur Parallelisierung von Aufgaben und Algorithmen.
Bisher haben wir uns darauf beschränkt, die Konzepte und die Implementierungsmittel der Parallelen Programmierung zu diskutieren. Mit diesem Wissen wollen wir jetzt einige allgemeine Verfahren und Ansätze besprechen, wie eine gegebene Aufgabe oder ein gegebener sequentieller Algorithmus parallelisiert werden kann. Wir konzentrieren uns auf die folgenden vier Verfahren.
Für jedes Verfahren stellen wir typische Einsatzfelder vor.
Damit die Effizienz von parallelen Programmen möglichst groß wird, dürfen nicht nur viele Prozessoren zur Lösung einer Aufgabe eingesetzt werden, sondern alle Prozessoren müssen auch gleichmäßig und kontinuierlich ausgelastet werden. Ist dies der Fall, so sprechen wir auch von guter Lastbalancierung. Weitere Faktoren, die die Effizienz verringern, sind der Overhead für das Erzeugen und Starten der parallelen Programme, für die notwendige Synchronisation und für die Verzögerungen bei dem Austausch der Informationen.
Das Master-Slave-Verfahren entspricht im wesentlichen dem Verfahren, das wir bei Threads schon verwendet haben. Ein Hauptprogramm (Meister) erzeugt und beauftragt Threads (Gesellen), die bestimmte von ihm angegebene Aufgaben bearbeiten. Charakteristisch hierfür ist:
Beispiele dafür sind die bekannte Matrixmultplikation oder die Vektorsumme. Bei der Vektorsumme wird die Summation von z.B. 1000 Zahlen auf zehn Threads verteilt, so daß jeder Thread genau 100 Zahlen zu addieren hat. Der Hauptthread hat dann noch die zehn Teilsummen zu addieren, um das Endergebnis zu erhalten.
Das Master-Slave-Verfahren ist immer dann schlecht anwendbar, wenn die Aufgaben schlecht strukturiert und von unterschiedlichem Aufwand sind. In diesem Fall kann eben auch nur eine schlechte Lastbalancierung erreicht werden.
Varianten des Master-Slave-Verfahrens sind das Farming-Out-Verfahren und das Divide-and-Conquer-Verfahren. Beim Farming-Out-Verfahren werden von einem Farmer (Master) die Aufgaben an eine Farm von Prozessen (Slaves) verteilt. Die Ergebnisse werden dann vom Farmer eingesammelt (geerntet). Beim Divide-and-Conquer-Verfahren (Teile und (Be-)herrsche) werden die Aufgaben in beherrschbare Stücke geteilt (vom Master), die dann von Subprozessen (Slaves) bearbeitet werden. Dieses Verfahren wird oft auch rekursiv verwendet; das heißt, die Slaves teilen ihre Aufgaben wieder und geben sie als Master an weitere Subprozesse zur Bearbeitung.
Das Client-Server-Verfahren ist im Internet der meist verwendete Ansatz. Dabei bietet ein Server (Dienstleister) auf bekannten Kanälen Dienste an. Zum Beispiel FTP-Dienste oder auch News- oder Web-Dienste. Die Clients stellen eine Verbindung zu diesen Kanälen her und nehmen entsprechend dem Protokoll des Kanals den angebotenen Dienst in Anspruch. Dieses Verfahren wird typischerweise in folgenden Fällen eingesetzt.
Ein Beispiel ist etwa das Produzenten-Konsumenten-Problem, bei dem Produzenten oder Compute-Server eine Dienstleistung anbieten und Konsumenten diese Leistungen in Anspruch nehmen. Ein weiteres Beispiel ist Remote Method Invocation (RMI) von Java. Dabei verwenden Clients im voraus verabredete Methoden auf entfernten Servern. Die Größe der Aufgabe wird dabei erst mit Angabe der Eingabeparameter spezifiziert; auch die Rückgabewerte können beliebige Größe haben.
Das Workpile- oder Arbeitsstapel-Verfahren benutzt - wie der Name schon andeutet - einen Stapel von Aufgaben (Arbeitsaufträgen). Diese Aufgaben werden von Threads oder Prozessen entnommen, bearbeitet, und dann werden die Ergebnisse an einer geeigneten Stelle abgelegt. Dann entnimmt der Thread oder Prozeß die nächste Aufgabe, und so weiter. Das Verfahren kann zusammen mit Threads eingesetzt werden, dann kann ein Bounded Buffer als Stapel benutzt werden; oder es kann auch mit verteilten Prozessen verwendet werden, dann kann der Stapel durch einen Kanal realisiert werden. Workpile eignet sich besonders unter folgenden Voraussetzungen:
Zur Implementierung des Aufgabenstapels werden meist Bounded Buffer verwendet, es können aber auch Arrays etc. verwendet werden. Wichtig ist eine gewisse Synchronisation der Entnahme und des Einfügens von Aufgaben: Es soll die gleiche Aufgabe nicht mehrfach erzeugt werden, und eine Aufgabe soll auch nicht mehrfach entnommen werden können. Bei verteilten Prozessen übernimmt ein Prozeß meist die Verwaltung des Stapels, und die Mitarbeiter holen sich die Aufgaben von einem verabredeten Kanal.
Der Vorteil von Workpile liegt darin, daß sich auch bei wechselnder Anzahl und wechselndem Aufwand von Aufgaben eine gute Lastenverteilung erreichen läßt. Wenn zum Beispiel beim Master-Slave-Ansatz ein Thread länger für eine Aufgabe benötigt als die anderen, muß der Master auf das Ergebnis warten und die restlichen Threads beanspruchen eventuell unnötig die Prozessoren.
Durch einen zusätzlichen Booleschen Indikator kann bei Bedarf auch der Inhalt des Arbeitsstapels invalidiert werden. Zum Beispiel bei Tiefensuche in Bäumen, wobei die Suche in einem Teilbaum eine Aufgabe definiert; hier kann die Suche abgebrochen werden (global oder lokal in einem Teilbaum), falls ein Element mit den gesuchten Eigenschaften gefunden ist.
Bei der Fließband-Parallelisierung erfolgt die Aufteilung der Aufgaben durch aufteilen der sequentiellen Abarbeitung. Die Threads oder Prozesse sind auf jeweils eine sequentielle Teilaufgabe spezialisiert. Sie erhalten von vorhergehenden Threads oder Prozessen teilweise vorverarbeitete Daten, die von ihnen um eine Stufe weiterverarbeitet werden. Danach werden die Teilergebnisse an den nächsten Thread oder Prozeß weitergegeben. Pipelining ist unter folgenden Bedingungen gut einsetzbar:
Ein Beispiel für diese Methodik ist etwa die
Berechnung des Winkels zwischen zwei sehr langen Vektoren.
Die Pipeline besteht dort aus zwei Stufen, die hintereinander
ausgeführt werden mußten: Lesen der Vektoren von der
Festplatte und anschließende Bildung des Skalarprodukts.
Diese Stufen werden für jede reelle Zahl wie am
Fließband durchlaufen.
Wenn der Puffer zwischen den Stufen nur mit der Größe gewählt ist,
so arbeiten die beiden Stufen sehr gut im Gleichtakt.
Mit einem größeren Zwischenpuffer erreicht man eine bessere
Entkoppelung der Pipeline-Stufen.
Ein anderes Beispiel für Pipelining, ist das Sortieren einer Folge von ganzen Zahlen (Integer Sort). Dieses Beispiel hat den Vorteil, daß es sich auf beliebig viele Threads bzw. Prozesse verteilen läßt. Die meisten sonst gebräuchlichen Pipeline-Probleme haben nur eine begrenzte kleine Anzahl von Stufen. Zwei, wie oben in dem Vektor-Winkel Problem, oder in anderen Fällen auch einmal drei oder vier Stufen.
Wir haben gesehen, daß Java alle notwendigen Hilfmittel zur parallelen Programmierung zur Verfügung stellt: Threads, Sockets und RMI. Damit eignet sich Java sowohl zur Ausbildung als auch zur Realisierung von wissenschaftlichen Codes wie es zum Beispiel in [5,8,3] dokumentiert ist.