Zum Inhalt
wannacode

Threads

Kurz gesagt: Ein Thread ist ein eigener Ausführungsstrang im Programm; mehrere Threads laufen gleichzeitig und teilen sich den Speicher, deshalb wird ein gemeinsamer Zähler mit synchronized geschützt.

Problem

Drei Berichte, einer nach dem anderen

Das Programm lädt drei Monatsberichte vom Server herunter. Der Download ist hier eine Pause: Thread.sleep(ms) hält das Programm für so viele Millisekunden an. Die Pause kann von außen unterbrochen werden und wirft dann die geprüfte InterruptedException, deshalb fängt download sie.
Main.java
static void download(String file, int ms) {
try {
Thread.sleep(ms);
} catch (InterruptedException e) {
return;
}
System.out.println("Heruntergeladen: " + file);
}
public static void main(String[] args) {
download("maerz.pdf", 600);
download("april.pdf", 200);
download("mai.pdf", 400);
System.out.println("Fertig");
}
Ausgabe
Heruntergeladen: maerz.pdf
Heruntergeladen: april.pdf
Heruntergeladen: mai.pdf
Fertig
Die Pausen sind 0,6, 0,2 und 0,4 Sekunden, zusammen 1,2 Sekunden. Die ganze Zeit wartet das Programm nur auf das Netzwerk. Gäbe es einen Button „Abbrechen“, würde er nicht reagieren. Und die drei Berichte hängen nicht voneinander ab, sie könnten also gut gleichzeitig heruntergeladen werden.

Die Idee

Prozess und Thread

Wenn du das Programm startest, gibt ihm das Betriebssystem einen Prozess: eigenen Speicher, eigene geöffnete Dateien. Im Prozess laufen ein oder mehrere Threads. Ein Thread ist eine Folge von Befehlen, die nacheinander ausgeführt werden. Bisher hatte jedes deiner Programme einen Thread: den, der main ausführt.

Die Threads eines Prozesses teilen sich ihren Speicher: Sie sehen dieselben Objekte. Das macht sie praktisch (sie können einander leicht Daten geben) und gefährlich (sie können einander durcheinanderbringen, wie du gleich sehen wirst).

  • Parallel heißt wirklich gleichzeitig, auf verschiedenen Kernen des Prozessors.
  • Nebenläufig (concurrent) heißt auf einem Kern, abwechselnd und so schnell, dass es gleichzeitig aussieht.

Für dich als Programmierer spielt der Unterschied selten eine Rolle: In beiden Fällen weißt du nicht, welcher Thread wann weitermacht.

Genauer, für Neugierige
  • Der Browser Chrome startet für jeden Tab einen eigenen Prozess. Deshalb hält eine hängende Seite die anderen nicht auf, und deshalb braucht der Browser auch viel Speicher.
  • Threads benutzt jedes Programm, das mehrere Dinge auf einmal tut: Ein Spiel spielt den Ton in einem Thread ab und führt die Logik in einem anderen aus; ein Webserver bedient jeden Client in seinem eigenen Thread.
  • Der Preis: Jeder Thread hat seinen eigenen Stack, und der Speicher dafür wird extra reserviert. Tausende Threads kosten viel. Deshalb nehmen echte Programme einen Thread-Pool (die letzte Idee der Lektion).

Sieh es dir an

Der erste Thread

Die Klasse Thread ist ein Thread. Ihrem Konstruktor gibst du mit, was er tun soll: ein Objekt des Interfaces Runnable, das eine Methode hat, run(). Am kürzesten geht es mit einem Lambda:

Thread worker = new Thread(() -> download("april.pdf", 200));
worker.start();
System.out.println("main wartet nicht");
worker.join();
System.out.println("Der Bericht ist heruntergeladen");
Ausgabe
main wartet nicht
Heruntergeladen: april.pdf
Der Bericht ist heruntergeladen
  • start() startet den neuen Thread und kehrt sofort zurück. main macht weiter, ohne zu warten, deshalb steht „main wartet nicht“ zuerst da.
  • join() lässt main warten, bis der Thread fertig ist.

download ist die Methode aus der ersten Karte. Gut, dass sie die InterruptedException selbst fängt: run() in Runnable hat kein throws, also kann der Code im Thread die geprüfte Exception nicht nach außen lassen. Auch join() wirft sie, deshalb ist main ab hier public static void main(String[] args) throws InterruptedException.

