Threads I – Allgemeines und erstes Java

Paralleles Arbeiten und die Gründe dafür

Früher, als ich angefangen habe, war das noch einfach: Da hatte ein Computer einen Prozessor: So heißt das Ding, das das eigentliche Rechenarbeiten übernimmt, irgendwo tief drinnen im Computer. Un ein Prozessor heißt, dass eine Rechen- oder sonstige Aufgabe gleichzeitig bearbeitet werden kann. Und doch sah es auch früher so aus, als könnte der Computer zwei Sachen gleichzeitig machen: ein Lied abspielen und gleichzeitig im Textverarbeitungsprogramm die getippten Zeichen einfügen. Vom Laden und Speichern und der Mausbewegung gar nicht zu reden.

Das funktioniert so, dass der Prozessor zwischen den verschiedenen Programmen sehr schnell wechselt: Ein paar Dutzend Millisekunden darf das Musikspielprogramm ran, dann wieder die Textverarbeitung, dann wieder das Musikprogramm, und so weiter. So entsteht der Eindruck, dass diese Prozesse gleichzeitig ablaufen.

Bei meinem aktuellen Windows laufen zur Zeit um die sechzig solcher Prozesse ab. Der Taskmanager gibt einem einen Überblick darüber:

threads_prozesse

Inzwischen haben Computer aber mehr als nur einen Prozessor. Neue kommen mit vier oder acht Prozessoren, und jeder Prozessor kann gleichzeitig und unabhängig von den anderen arbeiten. (Es gibt auch Mischformen, bei denen Prozessoren nur halbwegs unabhängig von einander arbeiten, oder dem Betriebssystem vormachen, es gäbe mehr von ihnen, als eigentlich da sind. Und außerdem hat inzwischen jede Grafikkarte eigene Prozessoren.)

Das ist gut so. Denn richtig viel schneller werden die Prozessoren seit einigen Jahren nicht mehr. Wenn man die Illusion hat, dass Computer trotzdem schneller werden, dann liegt es daran, dass die Prozessorzahl erhöht wird und deshalb mehrere Dinge gleichzeitig erledigt werden können.

Aber selbst wenn man 32 Prozessoren hat, und eine reguläre Rechenaufgabe rechnen will – etwa das Berechnen der Fakultät von 200 000 – dann dauert das noch ziemlich lange, weil sich trotzdem nur ein Prozessor um diese Rechenaufgabe kümmert. Wie viele es sonst noch gibt, ist dann auch egal. (Gut, abgesehen davon, dass generell nicht so viel zwischen Prozessen umgeschaltet werden muss, wenn sie sich auf mehr Prozessoren verteilen können.)

Wenn man möchte, dass es richtig schnell geht mit dem Rechnen, dann muss man die Rechenaufgabe geschickt verteilen auf die zu Verfügung stehenden Prozessoren.

Fakultätsberechnung mit mehreren Prozessoren

Fakultät schreibt man so: n!, und das bedeutet n * (n-1) * (n-2) * (n-3) ... * 1
Als Beispiel: 12! = 12*11*10*9*8*7*6*5*4*3*2*1 = 479 001 600

Es gibt keine Abkürzung oder Formel für das Berechnen der Fakultät: Wenn ich 200000! ausrechnen möchte, muss ich wirklich all diese Multiplikationen durchführen. Das wird schnell sehr aufwendig.

Wenn ich zwei Prozessoren habe, kann ich die Arbeit aber auf beide verteilen. Um 12! zu berechnen, rechnet der eine dann
2*4*6*8*10*12
und der andere rechnet
1*3*5*7*9*11
und am Schluss werden die Ergebnisse multipliziert.

Ich kann das auch auf vier Prozessoren verteilen:
1*5*9
2*6*10
3*7*11
4*8*12

Oder sechs:
1*7
2*8
3*9
4*10
5*11
6*12

