4.04 Multithreading

Einleitung

Früher konnten Computer immer nur eine Anweisung nach der anderen abarbeiten. Dies entspricht auch vielen unserer Beispiele, vor allem im Einstieg ins Programmieren. Wir konnten dort zwar mit Schleifen Anweisungen wiederholen oder mit Verzweigungen verschiedene Alternativen wählen, trotzdem gab es immer nur einen Handlungsstrang, dem unsere Programme gefolgt sind.

In den meisten Programmen gibt es heutzutage aber mehrere solcher Handlungsstränge, die gleichzeitig nebeneinander laufen. Sehr deutlich wird dies, wenn wir uns ein Computerspiel vorstellen: Hier muss die grafische Darstellung berechnet werden, die Eingaben des Spielers müssen berücksichtigt werden, die Gegner des Spielers müssen reagieren etc. und all dies geschieht parallel.

In modernen Rechnern finden sich meist Prozessoren mit mehreren Kernen, so dass es plausibel erscheint, dass mehrere solche Stränge unabhängig voneinander arbeiten können. Jedoch benötigt man keineswegs für jeden Strang einen eigenen Prozessorkern. Auch Rechner mit nur einem Prozessorkern können durch schnelles Hin- und Herwechseln zwischen den Strängen eine Gleichzeitigkeit simulieren, die es in Wahrheit gar nicht gibt. Für den Benutzer ist dies aber nicht erkennbar.

Einen Handlungsstrang nennt man in der Programmierung Thread. Arbeiten wir mit mehreren solchen Threads, sprechen wir von Multithreading.

Die Grundideen des Multithreading in Java lernen wir in dieser Lektion. Wir kratzen dabei allerdings nur an der Oberfläche. Eine ausführliche Darstellung findet sich beispielsweise hier.

Threads erzeugen

Unser erster Thread

Ein Thread ist in Java ein Objekt, das wir – wie andere Objekte auch – erzeugen und in einer Variablen ablegen können. Allerdings müssen wir einem Thread auch noch mitteilen, welche Anweisungen er denn ausführen soll. Dies können wir, indem wir ihm bei seiner Erzeugung ein sogenanntes Runnable-Objekt übergeben.
Ein Runnable-Objekt wiederum ist ein Objekt einer Klasse, die das Interface Runnable implementiert. Hier ein Beispiel dazu:

public class HalloSchreiber implements Runnable {

    @Override
    public void run() {
        for (int i = 0; i < 10; i=i+1) {
            System.out.println("Hallo");
        }
    }

}

Das Interface Runnable fordert, dass die (abstrakte) Methode run() implementiert wird. Dies wird in der Klasse HalloSchreiber getan. Die Methode erhält hier die simple Aufgabe, zehnmal „Hallo“ in der Konsole auszugeben.
Nun können wir in einer anderen Klasse zum Test einen Thread erstellen und ihm als Parameter ein Objekt der Klasse HalloSchreiber übergeben:

public class ThreadDemo {

    public static void main(String[] args) {

        Thread eins = new Thread(new HalloSchreiber());

        eins.start();

    }

}

Damit der Thread startet, müssen wir noch die Methode start() aufrufen. Es wird dann tatsächlich zehnmal „Hallo“ geschrieben.

Mehrere Threads starten

Die Besonderheit des parallelen Arbeitens von Threads wird hier natürlich noch nicht deutlich. Daher erweitern wir unser Beispiel. Die Klasse HalloSchreiber soll nun so aussehen:

public class HalloSchreiber implements Runnable {

    @Override
    public void run() {
        for (int i = 0; i < 10; i = i + 1) {
            System.out.println(Thread.currentThread().getName()+" Hallo");
        }
    }

}

Der Unterschied zu vorher liegt darin, dass in der Ausgabe nun auch der Name des Threads erscheint. Diesen Namen können wir in der ausführenden Klasse festlegen:

public class ThreadDemo {

    public static void main(String[] args) {

        Thread eins = new Thread(new HalloSchreiber());
        eins.setName("Thread 1");
        Thread zwei = new Thread(new HalloSchreiber());
        zwei.setName("Thread 2");

        eins.start();
        zwei.start();
        
        System.out.println("Threads gestartet!");

    }

}

Hier werden zwei Threads erzeugt. Beide erhalten jeweils ein Objekt der Klasse HalloSchreiber und damit dieselbe Aufgabe. Mit setName geben wir beiden aber verschiedene Namen, um sie unterscheiden zu können. Außerdem zeigen wir an, dass beide Threads gestartet wurden.