Genauer, für Neugierige
Das Lambda ist der kürzeste Weg, aber nicht der einzige. In älterem Code begegnen dir die anderen drei:
class ReportThread extends Thread {
@Override
public void run() {
System.out.println("1. eine Unterklasse von Thread");
}
}
class ReportJob implements Runnable {
@Override
public void run() {
System.out.println("2. eine Klasse, die Runnable implementiert");
}
}
public static void main(String[] args) throws InterruptedException {
Thread a = new ReportThread();
Thread b = new Thread(new ReportJob());
Thread c = new Thread(new Runnable() {
@Override
public void run() {
System.out.println("3. eine anonyme Klasse");
}
});
Thread d = new Thread(() -> System.out.println("4. ein Lambda"));
for (Thread t : new Thread[] { a, b, c, d }) {
t.start();
t.join();
}
}
Ausgabe
1. eine Unterklasse von Thread
2. eine Klasse, die Runnable implementiert
3. eine anonyme Klasse
4. ein Lambda
Eine Unterklasse von Thread ist der älteste Weg. Flexibler ist es, die Aufgabe (Runnable) vom Thread zu trennen: So kann dieselbe Aufgabe an einen Thread-Pool gehen, und deine Klasse kann weiter von einer anderen Klasse erben.

Sieh es dir an

Drei auf einmal

Jetzt die drei Berichte, jeder in seinem eigenen Thread:
long start = System.currentTimeMillis();
Thread march = new Thread(() -> download("maerz.pdf", 600));
Thread april = new Thread(() -> download("april.pdf", 200));
Thread may = new Thread(() -> download("mai.pdf", 400));
march.start();
april.start();
may.start();
march.join();
april.join();
may.join();
long ms = System.currentTimeMillis() - start;
System.out.println("Fertig in unter einer Sekunde: " + (ms < 1000));
Ausgabe
Heruntergeladen: april.pdf
Heruntergeladen: mai.pdf
Heruntergeladen: maerz.pdf
Fertig in unter einer Sekunde: true

Die Threads starten in der Reihenfolge März, April, Mai, werden aber in der Reihenfolge der Pausen fertig: April (0,2 s), Mai (0,4 s), März (0,6 s). Das ganze Warten dauert so lange wie das längste, 0,6 Sekunden statt 1,2.

In welcher Reihenfolge die Threads bei println ankommen, entscheidet das Betriebssystem. Hier sind die Pausen unterschiedlich genug, also ist die Reihenfolge immer gleich. Wären sie gleich lang, könnte sie bei jedem Lauf anders sein.

Probier es aus

start oder run

Was gibt das Programm aus? (das zweite Argument von Thread ist der Name des Threads)

Thread t = new Thread(() -> System.out.println("Läuft in: " + Thread.currentThread().getName()), "downloader");
t.run();
t.start();
t.join();
Antwort

Die Idee

Das Leben eines Threads

Ein Thread durchläuft mehrere Zustände, und getState() sagt dir, in welchem er ist:
Thread t = new Thread(() -> download("mai.pdf", 500));
System.out.println(t.getState());
t.start();
Thread.sleep(100);
System.out.println(t.getState());
t.join();
System.out.println(t.getState());
Ausgabe
NEW
TIMED_WAITING
Heruntergeladen: mai.pdf
TERMINATED
Zustand Wann
NEW erzeugt, noch nicht mit start() gestartet
RUNNABLE läuft oder ist bereit zu laufen
BLOCKED, WAITING, TIMED_WAITING wartet: auf einen Lock, auf einen anderen Thread (join) oder eine bestimmte Zeit (sleep)
TERMINATED run() ist fertig

Ein Thread kann nur einmal gestartet werden. Ein zweites start() auf demselben Thread wirft IllegalThreadStateException.

Genauer, für Neugierige

Weitere Methoden von Thread:

  • interrupt() bittet den Thread, aufzuhören. Schläft oder wartet er, wirft sleep/join eine InterruptedException. Deshalb fangen wir sie in download.
  • getName(), threadId() (früher getId()): Name und Nummer des Threads. Thread.currentThread() gibt dir den Thread, in dem der Code gerade läuft.
  • Thread.yield() deutet an, dass der Thread den Prozessor einem anderen überlassen kann. Das brauchst du selten.

Problem

Die verlorenen Besuche

