MarktundTechnik Home-Page Previous Page TOC Index Next Page See Page



18. Tag:

Multithreading

von Charles L. Perkins

Heute lernen Sie alles über Threads, die in Woche 2 kurz erwähnt wurden:

Threads sind in der Welt der Computerwissenschaft eine relativ neue Erfindung. Obwohl es Prozesse - die größeren Verwandten von Threads - schon seit Jahrzehnten gibt, werden Threads erst seit kurzem akzeptiert. Threads sind sehr nützlich. Programme, die mit Threads geschrieben wurden, sind von bemerkenswert hoher Qualität, auch für den Gelegenheitsbenutzer.

Stellen wir uns einmal vor, daß Sie eine große Datei in dem von ihnen bevorzugten Texteditor bearbeiten. Muß der Texteditor beim Starten die gesamte Datei prüfen, bevor er Sie sie bearbeiten läßt? Muß er eine Kopie der Datei anlegen? Falls die Datei riesig ist, kann das ein Alptraum sein. Wäre es nicht schöner, wenn er ihnen die erste Seite erst einmal anzeigt, damit Sie mit dem Editieren beginnen können, und dann die langsameren Aufgaben (im Hintergrund) ausführt? Genau diese Art von Parallelismus innerhalb eines Programms bieten Threads.

Das wohl beste Beispiel von Threads (oder deren Mangel) ist ein WWW-Browser. Kann Ihr Browser eine endlose Zahl von Dateien und Web-Seiten gleichzeitig herunterladen, während Sie immer noch weiter stöbern können? Kann Ihr Browser alle Bilder, Sounds usw. parallel laden, während diese Seiten heruntergeladen werden, und die schnellen und langsamen Download-Zeiten mehrerer Internet-Server handhaben? HotJava kann das alles und mehr, denn es nutzt das integrierte Threading der Java-Sprache.

Das Problem mit dem Parallelismus

Warum unterstützt nicht jedes System Threading, wenn das so phantastisch ist? Viele moderne Betriebssysteme haben die Grundvoraussetzungen, um Threads zu erstellen und auszuführen, jedoch fehlt es ihnen an einer wichtigen Komponente. Der Rest ihrer Umgebung ist nicht threadfähig. Stellen Sie sich einmal vor, Sie arbeiten in einem von vielen Threads und jeder nutzt gemeinsam wichtige Daten. Wenn Sie die Daten verwalten würden, können Sie entsprechende Schritte unternehmen, um sie zu schützen (wie Sie in der heutigen Lektion noch sehen werden). Hier werden sie aber vom System verwaltet. Nun stellen Sie sich einen Codeteil im System vor, der wichtige Werte liest, eine Weile nachdenkt und dann diesem Wert 1 hinzufügt:

if (crucialValue > 0) {

   ... // Denkt nach, was zu tun ist

   crucialValue += 1;

}

Bedenken Sie, daß jede beliebige Anzahl von Threads diesen Teil des Systems gleichzeitig aufrufen kann. Die Katastrophe tritt ein, wenn zwei Threads den if-Test ausführen, bevor einer crucialValue erhöht hat. In diesem Fall schreiben beide den Wert übereinander und eine Erhöhung geht verloren. Das mag nicht so schlimm erscheinen, kann aber den Zustand der Bildschirmanzeige beeinflussen. Eine unglückliche Anordnung von Threads kann verusachen, daß der Bildschirm nicht richtig aktualisiert wird. Auch Maus- oder Tastaturereignisse können verlorengehen, Datenbanken können fehlerhaft fortgeschrieben werden usw.

Diese Katastrophe ist unvermeidbar, wenn ein wichtiger Systemteil nicht unter Berücksichtigung von Threads geschrieben wird. Zum Glück wurde Java von Grund auf unter diesem Aspekt entwickelt. Alle Klassen der Java-Bibliothek sind threadtauglich. Sie brauchen sich also nur um die Synchronisation und die Anordnung der Threads zu kümmern.

Atomare Operationen sind Operationen, die alle gleichzeitig in mehreren Threads erfolgen.

So mancher Leser wundert sich vielleicht, woran das Problem hier eigentlich liegt. Könnte man nicht den ...-Bereich im Beispiel verkleinern, um das Problem zu vermeiden? Ohne atomare Operationen lautet die Antwort »Nein«. Auch wenn der ...-Bereich null Zeit in Anspruch nehmen würde, müßte man den Wert einer Variablen ermitteln und etwas entsprechend dieser Entscheidung ändern. Diese zwei Schritte können ohne atomare Operation nie gleichzeitig erfolgen. Sogar die eine Zeile crucialValue +=1 umfaßt drei Schritte: Den aktuellen Wert holen, Eins dazuaddieren und zurückschreiben. (Auch ++crucialValue hilft hier nicht.) Alle drei Schritte müssen gleichzeitig geschehen. Spezielle Java-Primitivtypen auf den untersten Ebenen der Sprache bieten grundlegende atomare Operationen, um sichere Programme mit Threads zu entwickeln.

Denken in Multithreading

Die Gewöhnung an Threads dauert eine Weile und erfordert eine neue Denkensweise. Anstatt immer genau zu wissen, was durch die Methoden, die man geschrieben hat, bewirkt wird, stellt man sich hier und da zusätzliche Fragen. Was passiert, wenn eine Methode gleichzeitig durch mehrere Threads aufgerufen wird? Muß sie auf irgendeine Art geschützt werden? Was ist mit der Klasse insgesamt? Läuft nur eine Methode gleichzeitig ab?

Oftmals geht man von Annahmen aus, unter denen man schließlich eine lokale Instanzvariable durcheinanderbringt. Wir machen nun absichtlich ein paar Fehler und korrigieren sie anschließend. Zuerst der einfachste Fall:

public class ThreadCounter {

   int crucialValue;

   public void countMe() {

      crucialValue += 1;

   }

   public int howMany() {

      return crucialValue;

   }

}

Dieser Code leidet an der reinsten Form von »Synchronisationsproblemen«: Das += erfordert mehr als einen Schritt, wodurch man sich in der Anzahl von Threads irren kann. (Machen Sie sich noch keine Gedanken darüber, wie Threads erstellt werden. Stellen Sie sich einfach mehrere davon vor, die countMe() gleichzeitig zu leicht versetzten Zeiten aufrufen können.) Java ermöglicht, das wie folgt zu beheben:

public class SafeThreadCounter {

   int crucialValue;

   public synchronized void countMe() {

      crucialValue += 1;

   }

   public int howMany() {

      return crucialValue;

   }

}

Das Schlüsselwort synchronized fordert Java auf, den Codeblock in der Methode threadtauglich zu machen. Dadurch ist es nur jeweils einem Thread gestattet, in diese Methode einzudringen. Die anderen müssen warten, bis der laufende Thread beendet ist, erst dann können sie beginnen. Das bedeutet, daß es nicht ratsam ist, eine große längere Methode zu synchronisieren. Alle Threads würden irgendwann an diesem Engpaß steckenbleiben und warten, bis sie bei dieser einen schwerfälligen Methode an die Reihe kommen.

Noch schlimmer ist das mit den meisten unsynchronized-Variablen. Da der Compiler diese Variablen während der Berechnungen in Registern halten kann und die Register eines Threads für die anderen Threads nicht sichtbar sind (insbesondere, wenn sie sich auf einem echten Multiprozessorrechner in einem anderen Prozessor befinden), kann eine Variable so aktualisiert werden, daß das Ergebnis durch keine mögliche Thread-Ordnung produziert werden könnte. Das ist für den Programmierer völlig unverständlich. Um einen solchen absonderlichen Fall zu vermeiden, kann eine Variable mit volatile beschriftet werden, was bedeutet, daß Sie von nun an sicher sein können, daß sie asynchron durch multiprozessorähnliche Threads aktualisiert wird. Dann wird sie von Java bei Bedarf geladen und gespeichert. Sie muß somit keine Register verwenden.

In früheren Versionen wurden Variablen, die vor diesen seltsamen Auswirkungen sicher waren, mit threadsafe bezeichnet. Da die meisten Variablen aber sicher verwendet werden können, geht man jetzt davon aus, daß sie auch threadtauglich sind, es sei denn, man kennzeichnet sie mit volatile. Die Verwendung von volatile ist sehr selten. In der Beta-Version wird volatile in der Java-Bibliothek überhaupt nicht benutzt.

Anmerkungen zu Points

Die Methode howMany() im letzten Beispiel muß nicht synchronized sein, weil sie einfach den aktuellen Wert einer Instanzvariablen ausgibt. Ein Teil weiter oben in der Aufrufkette, der den von der Methode ausgegebenen Wert benutzt, ist eventuell synchronized erforderlich. Hier ein Beispiel:

public class Point {

   private float x, y;

   public float x() {             // Muß synchronisiert sein

      return x;

   }

   public float y() {             //Ebenso

      return y;

   }

   ...// Methoden zum Setzen und Ändern von x und y

}

public class UnsafePointPrinter {

   public void print(Point p) {

      System.out.println("The point's x is " + p.x()

         + " and y is " + p.y() + ".");

   }

}

Die analogen Methoden zu howMany() sind x() und y(). Sie brauchen keine Synchronisation, weil sie lediglich die Werte von Instanzvariablen ausgeben. Der Aufrufer von x() und y() muß entscheiden, ob er sich synchronisieren muß und tut das dann auch. Die Methode print() liest zwei Werte. Das bedeutet, daß ein anderer Thread, der zwischen dem Aufruf von p.x() und p.y() läuft, den Wert von x und y, der in Point p gespeichert ist, geändert hat. Sie wissen nicht, wie viele andere Threads eine Möglichkeit haben, in diesem Point-Objekt Methoden zu erreichen und aufzurufen. Die »Multithreading-Denkensweise« bedeutet also im Grunde, vorsichtig zu sein und nicht anzunehmen, daß etwas zwischen zwei Programmteilen nicht passiert, auch wenn es zwei Teile der gleichen Zeile oder des gleichen Ausdrucks sind, wie der String+-Ausdruck in diesem Beispiel.

TryAgainPointPrinter

Sie könnten versuchen, eine sichere Version von print() dadurch zu erreichen, daß Sie das synchronized-Schlüsselwort einfügen. Statt dessen versuchen wir aber einen anderen Ansatz:

public class TryAgainPointPrinter {

   public void print(Point p) {

      float safeX, safeY;

      synchronized(this) {

         safeX = p.x();    // Diese zwei Zeilen laufen jetzt

         safeY = p.y();   // atomar ab

      }

      System.out.print("The point's x is " + safeX

            + " y is " + safeY);

   }

}

Die synchronized-Anweisung hat ein Argument, das bestimmt, welches Objekt gesperrt werden soll, um zu verhindern, daß mehr als ein Thread den in Klammern stehenden Codeblock gleichzeitig ausführt. Hier benutzen wir this (direkt die Instanz), die genau das Objekt ist, das von der synchronized-Methode gesperrt werden müßte, wenn wir print() geändert hätten, um so sicher zu sein wie die countMe()-Methode. Sie haben mit dieser neuen Form der Synchronisation etwas gewonnen: Sie können genau bestimmen, welcher Teil einer Methode sicher sein muß. Der Rest kann bleiben, wie er ist.

Beachten Sie, wie wir diese Freiheit genutzt haben, um den geschützten Teil der Methode so klein wie möglich zu halten, während die String-Erzeugung sowie die Verkettungen und Ausgaben außerhalb des »geschützten« Bereichs bleiben. Das ist sowohl guter Programmierstil als auch effizienter, da nur wenige Threads anstehen müssen, um in die geschützten Bereiche zu gelangen.

SafePointPrinter

Der kritische Leser ist angesichts des letzten Beispiels vielleicht etwas beunruhigt. Es scheint, als habe man sichergestellt, daß keiner Ihre Aufrufe auf x() und y() außer der Reihe ausführt, sondern verhindert hat, daß sich Point p ändern kann. Das trifft nicht zu, denn das Problem wurde noch nicht gelöst. Sie brauchen wirklich die volle Leistung der synchronized-Anweisung:

public class SafePointPrinter {

   public void print(Point p) {

      float safeX, safeY;

      synchronized(p) {   // Keiner kann p ändern,

         safeX = p.x();   // während diese zwei Zeilen

         safeY = p.y();   // voller Behagen atomar sind

      }

      System.out.print("The point's x is " + safeX

               + " y is " + safeY);

   }

}

Da haben wir's. Wir müssen Point p tatsächlich vor Änderungen schützen, deshalb wird es durch die synchronized-Anweisung gesperrt. Sollten nun x() und y() zusammentreffen, können beide sicher sein, die aktuellen x- und y-Werte von Point p zu erhalten, ohne daß ein anderer Thread dazwischen eine ändernde Methode aufrufen kann. Sie gehen aber immer noch davon aus, daß sich Point p selbst geschützt hat. (Sie können das bei Systemklassen immer annehmen, jedoch haben Sie diese Point-Klasse geschrieben.) Sie können das sicherstellen, indem Sie die einzige Methode, die x und y in p ändern kann, selbst schreiben:

public class Point {

   private float x, y;

   ... // Die x()- und y()-Methoden

   public synchronized void setXAndY(float newX, float newY) {

      x = newX;

      y = newY;

   }

}

Indem Sie die einzige »setzende« Methode in Point auf synchronized festlegen, stellen Sie sicher, daß ein anderer Thread, der versucht, Point p habhaft zu werden und ihn zu ändern, warten muß. Sie haben Point p mit der synchronized(p)-Anweisung gesperrt. Jeder andere Thread muß nun versuchen, den gleichen Point p über die implizite synchronized(this)-Anweisung zu sperren, die p jetzt beim Eintritt in setXAndY() ausführt. Somit haben wir endlich Thread-Sicherheit erlangt.

ReallySafePoint

Ein zusätzlicher Vorteil des synchronized-Modifiers (oder synchronized(this){...}) für Methoden ist, daß nur diese Methode (oder Codeblöcke) gleichzeitig laufen können. Sie können dieses Wissen nutzen, um zu gewährleisten, daß nur eine der wichtigen Methoden in einer Klasse zu einem bestimmten Zeitpunkt abläuft:

public class ReallySafePoint {

   private float x, y;

   public synchronized Point getUniquePoint() {

      return new Point(x, y);   // Kann ein weniger sicherer Punkt sein,

   }            // weil ihn nur der Aufrufer hat.

   public synchronized void setXAndY(float newX, float newY) {

      x = newX;

      y = newY;

   }

   public synchronized void scale(float scaleX, float scaleY) {

      x *= scaleX;

      y *= scaleY;

   

   public synchronized void add(ReallySafePoint aRSP) {

      Point p = aRSP.getUniquePoint();

      x += p.x();

      y += p.x();

   }    // Point p wird bald von GC weggeworfen und keiner hat ihn mehr gesehen

}

In diesem Beispiel werden mehrere vorher erwähnte Ideen kombiniert. Damit ein Aufrufer synhronize(p) nicht braucht, um Ihr x und y zu holen, geben Sie ihm eine synchronized-Möglichkeit, einen eindeutigen Point (ähnlich der Ausgabe mehrerer Werte) zu holen. Jede Methode, die die Instanzvariablen des Objekts ändert, ist ebenfalls synchronized, um sie zu hindern, zwischen den x- und y-Referenzen in getUniquePoint() hin- und herzurennen und etwa gleichzeitig mit einer anderen die lokalen x und y zu ändern. add() benutzt selbst getUniquePoint(), um nicht synchronized(aRSP) ausgeben zu müssen.

Derart sichere Klassen sind eher ungewöhnlich. In der Regel obliegt es dem Programmierer, die Threads voreinander zu schützen. Nur wenn Sie ganz sicher sind, daß nur einer ein bestimmtes Objekt kennt, können Sie sich entspannt zurücklehnen. Selbstverständlich können Sie so sicher sein, wenn Sie das Objekt erstellt und es niemandem gegeben haben.

Schützen einer Klassenvariablen

Nehmen wir an, Sie möchten mit einer Klassenvariablen einige Informationen aus allen Instanzen einer Klasse sammeln:

public class StaticCounter {

   private static int crucialValue;

   public synchronized void countMe() {

      crucialValue += 1;

   }

}

Ist das sicher? Schon, aber nur, wenn crucialValue eine Instanzvariable wäre. Da sie eine Klassenvariable ist und es nur eine Kopie davon für alle Instanzen gibt, könnten mehrere Threads sie durch Verwendung unterschiedlicher Instanzen der Klasse ändern. (Sie wissen ja, daß der synchronized-Modifier das Objekt this - eine Instanz - sperrt.) Zum Glück kennen Sie bereits das Werkzeug, mit dem dieses Problem gelöst werden kann:

public class StaticCounter {

   private static int crucialValue;