Starten wir dieses Beispiel, könnte eine Ausgabe so aussehen:

Threads gestartet!
Thread 2 Hallo
Thread 2 Hallo
Thread 2 Hallo
Thread 2 Hallo
Thread 1 Hallo
Thread 2 Hallo
Thread 1 Hallo
Thread 1 Hallo
Thread 2 Hallo
Thread 1 Hallo
Thread 2 Hallo
Thread 1 Hallo
Thread 1 Hallo
Thread 1 Hallo
Thread 1 Hallo
Thread 1 Hallo
Thread 1 Hallo
Thread 2 Hallo
Thread 2 Hallo
Thread 2 Hallo

Nun werden die Besonderheiten deutlich:

  • Nach Aufruf der start()-Methode kehrt das Programm sofort zum Hauptstrang zurück. Das sehen wir daran, dass die Meldung „Threads gestartet!“ ganz am Anfang steht. Würden wir ohne Threads arbeiten und stattdessen beispielsweise Untermethoden verwenden, wäre diese Meldung ganz am Ende!
  • Es wird ständig zwischen den beiden Threads gewechselt, so dass sie uns als parallel arbeitend erscheinen.

Vorsicht Falle

An dieser Stelle sollte vor einem häufigen Fehler gewarnt werden. Man kann nämlich recht schnell die Methoden run() und start() durcheinander bringen.
Die Methode run() hat einzig den Zweck bei der Implementierung von Runnable überschrieben zu werden. Die Methode start() hingegen sorgt dafür, dass ein  Thread seine Arbeit aufnimmt.

Dies wird deutlich, wenn wir versehentlich die Methode run() statt start() verwenden:

public class ThreadDemo {

    public static void main(String[] args) {

        Thread eins = new Thread(new HalloSchreiber());
        eins.setName("Thread 1");
        Thread zwei = new Thread(new HalloSchreiber());
        zwei.setName("Thread 2");

        eins.run(); // So nicht!!!
        zwei.run(); // So nicht!!!
        
        System.out.println("Threads gestartet!");

    }

}

Wir erhalten diese Ausgabe:

main Hallo
main Hallo
main Hallo
main Hallo
main Hallo
main Hallo
main Hallo
main Hallo
main Hallo
main Hallo
main Hallo
main Hallo
main Hallo
main Hallo
main Hallo
main Hallo
main Hallo
main Hallo
main Hallo
main Hallo
Threads gestartet!

Wie wir sehen, findet hier kein Multithreading statt.

Lebende Threads, tote Threads und Daemons

Der Lebenszyklus eines Threads lässt sich wie folgt beschreiben:

  • Wird ein neues Objekt von der Klasse Thread erzeugt, so liegt noch kein neuer Thread vor. Es gibt also noch keinen weiteren Handlungsstrang.
  • Wird mit start() ein Thread gestartet, so existiert tatsächlich ein Thread, also ein neuer Handlungsstrang. Man sagt, dass der Thread nun lebendig ist. Mit der Methode isAlive() lässt sich prüfen, ob ein Thread lebendig ist.
  • Hat der Thread seine Methode run() abgearbeitet, ist er tot. Er kann dann nicht erneut gestartet werden.

Nicht jeder Thread muss sterben. Bei einem Spiel beispielsweise könnte es sinnvoll sein, gewisse Threads immer weiter laufen zu lassen. Das kann allerdings dazu führen, dass ein Programm – genauer gesagt, die JVM – nie beendet wird.

Um dies zu vermeiden, können wir sogenannte Daemon-Threads verwenden. Sehen wir uns zunächst ein Beispiel an. Dazu verwenden wir neben der Klasse HalloSchreiber von oben auch noch diese:

public class WarteSchreiber implements Runnable {

    @Override
    public void run() {
        int zaehler = 0;
        while(true){
            zaehler = zaehler + 1;
            System.out.println(Thread.currentThread().getName()+" Bitte Warten! "+zaehler);
        }
    }

}

In dieser run()-Methode finden wir eine Endlosschleife, so dass man erwarten könnte, dass ein solcher Thread nie beendet wird. Das stimmt auch, solange wir keinen Daemon-Thread verwenden.

Unsere Beispielanwendung sieht hier so aus:

public class ThreadDemo {