Die Website zählt ihre Besuche. Zwei Threads bedienen Besucher, und jeder ruft 100 000-mal increment() auf:
Main.java
class Counter {
private int count = 0;
public void increment() {
count++;
}
public int getCount() {
return count;
}
}
public static void main(String[] args) throws InterruptedException {
Counter visits = new Counter();
Runnable work = () -> {
for (int i = 0; i < 100_000; i++) {
visits.increment();
}
};
Thread first = new Thread(work);
Thread second = new Thread(work);
first.start();
second.start();
first.join();
second.join();
System.out.println("Besuche: " + visits.getCount());
}

Du erwartest 200 000. Fünf Läufe:

Ausgabe (jedes Mal anders)
Besuche: 150329
Besuche: 138149
Besuche: 168862
Besuche: 165672
Besuche: 157704

Jedes Mal weniger, und jedes Mal anders. Der Grund ist, dass count++ kein einzelner Schritt ist, sondern drei:

int value = count; // 1. lesen
value = value + 1; // 2. 1 addieren
count = value; // 3. schreiben

class Counter {
private int count = 5;
public void increment() {
count++; // lesen, 1 addieren, schreiben
}
public synchronized void safeIncrement() {
count++;
}
public synchronized int getCount() {
return count;
}
}
Schritt für Schritt, in Worten
  1. count ist 5. Die Threads A und B rufen gleichzeitig increment() auf.
  2. A liest count: 5.
  3. B liest count, bevor A geschrieben hat: auch 5.
  4. A addiert 1 in seiner eigenen Kopie und schreibt 6.
  5. B schreibt auch 6. Zwei Aufrufe, und count ist 6, nicht 7: Eine der Additionen geht verloren.
  6. Noch einmal, jetzt mit synchronized: A nimmt den Lock, liest 5 und schreibt 6.
  7. B erreicht die Methode und wartet, bis A den Lock freigibt.
  8. B nimmt den Lock, liest 6 und schreibt 7: Nichts geht verloren.

Wenn beide Threads denselben Wert lesen, bevor einer von ihnen geschrieben hat, geht eine der Additionen verloren. Das nennt man eine Race Condition (Wettlaufsituation). Ob sie eintritt, hängt davon ab, wann das Betriebssystem die Threads wechselt, deshalb ist das Ergebnis bei jedem Lauf anders.

Die Idee

synchronized: einer nach dem anderen

Die Lösung ist, dass immer nur ein Thread diese drei Schritte macht. Das Wort synchronized vor der Methode tut genau das:
class Counter {
private int count = 0;
public synchronized void increment() {
count++;
}
public synchronized int getCount() {
return count;
}
}
Ausgabe
Besuche: 200000

Jedes Objekt hat einen Lock. Der Thread nimmt ihn, bevor er eine synchronized-Methode betritt, und gibt ihn frei, wenn er sie verlässt. Der andere Thread, der dasselbe Objekt will, wartet. Jetzt ist das Ergebnis bei jedem Lauf 200 000. Auch getCount() ist synchronized: So sieht der Thread, der liest, den zuletzt geschriebenen Wert.

Musst du nur einen Teil einer Methode sperren, gibt es einen synchronized-Block mit einem Objekt für den Lock. Für einen Zähler gibt es auch eine fertige Klasse: AtomicInteger (aus java.util.concurrent.atomic), deren incrementAndGet() die drei Schritte auf einmal macht, ohne Lock:

class Counter {
private int count = 0;
private final Object lock = new Object();
public void increment() {
synchronized (lock) {
count++;
}
}
public int getCount() {
synchronized (lock) {
return count;
}
}
}
class Counter {
private final AtomicInteger count = new AtomicInteger();
public void increment() {
count.incrementAndGet();
}
public int getCount() {
return count.get();
}
}
Ausgabe
Besuche: 200000
Genauer, für Neugierige
  • wait() und notifyAll(): Ein Thread in einer synchronized-Methode kann den Lock freigeben und schlafen, bis ein anderer Thread ihn weckt. So wartet die Küche auf eine Bestellung, und die Kasse wartet, bis die Küche die vorige genommen hat (das Problem „Producer und Consumer“):