   public void countMe() {

      synchronized(getClass()) {   // Direkte Referenz auf StaticCounter nicht möglich

         crucialValue += 1;    // Die (gemeinsame) Klasse ist jetzt gesperrt

      }

   }

}

Der Trick ist das »Sperren« der Klasse, nicht einer ihrer Instanzen. Da sich in der Klasse eine Klassenvariable befindet, ebenso wie sich in einer Instanz eine Instanzvariable befindet, dürfte dies nicht zu sehr überraschen. Auf ähnliche Weise können Klassen globale Ressourcen bieten, auf die jede Instanz (oder eine andere Klasse) direkt mit dem Klassennamen zugreifen und mit dem gleichen Namen sperren kann. In diesem Beispiel wird crucialValue aus einer Instanz von StaticCounter benutzt. Hätte man crucialValue jedoch irgendwo im Programm public deklariert, wäre folgender Ausdruck sicher:

synchronized(new StaticCounter().getClass()) {

   StaticCounter.crucialValue += 1;

}

Die direkte Verwendung einer Variablen eines anderen Objekts zeugt von schlechtem Programmierstil. Hier wurde das lediglich der Übersichtlichkeit halber praktiziert. StaticCounter bietet normalerweise eine countMe()-ähnliche Klassenmethode, die diese schmutzige Arbeit übernimmt.

Sie können sich inzwischen gut vorstellen, wie viel Aufwand das Java-Team betrieben hat, um sich all diese Dinge für jede Klasse (und Methode!) der Java-Klassenbibliothek auszudenken.

Erstellen und Verwenden von Threads

Sie haben jetzt die Leistung (und Risiken) der Nutzung mehrerer Threads verstanden. Nun wollen Sie sicher wissen, wie diese Threads eigentlich erstellt werden.

Im System selbst laufen immer ein paar sogenannte Dämonen-Threads ab. Einer dieser Teufelchen erledigt ständig die mühsame Arbeit der Müllabfuhr im Hintergrund. Außerdem gibt es einen Benutzer-Thread, der auf Maus- und Tastaturereignisse lauert. Wenn Sie nicht aufpassen, können Sie diesen Haupt-Thread aussperren. Falls das passiert, werden keine Ereignisse an Ihr Programm geschickt und es reagiert nicht mehr. Eine gute Faustregel lautet: Wann immer etwas in einem separaten Thread realisiert werden kann, sollten Sie das auch tun. Threads sind in Java relativ leicht zu erstellen, auszuführen und zu vernichten. Deshalb gehen Sie ruhig verschwenderisch damit um.

Am Namen der Klasse java.lang.Thread haben Sie vielleicht schon erraten, daß sie zum Erstellen von Threads dient - und Sie haben richtig geraten:

public class MyFirstThread extends Thread { // a.k.a., java.lang.Thread

   public void run() {

      ...   // Etwas Nützliches anstellen

   }

}

Sie haben damit einen neuen Typ von Thread namens MyFirstThread, der etwas Nützliches macht, wenn seine run()-Methode aufgerufen wird. Selbstverständlich hat diesen Thread keiner erstellt oder seine run()-Methode aufgerufen, deshalb passiert hier im Augenblick gar nichts. Um eine Instanz Ihrer neuen Thread-Klasse tatsächlich zu erstellen und auszuführen, schreiben Sie folgendes:

MyFirstThread aMFT = new MyFirstThread();


aMFT.start(); // Ruft unsere run()-Methode auf

Was könnte einfacher sein? Sie erstellen eine neue Instanz Ihrer Thread-Klasse und fordern sie zum Starten auf. Soll der Thread stoppen, schreiben Sie das hier:

aMFT.stop();

Abgesehen davon, daß ein Thread auf start() und stop() reagiert, kann er auch vorübergehend unterbrochen und später wieder aufgenommen werden:

Thread t = new Thread();

t.suspend();

...   // Etwas Spezielles machen, während t läuft

t.resume();

Ein Thread wird automatisch unterbrochen und wiederaufgenommen, wenn er erstmals an einem synchronized-Punkt blockt und dann (wenn er an die Reihe kommt) wieder loslegt.

Die Runnable-Schnittstelle

Das ist ja alles gut und schön, wenn Sie einen Thread erstellen, bei dem Sie sich den Luxus leisten können, ihn unter die Thread-Klasse in den Einfachvererbungsbaum zu stellen. Was aber, wenn er naturgemäß unter eine andere Klasse gehört, von der er den Großteil seiner Implementierung holt? Hier kommen uns die Schnittstellen vom 16. Tag zu Hilfe:

public class MySecondThread extends ImportantClass implements Runnable {

   public void run() {

      ...   // Etwas Nützliches tun

   }

}

Durch Implementierung der Schnittstelle Runnable deklarieren Sie Ihre Absicht, einen separaten Thread auszuführen. Die Klasse Thread implementiert diese Schnittstelle, wie Sie sich angesichts der Designdiskussion vom 16. Tag bestimmt denken können. Sicher können Sie an dem Beispiel auch erkennen, daß die Schnittstelle Runnable nur eine Methode, nämlich run() spezifiziert. Wie in MyFirstThread erwarten Sie, daß jemand eine Instanz von einem Thread erstellt und irgendwie unsere run()-Methode aufruft. Das erreichen wir so:

MySecondThread aMST = new MySecondThread();

Thread   aThread = new Thread(aMST);

aThread.start();      // Ruft unsere run()-Methode indirekt auf

Erstens erstellen Sie eine Instanz von MySecondThread. Dann geben Sie diese Instanz an den Constructor weiter, so daß der neue Thread entsteht, aus dem Sie das Ziel für diesen Thread machen. Startet dieser neue Thread, ruft seine run()-Methode die run()-Methode des Ziels auf (von dem der Thread annimmt, daß es ein Objekt ist, das die Runnable-Schnittstelle implementiert). Wird start() aktiviert, ruft aThread (indirekt) unsere run()-Methode auf. Sie können aThread mit stop() stoppen. Falls Sie den Thread oder die Instanz von MySecondThread nicht explizit ansprechen müssen, bietet sich uns eine Abkürzung an:

new Thread(new MySecondThread()).start();

Wie Sie sehen, ist der Klassenname MySecondThread unglücklich gewählt worden. Er stammt nicht von Thread ab und ist auch nicht der Thread, auf den Sie start() und stop() anwenden. Wir hätten wahrscheinlich einen Namen wie MySecondThreadedClass oder ImportantRunnableClass wählen sollen.

ThreadTester

Hier folgt ein längeres Beispiel:

public class SimpleRunnable implements Runnable {

   public void run() {

      System.out.println("in thread named '"

         + Thread.currentThread().getName() + "'");

   }   // Alle weiteren Methoden, die run() aufruft, befinden sich auch in diesem Thread

}

public class ThreadTester {

   public static void main(String argv[]) {

      SimpleRunnable aSR = new SimpleRunnable();

      while (true) {

         Thread t = new Thread(aSR);

         System.out.println("new Thread() " + (t == null ?

               "fail" : "succeed") + "ed.");

         t.start();

         try { t.join(); } catch (InterruptedException ignored) { }

               // Wartet, bis der Thread seine run()-Methode beendet hat

      }

   }

}

Falls Sie sich Sorgen machen, daß nur eine Instanz der Klasse SimpleRunnable erstellt wird, sie jedoch von vielen neuen Threads benutzt wird, kann ich Sie beruhigen. Trennen Sie geistig die aSR-Instanz (und die von ihr erkannten Methoden) von den verschiedenen Ausführungs-Threads, die durch sie weitergegeben werden können. Die aSR-Methoden liefern eine Ausführungsmaske, die die erstellten Threads verwenden. Jeder einzelne weiß, wo er ausgeführt wird und wodurch er sich von den anderen Threads unterscheidet. Alle nutzen die gleiche Instanz und die gleichen Methoden gemeinsam. Deshalb muß man gut aufpassen, wenn man synchronisiert, denn verschiedene Threads können in ihren Methoden randalieren.

Die Klassenmethode currentThread() kann aufgerufen werden, um den Thread zu holen, in dem momentan eine Methode ausgeführt wird. Wäre die Klasse SimpleRunnable eine Subklasse von Thread, würden ihre Methoden die Antwort bereits kennen (sie ist diejenige, in der der Thread läuft). Da SimpleRunnable einfach die Schnittstelle Runnable implementiert und damit rechnet, daß ein anderer (main()von ThreadTester) den Thread erstellt, braucht ihre run()-Methode eine andere Möglichkeit, an diesen Thread Hand anzulegen. Die in diesem Beispiel aufgezeigte Klassenmethode funktioniert auf jeden Fall.

Mit Ihrem Wissen über Threads können Sie einige furchtbare Dinge anstellen! Wenn Sie beispielsweise den Haupt-Thread des Systems ausführen, irrtümlicherweise aber der Meinung sind, Sie befänden sich in einem anderen, und folgendes anweisen:

Thread.currentThread().stop();

hat das für ihr Programm die klägliche Folge, daß es zum Sterben verurteilt ist.

Zurück zum Beispiel, das getName() - den aktuellen Thread - aufruft, um dessen Namen zu holen (normalerweise nützlich, z. B. Thread-23), so daß der Welt mitgeteilt werden kann, in welchem Thread run() läuft. Dann gibt es noch die Verwendung der Methode join(). Wird sie an einen Thread geschickt, bedeutet das quasi: »Ich bin bereit, ewig zu warten, bis du mit deiner run()-Methode fertig bist.« Das wollen wir aber nicht. Soll demnächst etwas Wichtigeres ablaufen, können Sie nicht darauf warten, bis der mit join() belegte Thread fertig ist. Im Beispiel ist die run()-Methode kurz, so daß jede Schleife getrost warten kann, bis der jeweils vorhergehende Thread fertig ist. (Wir haben in diesem Beispiel in der Zwischenzeit, bis join() fertig ist, ohnehin nichts zu tun.) Die dadurch erzeugte Ausgabe sieht so aus:

new Thread() succeeded.

in thread named 'Thread-1'

new Thread() succeeded.

in thread named 'Thred-2'

new Thread() succeeded.

in thread named 'Thread-3'

^C

Strg+C wurde gedrückt, um das Programm abzubrechen, da es ansonsten endlos laufen würde.

NamedThreadTester

Sollen Ihre Threads bestimmte Namen tragen, können Sie diese selbst durch eine zweifache Argumentform des Thread-Constructors zuweisen:

public class namedThreadTester {

   public static void main(String argv[]) {

      SimpleRunnable aSR = new SimpleRunnable();

      for (int i = 1; true; ++i) {

         Thread t = new Thread(aSR, "" + (100 - i)

            + " threads on the wall..");

         System.out.println("new Thread() " + (t == null ?

            "fail" : "succeed") + "ed.");

         t.start();

         try { t.join(); } catch (InterruptedException ignored) { } 

      }

   }

}

Damit haben wir ein Zielobjekt wie zuvor und eine Zeichenkette (String), die den neuen Thread benennt. Die Ausgabe sieht so aus:

new Thread() succeeded.

in thread named '99 threads on the wall...'

'new Thread() succeeded.

in thread named '98 threads on the wall...'

new Thread() succeeded.

in thread named '97 threads on the wall...'

^C

Die Benennung von Threads ist eine einfache Möglichkeit, Informationen an sie weiterzugeben. Diese Informationen fließen vom Eltern-Thread zu seinem neuen Kind. Ferner ist das auch bei der Fehlerabgrenzung nützlich, so daß Sie den Thread, der einen Fehler verursacht hat, leichter identifizieren können. Man könnte Namen auch zum Gruppieren von Threads verwenden, jedoch bietet Java das standardmäßig mit der ThreadGroup-Klasse. Mit einer ThreadGroup können Sie Threads gruppieren, sie damit als Einheit manipulieren und global daran hindern, andere Threads irgendwie zu beeinflussen (hilfreich in Sicherheitsfragen).

Wissen, wann ein Thread stoppt

Wir beschäftigen uns nun mit einer anderen Version des letzten Beispiels, bei dem ein Thread erstellt und an andere Programmteile abgegeben wird. Nehmen wir an, Sie möchten wissen, wann dieser Thread stirbt, damit Sie bestimmte Reinigungsarbeiten durchführen können. Wäre SimpleRunnable eine Subklasse von Thread, könnten Sie stop() dafür verwenden. Sehen Sie sich aber einmal die Deklaration der stop()-Methode des Threads an:

public final void stop() {...}

final bedeutet hier, daß Sie diese Methode in einer Subklasse nicht überschreiben können. Jedenfalls ist SimpleRunnable keine Subklasse von Thread. Wie also können wir in diesem theoretischen Beispiel das Ableben des Threads auffangen? Die Lösung liegt in der folgenden Zauberformel:

public class SingleThreadTester {

   public static void main(String argv[]) {

      Thread t = new Thread(new SimpleRunnable());

      try {

         t.start();

         someMethodThatMightStopTheThread(t);

      } catch (ThreadDeath aTD) {

         ...       // Irgendwelche Reinigungsarbeiten ausführen

         throw aTD;   // Den Fehler erneut auswerfen

      }

   }

}

Sie verstehen den Großteil dieser Zauberformel von der gestrigen Lektion. Wenn der in diesem Beispiel erstellte Thread stirbt, wird durch throws ein Fehler der Klasse ThreadDeath ausgeworfen. Der Code greift durch catch den Fehler auf und führt die erforderliche Reinigung durch. Dann wird der Fehler durch rethrows erneut ausgeworfen, damit der Thread friedlich dahinscheiden kann. Der Reinigungscode wird nicht aufgerufen, wenn sich der Thread normal verabschiedet (indem seine run()-Methode komplett ausgeführt wird). Das soll auch so sein. Wir haben ja festgelegt, daß die Reinigung nur nötig ist, wenn stop() auf den Thread angewandt wird.

Threads können auch auf andere Weise entschlafen, z. B. durch Auswerfen von Ausnahmen mit throw, für die kein catch ausgeführt wird. In solchen Fällen wird stop() nie aufgerufen und der davorstehende Code reicht nicht. (Falls die Reinigung trotzdem stattfinden muß, auch wenn der Thread ein normales Ende nimmt, können Sie eine finally-Klausel einfügen.) Da unerwartete Ausnahmen aus dem Niemandsland auftauchen und einen Thread abmurksen können, sind Multithreading-Programme besser vorhersehbar, robuster und wartungsfreundlicher, weil sie alle ihre Ausnahmen sorgfältig auffangen.

Thread-Scheduler

Sicherlich haben Sie sich inzwischen die Frage gestellt, in welcher Reihenfolge die Threads angeordnet werden sollen und wie man diese Reihenfolge kontrollieren kann. Leider gibt es zum ersten Teil der Frage in der derzeitigen Java-Implementierung keine Antwort. Mit viel Aufwand können Sie aber den zweiten Teil der Frage lösen.

Scheduler ist die Bezeichnung des Systemteils, das die Echtzeitreihenfolge von Threads festlegt.

Preemptives oder nichtpreemptives Scheduling

Normalerweise hat jeder Scheduler die Möglichkeit, seinen Job auf zwei grundsätzlich verschiedene Arten zu handhaben: durch preemptives oder nichtpreemptives Scheduling.

Beim nichtpreemptiven Scheduling führt der Scheduler den aktuellen Thread endlos aus, so daß dem Thread explizit mitgeteilt werden muß, wann ein anderer Thread gestartet wird. Beim preemptiven Scheduling führt der Scheduler den aktuellen Thread so lange aus, bis er eine bestimmte Zeit in Sekunden verbraucht hat, dann wird der Thread »verhindert« bzw. unterbrochen (suspend()) und ein anderer Thread wird für die Dauer des nächsten Bruchteils von Sekunden ausgeführt.

Nichtpreemptives Scheduling ist sehr höflich, fragt immer um Erlaubnis, terminieren zu dürfen, und ist besonders in sehr zeitkritischen Echtzeitanwendungen nützlich, deren Unterbrechung im falschen Moment etwa ein Flugzeug zum Absturz bringen kann. Die meisten modernen Scheduler basieren auf dem preemptiven Scheduling, weil es sich abgesehen von ein paar extrem zeitkritischen Anwendungen erwiesen hat, daß das Entwickeln von Multithreading-Programmen viel einfacher ist. Einerseits wird kein Thread gezwungen, sich zu entscheiden, wann genau er die Kontrolle über einen anderen Thread an sich reißen soll. Vielmehr kann jeder Thread blind weiterrennen, wohlwissentlich, daß der Scheduler fair ist und allen Threads eine Chance gibt, sich auszutoben.

Andererseits hat sich dieser Ansatz aber nicht zur Terminierung von Threads bewährt. Der Scheduler erhält zu viel Kontrolle über die Threads. Bei ganz ausgefeilten Schedulern können Sie jedem Thread eine Priorität zuweisen. Dadurch werden einige Threads »wichtiger« als andere. Höhere Priorität bedeutet oft, daß der betreffende Thread häufiger ausgeführt wird (oder mehr Laufzeit bereitgestellt bekommt). Es bedeutet aber immer, daß er Threads mit niedrigerer Priorität unterbrechen kann, auch wenn diese die ihnen zugeteilten Zeiteinheiten noch nicht aufgebraucht haben.

Im derzeitigen Java-Release wird das Verhalten des Schedulers nicht genau spezifiziert. Threads können Prioritäten zugewiesen werden. Wird eine Wahl zwischen mehreren Threads, die alle gleichzeitig starten wollen, getroffen, gewinnt der Thread mit der höchsten Priorität. Unter den Threads, die alle die gleiche Priorität haben, ist das Verhalten aber nicht gut definiert. Das heißt, daß sich die Verhaltensweisen auf den verschiedenen Plattformen, auf denen Java derzeit läuft, unterscheiden.

Diese unvollständige bzw. schwache Spezifikation des Schedulers ist ärgerlich, soll aber meines Wissens in künftigen Releases korrigiert werden. Die feinen Einzelheiten über das Scheduling nicht zu wissen, ist noch erträglich. Jedoch nicht zu wissen, ob Threads mit gleicher Priorität explizit anfragen müssen oder zur endlosen Ausführung verdammt sind, ist nicht akzeptabel. Beispielsweise haben alle Threads, die wir bisher in den Übungen erstellt haben, die gleiche Priorität. Wir wissen also nicht, welches Scheduling-Verhalten Sie an den Tag legen!

Testen des Schedulers

Um herauszufinden, welche Art Scheduler Sie auf Ihrem System haben, versuchen Sie es damit:

public class RunnablePotato implements Runnable {

   public void run() {

      while (true)

         System.out.println(thread.currentThread().getName());

   }

}

public class PotatoThreadTester {

   public static void main(String argv[]) {

      RunnablePotato aRP = new RunnablePotato();

      new Thread(aRP, "one potato").start();

      new Thread(aRP, "two potato").start();

   }

}

Bei einem nichtpreemptiven Scheduler wird folgendes ausgegeben:

one potato

one potato

one potato

...

und so weiter ohne Ende, bis Sie das Programm abbrechen. Bei einem preemptiven Scheduler, der Zeiteinheiten vergibt, wird die Zeile one potato ein paarmal wiederholt, dann folgt two potato in der gleichen Zeilenzahl uns so weiter ... wieder von vorn:

one potato

one potato

...

one potato

two potato

two potato

...

two potato

...

two potato

...

bis Sie das Programm unterbrechen. Was nun, wenn wir sicher sein wollen, daß sich die zwei Threads abwechseln, gleichgültig, was der Scheduler im Sinn hat? Wir schreiben RunnablePotato um:

public class RunnablePotato implements Runnable {

   public void run() {

      while (true) {

         System.out.println(thread.currentThread().getName());

         Thread.yield();   // Läßt einen anderen Thread eine Weile laufen

      }

   }

}

Normalerweise muß man Thread.currentThread().yield() deklarieren, um an den aktiven Thread Hand anlegen zu können, dann ruft man yield() auf. Da dieses Muster üblich ist, bietet die Thread-Klasse eine Kurzform.

Die yield()-Methode gibt laufwilligen Threads eine Chance, zu starten. (Falls keine Threads an den Startlöchern stehen, fährt der Thread, der yield() erteilt hat, einfach fort.) In unserem Beispiel lauert kein anderer Thread darauf, endlich laufen zu dürfen. Wenn Sie also die Klasse ThreadTester ausführen, sollten Sie folgende Ausgabe erhalten:

one potato

two potato

one potato

two potato

one potato

two potato

...

auch wenn Ihr System-Scheduler nichtpreemptiv arbeitet und daher von sich aus den zweiten Thread nie ausführen würde.

PriorityThreadTester

Mit folgendem Code können Sie feststellen, ob auf Ihrem System Prioritäten anwendbar sind:

public class PriorityThreadTester {

   public static void main(String argv[]) {

      RunnablePotato aRP = new RunnablePotato();

      Thread t1 = new Thread(aRP, "one potato");

      Thread t2 = new Thread(aRP, "two potato");

      t2.setPriority(t1.getPriority() + 1);

      t1.start();

      t2.start();   // Bei Priorität Thread.NORM_PRIORITY + 1

   }

}

Die Werte stellen die niedrigste, normale und höchste Priorität dar, die Threads zugewiesen werden können. Sie sind in Klassenvariablen der Thread-Klasse gespeichert: Thread.MIN_PRIORITY, Thread.-NORM_PRIORITY und Thread.MAX_PRIORITY. Das System weist neuen Threads standardmäßig die Priorität Thread.NORM_PRIORITY zu. In Java sind derzeit Prioritäten im Bereich von 1 bis 10 definiert. 5 entspricht normal. Verlassen Sie sich aber nicht auf diese Werte. Verwenden Sie die Klassenvariablen oder Tricks wie in diesem Beispiel.

Ist one potato die erste Ausgabezeile, vergibt das System keine Rechte anhand von Prioritäten. Warum? Nehmen wir an, der erste Thread (t1) hat gerade begonnen. Bevor er Gelegenheit hat, etwas auszugeben, drängt sich Thread t2 vor, weil er eine höhere Priorität hat. Dieser Thread sollte den ersten unterbrechen und two potato ausgeben, während t1 noch nicht mit seiner Ausgabe angefangen hat. Verwenden Sie die RunnablePotato-Klasse, die kein yield() hat, muß t2 die Kontrolle nie abgeben. Wenn Sie die neueste RunnablePotato-Klasse (mit yield()) verwenden, besteht die Ausgabe aus abwechselnden Zeilen von one potato und two potato wie zuvor, beginnt aber mit two potato.

Wie sich komplexe Threads verhalten, wird an folgendem Beispiel deutlich:

public class ComplexThread extends Thread {

   private int delay;

   ComplexThread(String name, float seconds) {

      super(name);

      delay = (int) seconds * 1000; // Verzögerungen in Millisekunden

      start();         // Mach' dich für den Start bereit!

   }

   public void run() {

      while (true) {

      System.out.println(Thread.currentThread().getName());

      try {

         Thread.sleep(delay);

      } catch (InterruptedException ignored) {

         return;

      }

   }

}

public static void main(String argv[]) {

   new ComplexThread("one potato", 1.1F);

   new ComplexThread("two potato", 0,3F);

   new ComplexThread("three potato", 0.5F);

   new ComplexThread("four", 0.7F);

   }

}

In diesem Beispiel wird der Thread und sein Tester in einer Klasse zusammengefaßt. Der Constructor kümmert sich um die Benennung (seiner selbst) und den Start (seiner selbst), weil er jetzt ein Thread ist. Die main()-Methode erstellt neue Instanzen ihrer eigenen Klasse, weil diese Klasse eine Subklasse von Thread ist. Auch run() ist komplizierter, weil es jetzt erstmals eine Methode nutzt, die eine unerwartete Ausnahme auswerfen kann.

Die Thread.sleep()-Methode zwingt den aktiven Thread, mit yield() die Kontrolle anzufordern, und wartet dann für die Dauer der spezifizierten Zeit, bis sie den Thread wieder laufen läßt. Er kann allerdings während seiner Wartezeit von einem anderen Thread unterbrochen werden. In einem solchen Fall wirft er eine InterruptedException aus. Da run() nicht zum Auswerfen dieser Ausnahme definierte wurde, muß dies durch catch »verborgen« werden. Da Unterbrechungen normalerweise Anfragen zum Stoppen sind, sollten Sie den Thread beenden, was Sie tun, indem Sie einfach von der run()-Methode fortfahren.

Dieses Programm gibt ein wiederkehrendes komplexes Muster mit vier verschiedenen Zeilen aus, wobei ab und zu folgendes erscheint:

...

one potato

two potato

three potato

four

...

Studieren Sie dieses Ausgabemuster genauer. Es beweist, daß in Java-Programmen echter Parallelismus abläuft. Sie können anhand des komplexen Verhaltens, das diese einfachen vier Threads aufweisen, sicherlich einschätzen, daß viele Threads zu Chaos führen, wenn sie nicht sorgfältig kontrolliert werden. Zum Glück bietet Java Synchronisations- und Thread-Handhabungsverfahren, mit denen man dieses Chaos in den Griff bekommt.

Zusammenfassung

Heute haben Sie gelernt, daß Parallelismus wünschenswert und leistungsstark ist, jedoch viele neue Probleme aufwirft - Methoden und Variablen müssen vor Thread-Konflikten geschützt werden. Ohne sorgfältige Kontrolle entsteht dadurch leicht Chaos.

Durch eine »Multithreading-Denkensweise« können Sie Stellen in Ihren Programmen erkennen, die synchronized-Anweisungen (bzw. Modifier) benötigen, um sie threadbeständig zu machen. Eine Reihe von Point-Beispielen haben in dieser Lektion die verschiedenen Sicherheitsebenen verdeutlicht. Ferner wurde gezeigt, wie Subklassen von Thread oder Klassen, die die Runnable-Schnittstelle implementieren, sowie run() behandelt werden, um Multithreading-Programme zu entwickeln.

Sie haben auch gelernt, wie Sie Ihre Threads mit yield(), start(), stop(), suspend() und resume() behandeln und ThreadDeath jederzeit in den Griff bekommen können.

Sie haben in dieser Lektion preemptives und nichtpreemptives Scheduling mit und ohne Prioritäten gelernt und wissen jetzt außerdem, wie Sie Ihr Java-System testen müssen, um zu ermitteln, welchen Scheduler es nutzt.

Damit ist die Beschreibung von Threads beendet. Sie wissen nun genug, um auch komplexe Programme, d. h. Multithreading-Programme zu schreiben. Je mehr Erfahrung Sie mit Threads erlangen, um so leichter wird Ihnen die Benutzung der ThreadGroup-Klasse oder der Auflistungsmethoden von Thread fallen. Scheuen Sie nicht davor zurück, ausgiebig mit Threads zu experimentieren. Sie können dadurch nichts kaputtmachen, lernen aber viel dabei.


Fragen und Antworten

F: Warum werden Threads nicht im ganzen Buch verwendet, wenn sie angeblich in Java so wichtig sind?

A: Sie werden überall verwendet. Jedes Einzelprogramm, das bisher geschrieben wurde, »erzeugt« mindestens einen Thread, denjenigen, in dem es läuft. (Das System erstellt Thread automatisch!)

F: Wie werden diese Threads erstellt und ausgeführt? Und was ist mit Applets?

A: Wird ein einfaches Java-Programm gestartet, erstellt das System einen Haupt-Thread. Dessen run()-Methode ruft Ihre main()-Methode auf, um das Programm zu starten. Sie tun nichts dazu, um diesen Thread zu holen. Wird ein einfaches Applet in einem javakundigen Browser geladen, wird vom Browser automatisch ein Thread erstellt, und seine run()-Methode ruft Ihre init()- und start()-Methoden auf, um Ihr Programm zu starten. In beiden Fällen wird irgendwo von der Java-Umgebung ein neuer Thread() der einen oder anderen Art erstellt.

F: Die ThreadTester-Klasse hat eine endlose Schleife, die Threads erstellt und sie dann mit join() verbindet. Ist das wirklich endlos?

A: Theoretisch schon. In der Praxis bestimmen die verfügbaren Ressourcen des Thread-Pakets und der Papierkorb Ihres Java-Releases, wie lange die Schleife ausgeführt wird. Mit der Zeit werden Schleifen aber in allen Java-Releases wirklich endlos.

F: Ich kenne Java-Versionen, die das Scheduler-Verhalten auf seltsame Weise regeln. Wie sieht's derzeit damit aus?

A: Wir haben von Arthur van Hoff von Sun zum Beta-Release folgende Informationen erhalten: ».. hängt von der Plattform ab. Es ist normalerweise preemptive... Je nach der zugrundeliegenden Implementierung werden Prioritäten nicht immer eingehalten.« Das gibt Ihnen einen Hinweis darüber, daß das gesamte Problem auf die Implementierung zurückzuführen ist. Irgendwann, so dürfen wir hoffen, ist sowohl das Design als auch die Implementierung in bezug auf das Scheduler-Verhalten eindeutig.

F: Unterstützt Java auch komplexe Multithreading-Konzepte, beispielsweise Semaphern?

A: Die Object-Klasse in Java bietet Methoden, die zum Einrichten von Bedingungsvariablen, Semaphern und anderen höheren parallelen Gebilden benutzt werden können. Die Methode wait() (und ihre zwei Varianten mit einem Timeout) veranlassen den aktiven Thread, so lange zu warten, bis eine Bedingung erfüllt ist. Die Methode notify() (bzw. notifyAll()), die innerhalb einer synchronized-Methode aufgerufen werden muß, weckt den aktiven bzw. alle Threads auf, um die Bedingung erneut zu prüfen, weil sich etwas geändert hat. Durch sorgfältige Kombination dieser zwei primitiven Methoden kann jede Datenstruktur sicher von mehreren Threads manipuliert werden, und alle klassischen parallelen Primitivtypen, die zum Implementieren paralleler Algorithmen erforderlich sind, können entwickelt werden.

F: Ich wurde vor etwas namens »Deadlock« gewarnt. Was hat es damit auf sich?

A: Bei einfachen Multithreading-Programmen braucht Sie das nicht zu beunruhigen. Bei komplizierteren Programmen müssen Situationen vermieden werden, in denen ein Thread ein Objekt gesperrt hat und auf die Beendigung eines anderen Threads wartet, während dieser Thread darauf wartet, bis der erste Thread das gleiche Objekt freigibt. Das ist dieser sogenannte Deadlock, der beide Threads für immer blockieren kann. Solche gegenseitigen Abhängigkeiten von Threads können tatsächlich zu Schwierigkeiten führen. Das ist eine der größten Herausforderungen beim Schreiben komplexer Multithreading-Programme.


Copyright ©1996 Markt&Technik
Buch- und Software- Verlag GmbH
Alle Rechte vorbehalten. All rights reserved.

Schreiben Sie uns!

Previous Page TOC Index Next Page See Page