    public static void main(String[] args) {

        Thread eins = new Thread(new HalloSchreiber());
        eins.setName("Thread 1");
        Thread zwei = new Thread(new HalloSchreiber());
        zwei.setName("Thread 2");
        Thread drei = new Thread(new WarteSchreiber());
        drei.setName(("Thread 3"));

        drei.setDaemon(true);
        
        eins.start();
        zwei.start();
        drei.start();
        
        System.out.println("Threads gestartet!");
                
    }

}

Der dritte Thread erhält als Parameter ein Objekt der neuen Klasse Warteschreiber. Außerdem wird er zu einem Daemon-Thread erklärt. Sobald die ersten beiden Threads abgerbeitet sind, ist nur noch dieser eine Daemon-Thread lebendig. Sobald Java dies registriert, wird das Programm beendet. Eine mögliche Ausgabe sieht so aus:

Thread 2 Hallo
Thread 2 Hallo
Thread 3 Bitte Warten! 1
Threads gestartet!
Thread 1 Hallo
Thread 1 Hallo
Thread 1 Hallo
Thread 1 Hallo
Thread 1 Hallo
Thread 1 Hallo
Thread 1 Hallo
Thread 1 Hallo
Thread 1 Hallo
Thread 1 Hallo
Thread 3 Bitte Warten! 2
Thread 3 Bitte Warten! 3
Thread 3 Bitte Warten! 4
Thread 3 Bitte Warten! 5
Thread 3 Bitte Warten! 6
Thread 3 Bitte Warten! 7
Thread 3 Bitte Warten! 8
Thread 3 Bitte Warten! 9
Thread 3 Bitte Warten! 10
Thread 2 Hallo
Thread 2 Hallo
Thread 2 Hallo
Thread 2 Hallo
Thread 2 Hallo
Thread 2 Hallo
Thread 2 Hallo
Thread 2 Hallo
Thread 3 Bitte Warten! 11
Thread 3 Bitte Warten! 12
Thread 3 Bitte Warten! 13
Thread 3 Bitte Warten! 14
Thread 3 Bitte Warten! 15
Thread 3 Bitte Warten! 16
Thread 3 Bitte Warten! 17

Allgemein gilt: Sind nur noch Daemon-Threads lebendig, wird ein Programm beendet. Wir können also auch mehrere Threads als Daemon-Threads auszeichnen.

Gemeinsamer Zugriff auf Resourcen

Wenn Threads sich in die Quere kommen

Wie schnell das Thema Multithreading komplex werden kann, sehen wir schon an dem nun folgenden Problem.

Zunächst erstellen wir eine neue Klasse Addierer, die das Interface Runnable  implementiert:

public class Addierer implements Runnable {

    int[] meinArray;

    public Addierer(int[] array) {
        meinArray = array;
    }

    @Override
    public void run() {
        for (int j = 0; j < 100; j=j+1) {
            for (int k = 0; k < meinArray.length; k=k+1) {
                meinArray[k] = meinArray[k] + 1;
            }
        }
    }
}

Ein Objekt dieser Klasse erhält als Parameter ein int-Array. In der run()-Methode wird das Array 100 mal durchlaufen, wobei in jedem Durchlauf jeder Eintrag um eins erhöht wird.

Mithilfe dieser Klasse erstellen wir drei Threads:

public class ThreadDemo {

    public static void main(String[] args) throws InterruptedException {

        int[] demoArray = new int[10];
        for (int i = 0; i < demoArray.length; i=i+1) {
            demoArray[i] = 0;
        }

        Thread erster = new Thread(new Addierer(demoArray));
        Thread zweiter = new Thread(new Addierer(demoArray));
        Thread dritter = new Thread(new Addierer(demoArray));

        erster.start();
        zweiter.start();
        dritter.start();
        
        erster.join();
        zweiter.join();
        dritter.join();
        
        for (int i = 0; i < demoArray.length; i=i+1) {
            System.out.println(demoArray[i]);
        }
         
    }

}

In diesem Beispiel wird ein Array der Länge 10 angelegt. Jeder der drei Threads soll mit diesem Array arbeiten. Die join-Anweisungen sind dazu da, zu warten, bis die drei Threads beendet sind. Sie führen also die Handlungsstränge wieder zusammen. (Aufgrund dieser Anweisungen musste übrigens  throws InterruptedException ergänzt werden.)
D.h., eigentlich müsste doch jeder der Threads alle Einträge um 100 erhöhen, so dass schließlich alle Einträge des Array den Wert 300 haben, oder?

Überraschenderweise geschieht dies nicht. Ein mögliche Ausgabe sieht nämlich so aus:

297
298
297
297
297
298
297
296
299
298

Eine Ursache kann folgender Ablauf sein:
Nehmen wir an, Thread A möchte gerade den Eintrag demoArray[0] um eins erhöhen. Außerdem liegt der aktuelle Wert dieses Eintrags vielleicht gerade bei 100.
Nun liest der Thread zunächst diesen Wert aus und berechnet demoArray[0]+1, also den neuen Wert 101. Bevor er jedoch diesen Wert wieder ablegen kann, wird er von Thread B unterbrochen.
Dieser Thread B durchläuft nun einmal das Array und erhöht (vielleicht erfolgreich) alle Werte. Jedoch hat Thread B noch mit dem alten Wert 100 an der Stelle demoArray[0] gerechnet! Daher hat Thread B den Wert von demoArray[0] auf 101 erhöht.
Wenn jetzt Thread A seine Arbeit wieder aufnimmt, speichert er seinen neu berechneten Wert 101 ebenfalls in demoArray[0] ab. Dadurch geht eine Erhöhung dieses Wertes verloren.

Das einfache Erhöhen eines int-Wertes besteht also aus mehreren Schritten, die beim Multithreading unterbrochen werden können. Man sagt, diese Operation ist nicht atomar.

Auch eine Verwendung einer Anweisung der Form meinArray[k]++; ändert dies nicht, denn dabei finden ebenfalls die oben dargestellten Schritte (Auslesen, Rechnen, Speichern) statt.

Atomare Datentypen

Im Falle der Verwendung von int-Werten kann uns die Klasse AtomicInteger helfen. Diese können wir wie folgt verwenden:

import java.util.concurrent.atomic.AtomicInteger;

public class ThreadDemo {

    public static void main(String[] args) throws InterruptedException {

        AtomicInteger[] demoArray = new AtomicInteger[10];
        for (int i = 0; i < demoArray.length; i=i+1) {
            System.out.println(demoArray[i] = new AtomicInteger(0));
        }

        Thread erster = new Thread(new Addierer(demoArray));
        Thread zweiter = new Thread(new Addierer(demoArray));
        Thread dritter = new Thread(new Addierer(demoArray));

        erster.start();
        zweiter.start();
        dritter.start();

        erster.join();
        zweiter.join();
        dritter.join();

        for (int i = 0; i < demoArray.length; i = i + 1) {
            System.out.println(demoArray[i]);
        }

    }

}

Die Klasse Addierer wird ebenfalls angepasst:

import java.util.concurrent.atomic.AtomicInteger;

public class Addierer implements Runnable {

    AtomicInteger[] meinArray;

    public Addierer(AtomicInteger[] array) {
        meinArray = array;
    }

    @Override
    public void run() {
        for (int j = 0; j < 100; j=j+1) {
            for (int k = 0; k < meinArray.length; k=k+1) {
                meinArray[k].getAndIncrement();
            }
        }
    }
}

Hier ist die Methode getAndIncrement() hervorzuheben, die einen Wert ausliest und erhöht, dabei aber nicht unterbrochen werden kann!

Es gibt noch weitere Methoden wie incrementAndGet(), getAndDecrement(), decrementAndGet(), addAndGet() etc., die alle die Aufgabe erfüllen, die ihr Name jeweils suggeriert.

Für long-Werte steht die Klasse AtomicLong zur Verfügung, für boolean-Werte die Klasse AtomicBoolean. Außerdem gibt es noch eine Klasse AtomicReference für Referenzvariablen. Diese bietet die z.B. die atomare Methode getAndSet.

Ausblick

Die Verwendung von Threads ist ein recht komplexes Thema, was in dem letzten Abschnitt über atomare Datenytpen ein wenig deutlich werden sollte.

Noch komplizierter wird es, wenn mehrere Threads Operationen an einem Objekt ausführen und seinen Zustand verändern – so ähnlich wie oben einfach nur die Werte des Arrays verändert werden sollten. Mittels Synchronisation kann es nötig sein, Threads dazu zu zwingen, auf andere zu warten.

Eine solches Vorgehen wiederum birgt die Gefahr eines Deadlocks: Thread A wartet auf Thread B und der wiederum auf A…

Eine tiefergehende Behandlung würde hier den Rahmen sprengen und viel Geduld erfordern. Daher möchte ich auf ausführliche Fachliteratur wie etwas „die Insel“ verweisen.

Schreibe einen Kommentar

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