class OrderBox {
private String order;
public synchronized void put(String newOrder) throws InterruptedException {
while (order != null) {
wait();
}
order = newOrder;
notifyAll();
}
public synchronized String take() throws InterruptedException {
while (order == null) {
wait();
}
String result = order;
order = null;
notifyAll();
return result;
}
}
public static void main(String[] args) throws InterruptedException {
OrderBox box = new OrderBox();
Thread kitchen = new Thread(() -> {
try {
for (int i = 0; i < 3; i++) {
System.out.println("Die Küche nimmt " + box.take());
}
} catch (InterruptedException e) {
return;
}
});
kitchen.start();
box.put("Pizza");
box.put("Salat");
box.put("Suppe");
kitchen.join();
}
Ausgabe
Die Küche nimmt Pizza
Die Küche nimmt Salat
Die Küche nimmt Suppe

wait() steht in einem while, nicht in einem if: Der Thread kann auch ohne Grund aufwachen, deshalb prüft er die Bedingung noch einmal.

  • Deadlock (Verklemmung): Zwei Threads warten für immer aufeinander. Das Büro hält den Drucker und wartet auf den Scanner, und der Empfang hält den Scanner und wartet auf den Drucker (pause ist sleep mit gefangener Exception):
Object printer = new Object();
Object scanner = new Object();
Thread office = new Thread(() -> {
synchronized (printer) {
pause(100);
synchronized (scanner) { System.out.println("Das Büro kopiert"); }
}
});
Thread reception = new Thread(() -> {
synchronized (scanner) {
pause(100);
synchronized (printer) { System.out.println("Der Empfang kopiert"); }
}
});

Das Programm hängt und gibt nie etwas aus. Vermeiden lässt sich das, indem alle Threads die Locks in derselben Reihenfolge nehmen.

  • Prioritäten: setPriority von 1 bis 10 deutet an, wie wichtig der Thread ist. Das Betriebssystem kann es ignorieren, also verlass dich nicht auf Prioritäten, damit das Programm richtig ist:
Thread report = new Thread(() -> download("jahresbericht.pdf", 100));
report.setPriority(Thread.MIN_PRIORITY); // 1; NORM_PRIORITY ist 5, MAX_PRIORITY ist 10
System.out.println(report.getName() + ", priority " + report.getPriority());

Probier es aus

Zwei Geldautomaten

Zwei Personen heben im selben Moment je 80 € von einem Konto mit 100 € ab. Manchmal bekommen beide das Geld, und der Kontostand wird -60 €. In welcher Zeile steckt der Fehler?

Wähle die Zeile mit dem Fehler.

Die Idee

Thread-Pool und Future

Für jede Aufgabe einen Thread zu erzeugen ist teuer, und bei tausend Aufgaben auch gefährlich. Ein Thread-Pool besteht aus ein paar Threads, die sich Aufgaben aus einer Warteschlange nehmen. In Java ist das ExecutorService (aus java.util.concurrent):
static int sizeOf(String file, int ms) {
try {
Thread.sleep(ms);
} catch (InterruptedException e) {
return -1;
}
return file.length() * 100;
}
String[] files = { "maerz.pdf", "april.pdf", "mai.pdf", "juni.pdf" };
ExecutorService pool = Executors.newFixedThreadPool(2);
List<Future<Integer>> sizes = new ArrayList<>();
for (String file : files) {
sizes.add(pool.submit(() -> sizeOf(file, 300)));
}
for (int i = 0; i < files.length; i++) {
System.out.println(files[i] + ": " + sizes.get(i).get() + " KB");
}
pool.shutdown();
Ausgabe
maerz.pdf: 900 KB
april.pdf: 900 KB
mai.pdf: 700 KB
juni.pdf: 800 KB
  • Executors.newFixedThreadPool(2) erzeugt einen Pool aus 2 Threads. Die vier Aufgaben wechseln sich darin ab.
  • submit nimmt eine Aufgabe, die ein Ergebnis zurückgibt, und gibt dir sofort ein Future: das Versprechen, dass das Ergebnis kommt.
  • get() wartet, bis die Aufgabe fertig ist, und gibt das Ergebnis zurück.
  • shutdown() sagt dem Pool, keine neuen Aufgaben mehr anzunehmen und die Threads zu beenden, wenn sie fertig sind. Ohne das endet das Programm nicht: Die Threads des Pools warten auf weitere Arbeit.

main hat hier throws Exception, weil get() auch eine ExecutionException wirft, wenn die Aufgabe eine Exception geworfen hat.

Genauer, für Neugierige
Gibt die Aufgabe nichts zurück, gibt es execute. Dann wartest du nach shutdown mit awaitTermination auf alle:
ExecutorService pool = Executors.newFixedThreadPool(2);
for (int i = 1; i <= 5; i++) {
String file = "bericht-" + i + ".pdf";
pool.execute(() -> download(file, 100));
}
pool.shutdown();
pool.awaitTermination(1, TimeUnit.MINUTES);
System.out.println("Alles fertig.");
Ausgabe eines Laufs
Heruntergeladen: bericht-2.pdf
Heruntergeladen: bericht-1.pdf
Heruntergeladen: bericht-3.pdf
Heruntergeladen: bericht-4.pdf
Heruntergeladen: bericht-5.pdf
Alles fertig.
Die ersten zwei Berichte laufen gleichzeitig, deshalb ändert sich ihre Reihenfolge von Lauf zu Lauf. In alten Beispielen siehst du while (!executor.isTerminated()) { }. Das wartet auch, lässt den Prozessor aber umsonst rotieren. awaitTermination schläft, bis der Pool fertig ist.

Probier es aus

Welches Ergebnis kommt zuerst?

„archiv.zip“ braucht 0,6 s zum Herunterladen, „notiz.txt“ 0,1 s. Was gibt das Programm aus?

ExecutorService pool = Executors.newFixedThreadPool(2);
Future<Integer> big = pool.submit(() -> sizeOf("archiv.zip", 600));
Future<Integer> small = pool.submit(() -> sizeOf("notiz.txt", 100));
System.out.println("Groß: " + big.get());
System.out.println("Klein: " + small.get());
pool.shutdown();
Antwort

Übung

Programme mit Threads

Löse die Aufgaben in deinem Editor. main darf throws InterruptedException haben (oder throws Exception für Future.get()).

1. Parallele Summe. Das Array sales enthält 1 000 000 Verkäufe, sales[i] = i % 100. Teile es in 4 gleiche Teile und summiere jeden in seinem eigenen Thread. Jeder Thread schreibt an seinen eigenen Platz im Array long[] partial, deshalb brauchst du keine Synchronisierung. Warte mit join auf sie und addiere die Teile:

Ausgabe
Teile: [12375000, 12375000, 12375000, 12375000]
Gesamt: 49500000

2. Herunterladen mit einem Pool. Lade sechs Berichte (maerz.pdf, april.pdf, mai.pdf, juni.pdf, juli.pdf, august.pdf) mit einem Pool aus 3 Threads herunter. Benutze sizeOf(file, 300) aus der Karte zum Pool. Gib die Größen in der Reihenfolge der Dateien aus, die Summe und ob alles in unter 1,5 Sekunden fertig war (nacheinander wären es 1,8 s):

Ausgabe
maerz.pdf: 900 KB
april.pdf: 900 KB
mai.pdf: 700 KB
juni.pdf: 800 KB
juli.pdf: 800 KB
august.pdf: 1000 KB
Gesamt: 5100 KB
Unter 1,5 Sekunden: true

3. Ein Zähler ohne Verluste. Vier Threads mit je 50 000 Besuchen erhöhen einen gemeinsamen Zähler. Schreib den Zähler so, dass das Ergebnis immer 200 000 ist. Probier es zuerst ohne Synchronisierung und sieh, wie viel verloren geht, dann korrigiere es einmal mit synchronized und einmal mit AtomicInteger.

Ausgabe
Besuche: 200000

Weitere Aufgaben

  • Ein Test für den Zähler. Schreib einen JUnit-Test, der 8 Threads mit je 10 000 Erhöhungen startet und mit assertEquals auf 80 000 prüft. Lass ihn ein paarmal gegen den nicht synchronisierten Zähler laufen. Ein Test, der nur manchmal fehlschlägt, ist das typische Zeichen einer Race Condition.
  • Der Deadlock. Führe das Beispiel mit dem Drucker und dem Scanner aus „Genauer“ aus und sieh zu, wie es hängt. Korrigiere es so, dass beide Threads zuerst den Drucker nehmen.

Test

Teste dich

1. Was ist der Unterschied zwischen t.start() und t.run()?
2. Warum ruft main join() auf, bevor es das Ergebnis ausgibt?
3. Zwei Threads führen auf einem gemeinsamen Feld ohne Synchronisierung je 100 000-mal count++ aus. Welchen Wert hat count am Ende?
4. Was garantiert eine synchronized-Methode?
5. Du gibst drei Aufgaben an einen Pool und behältst für jede ein Future. Was macht future.get()?

Erstellt von Ivo Gergov im Rahmen der Kampagne von Evo Tech für zugängliche Programmierausbildung ·