Dann geht das Rechnen insgesamt schneller, weil ich alle vorhandenen Prozessoren dazu nutze. Auf meinem Rechner dauert da berechnen von 200000! – eine ziemlich große Zahl – ohne Verteilung auf mehrere Rechenstränge 76 Sekunden, mit vier Rechensträngen 12 1/2 Sekunden, mit acht Strängen gut 10 Sekunden. (Aber natürlich laufen parallel noch viele weitere Prozesse auf meinem Rechner

Prozesse und Threads

Wenn auf einem Rechner mit einem Prozessor vier Programme laufen, dann muss immer wieder zwischen diesen vier Programmen umgeschaltet werden. Das ist aufwendig. Libre Office ist so ein Programm, Firefox ist so ein Programm, iTunes ist so ein Programm. Die laufen im Moment bei mir alle, wenn auch wahrscheinlich auf unterschiedlichen Prozessoren. Das sind sogenannte schwergewichtige Prozesse.

Und dann gibt es sogenannte leichtgewichtige Prozesse, die heißen auch: Threads. Ein Programm (wie Firefox) lässt gleichzeitig viele Threads laufen. Das Umschalten zwischen Threads ist nicht so aufwendig wie das zwischen schwergewichtigen Prozessen: Die gehören ja alle zum gleichen Programm und benutzen den gleichen Arbeitsspeicherbereich für ihre Daten, so dass dieser Bereich dann beibehalten werden kann. (Libre Office und Firefox dagegen nutzen keinen gemeinsamen Arbeitsspeicherbereich, da hat jeder seinen eigenen. Das heißt, dass beim Umschalten jeweils auch der ganze Arbeitsspeicherbereich ausgetauscht werden muss.)

Bei dem Firefox-Prozess läuft zum Beispiel jeder einzelne Tab als Thread. Das hat einen Nachteil: Wenn ein Thread fehlerhaft ist (wenn ein Tab mit einer Seite Probleme hat und hängen bleibt), muss der ganze Prozess abgebrochen werden – der ganze Firefox hängt und muss neugestartet werden. Bei Chrome dagegen läuft jeder Tab als eigener Prozess: Wenn der dann hängt, muss dieser Prozess abgebrochen werden, aber die anderen Prozesse (Tabs) bleiben unbeschädigt.

Zustände eines Threads (für den Anfang)

Threads_1

Wenn ich in einem Programm einen Thread anlege, befindet der sich erstmal im Zustand NEW. Sobald ich ihm dann sage, dass er loslegen soll, befindet er sich im Zustand RUNNABLE: er könnte laufen, wenn er Rechenzeit auf einem Prozessor kriegt. Ob und wann er wirklich zum Rechnen kommt, darauf hat er keinen Einfluss. Das Betriebssystem entscheidet, wer als nächster darf. Dazu greift es nach der einen oder anderen Strategie einen rechenbereiten Thread aus dem Pool aller RUNNABLE Threads heraus und lässt ihn ein wenig rechnen: Dann ist der Thread im Zustand RUNNING. Und wenn das Betriebssystem nach der einen oder anderen Strategie entscheidet, dass jetzt aber wieder genug ist, dann landet der Thread im Zustand RUNNABLE und wartet, bis er wieder mal dran kommt. Wahrscheinlich ist er irgendwann fertig mit seiner Aufgabe, dann ist sein Zustand TERMINATED.

Die Begriffe orientieren sich in diesem Fall an der Sprache Java; in anderen Umgebungen heißen die Zustände vielleicht anders.

Wie ein Thread in Java angelegt wird

Das geht sehr einfach. Java bringt bereits eine Klasse Thread mit, die die wichtigsten Methoden zur Verfügung stellt. Man schreibt sich jetzt einfach selber eine Klasse, die von Thread erbt.

Threads_Toilettenbenutzer

Das einzige, das unsere neue Klasse tun muss ist: die ererbte run-Methode überschreiben. Dann sieht die Klasse zum Beispiel so aus:

public class Toilettenbenutzer extends Thread {
  public void run() {
    System.out.println(getName()+" benutzt die Toilette.");
  }
}

Damit haben wir eine Klasse für jemanden, der einmal aufs Klo geht. Das ist aber langweilig. Also nehmen wir eine Klasse für jemanden, der immer wieder aufs Klo geht:

public class Toilettenbenutzer extends Thread {
  public void run() {
    while (true) {
      System.out.println(getName()+" benutzt die Toilette.");
    }
  }
}

Oder von mir aus auch 20 mal:

public class Toilettenbenutzer extends Thread {
  int anzahl = 0;
  public void run() {
    while (anzahl < 20) {
      System.out.println(getName()+" benutzt die Toilette.");
      anzahl = anzahl + 1;
    }
  }
}

Die Methode getName hat unser Toilettenbenutzer von der Klasse Thread geerbt. Wenn wir den vorgegebenen Namen ändern wollen, können wir das mit setName(String) tun.

Eine letzte Ergänzung: Damit das mit dem Toilettengehen nicht gar so schnell geht, schicken wir unseren Thread jedesmal für zufällige 0.5 bis 1.5 Sekunden schlafen:

1
2
3
4
5
6
7
8
9
10
11
12
public class Toilettenbenutzer extends Thread {
  int anzahl = 0;
  public void run() {
    while (anzahl < 20) {
      System.out.println(getName()+" benutzt die Toilette.");
      anzahl = anzahl + 1;
      try {
        sleep( (int) Math.random()*1000+500 );
      } catch (Exception e) { System.out.println(e); }
    }
  }
}

Neu ist eigentlich nur Zeile 8, aber Java möchte, dass diese ererbte Thread-Methode nur innerhalb eines try-catch-Blockes aufgerufen wird, das macht es etwas unübersichtlicher.

Ein häufiger Fehler ist es, wenn man jetzt die eben liebevoll angelegte run-Methode aufruft. Die wird dann zwar tatsächlich ausgeführt, aber nicht als Thread, sondern regulär, also nicht parallel zu anderen Arbeiten. Vielmehr muss man die start-Methode der Oberklasse Thread aufrufen, und die wiederum ruft die neue run-Methode auf – jetzt aber als Thread.

Jedenfalls haben wir jetzt einen weiteren Zustand von Threads, den des freiwilligen Schlafens:

Threads_3

Zur Wiederholung: Ruft man die Start-Methode auf, wird der Thread in den Zustand RUNNABLE versetzt, und im Zustand RUNNING wird nach und nach die run-Methode ausgeführt (mit Unterbrechungen), bis die run-Methode an ihrem Ende angekommen ist und der Thread im Zustand TERMINATED angekommen ist.

Jetzt kann man beliebig viele Objekte vom Typ Toilettenbenutzer erzeugen und starten, und alle gehen unabhängig voneinander immer wieder auf die Toilette.

Damit wir diese Objekte nicht immer wieder von Hand anlegen müssen, legen wir eine Starter-Klasse an:

public class Starter
{
    Toilettenbenutzer t0;
    Toilettenbenutzer t1;
    Toilettenbenutzer t2;
    Toilettenbenutzer t3;
 
    public Starter() {
        t0 = new Toilettenbenutzer();
        t1 = new Toilettenbenutzer();
        t2 = new Toilettenbenutzer();
        t3 = new Toilettenbenutzer();
    }
    void starten() {
        t0.start();
        t1.start();
        t2.start();
        t3.start();
    }
}

Und wenn man das dann aufruft, kommt zum Beispiel das heraus:

Thread-3 benutzt die Toilette.
Thread-2 benutzt die Toilette.
Thread-1 benutzt die Toilette.
Thread-4 benutzt die Toilette.
Thread-4 benutzt die Toilette.
Thread-3 benutzt die Toilette.
Thread-1 benutzt die Toilette.
Thread-2 benutzt die Toilette.

Schwierig wird es nur, wenn alle dieselbe Toilette benutzen sollen, und das möglichst nicht gleichzeitig, sondern nacheinander…

(Fortsetzung folgt. Und bitte gerne auf fachliche Fehler hinweisen.)

Aufgaben:

  • Legen Sie ein Projekt an, in dem Sie Kopien der Klassen Toilettenbenutzer und Starter anlegen und testen Sie diese.
  • Legen Sie ein anderes Projekt mit zwei Threads an, wobei der eine die Zahlen von 100 bis 1 ausdruckt und der andere die Zahlen von 1 bis 100, und starten Sie sie gleichzeitig.
  • Legen Sie vier Threads an, die jeweils wiederholt eine Zeile Ihrer Wahl ausdrucken, und starten Sie sie gleichzeitig.

5 Antworten auf „Threads I – Allgemeines und erstes Java“

  1. Ich würde noch auf Amdahls Gesetz hinweisen, das die Effektivität von Parallelisierung begrenzt – jedes Programm hat auch nicht parallelisierbare Teile und oft mehr, als man denkt. Dass sich an der Geschwindigkeit der Fakultätsberechnung beim Wechsel auf acht Kerne nur noch wenig tut, ist möglicherweise ein Beispiel dafür.

  2. Danke, das kann ich beides noch ergänzen oder dann als Rechercheauftrag verteilen. Gerade für Amdahl habe ich ein schönes Beispiel in einer Art informatischem Denksportbuch, vor Jahrzehnten gelesen und erst vor zehn Jahren deen Hintergrund kapiert.

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert.