Zum Inhalt
wannacode

Warum Threads

Kurz gesagt: Ein Thread ist ein eigener Ausführungsstrang im Programm; Threads beschleunigen Arbeit, die wartet (Netzwerk, Festplatte), schon auf einem Kern, während Arbeit, die rechnet, mehr Kerne braucht.

Problem

Der Kunde wartet dreimal

Der Kunde klickt auf „Bestellen“. Bevor er „Danke“ sieht, muss der Server drei Dienste über das Netzwerk fragen: die Zahlung (akzeptiert die Bank die Karte), das Lager (ist der Artikel lagernd) und die E-Mail (ist die Bestätigung raus). Jeder antwortet in etwa 200 ms.

Das Netzwerk ist hier Network. call wartet 200 ms und gibt die Antwort des Dienstes zurück. Das Programm fragt die drei nacheinander:

Network net = new Network();
String payment = net.call("Zahlung", "akzeptiert");
String stock = net.call("Lager", "verfügbar");
String email = net.call("E-Mail", "gesendet");
System.out.println(payment);
System.out.println(stock);
System.out.println(email);
System.out.println("Warterunden: " + net.rounds());
Ausgabe
Zahlung: akzeptiert
Lager: verfügbar
E-Mail: gesendet
Warterunden: 3

Jede Frage beginnt, nachdem die vorige fertig ist. Der Kunde wartet auf die Summe: etwa 600 ms. Und die ganze Zeit tut der Prozessor nichts: Er wartet nur auf eine Antwort.

Network zählt außerdem die Warterunden: Eine neue Runde beginnt, wenn das Programm fragt und auf keine andere Antwort gewartet wird. Hier sind es drei, eine für jede Frage. In dieser Lektion wird die Geschwindigkeit mit dieser Zahl gemessen und nicht in Millisekunden: Die Zeit hängt von der Maschine ab, die Zahl der Runden ist bei jedem Lauf gleich.

Genauer, für Neugierige

So funktioniert Network. synchronized stammt aus der Lektion „Threads“: Die beiden Zähler werden von mehreren Threads gleichzeitig angefasst. Warum ohne es Zahlen verloren gehen, zeigt die nächste Lektion, „Race Condition“.

class Network {
private int inFlight = 0;
private int rounds = 0;
// Fragt einen Dienst über das Netzwerk: Die Antwort kommt nach ~200 ms.
String call(String service, String answer) {
synchronized (this) {
if (inFlight == 0) rounds++; // niemand wartet: eine neue Runde
inFlight++;
}
pause(200);
synchronized (this) {
inFlight--;
}
return service + ": " + answer;
}
synchronized int rounds() {
return rounds;
}
private static void pause(int ms) {
try {
Thread.sleep(ms);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}

Die Idee

Arbeit, die wartet, und Arbeit, die rechnet

Die Arbeit eines Programms gibt es in zwei Arten.

  • Arbeit, die wartet (I/O-bound): eine Anfrage an die Bank, eine Abfrage an die Datenbank, das Lesen einer Datei, das Senden einer E-Mail. Das Programm fragt und steht still, bis die Antwort kommt. Der Prozessor ist währenddessen frei.
  • Arbeit, die rechnet (CPU-bound): 1000 Rechnungen als PDF, Produkt-Thumbnails, ein Bericht über eine Million Bestellungen. Hier ist der Prozessor die ganze Zeit beschäftigt.
Ein Thread, der wartet, braucht den Prozessor nicht. Deshalb helfen Threads bei Arbeit, die wartet, schon auf einem Kern: Der erste Thread fragt und schläft ein, der zweite fragt und schläft ein, ebenso der dritte, und die drei Wartezeiten laufen zusammen. Arbeit, die rechnet, braucht die ganze Zeit einen Kern. Sie wird nur mit mehr Kernen schneller, einer pro Thread.

Die erste Frage an ein langsames Programm lautet also: „Worauf wartet es?“ Die Antwort entscheidet, was hilft.

Sieh es dir an

Alle drei auf einmal

Die drei Fragen werden zu Aufgaben in einem Pool aus 3 Threads (ExecutorService aus der Lektion „Threads“). submit gibt sofort ein Future zurück, und get() wartet auf die Antwort:
Network net = new Network();
ExecutorService pool = Executors.newFixedThreadPool(3);
Future<String> payment =
pool.submit(() -> net.call("Zahlung", "akzeptiert"));
Future<String> stock =
pool.submit(() -> net.call("Lager", "verfügbar"));
Future<String> email =
pool.submit(() -> net.call("E-Mail", "gesendet"));
System.out.println(payment.get());
System.out.println(stock.get());
System.out.println(email.get());
pool.shutdown();
System.out.println("Warterunden: " + net.rounds());
Ausgabe
Zahlung: akzeptiert
Lager: verfügbar
E-Mail: gesendet
Warterunden: 1

Network net = new Network();
ExecutorService pool = Executors.newFixedThreadPool(3);
Future<String> payment =
pool.submit(() -> net.call("Zahlung", "akzeptiert"));
Future<String> stock =
pool.submit(() -> net.call("Lager", "verfügbar"));
Future<String> email =
pool.submit(() -> net.call("E-Mail", "gesendet"));
System.out.println(payment.get());
System.out.println(stock.get());
System.out.println(email.get());
pool.shutdown();
System.out.println("Warterunden: " + net.rounds());
Schritt für Schritt, in Worten
  1. Eine Bestellung braucht drei Antworten über das Netzwerk: die Zahlung, die Lagerprüfung und die Bestätigungs-E-Mail. Jede wartet etwa 200 ms.
  2. Nacheinander: Der Thread fragt nach die Zahlung und tut nichts, bis die Antwort kommt.
  3. Erst dann fragt er nach das Lager. Weitere 200 ms Warten.
  4. Erst dann fragt er nach die E-Mail. Weitere 200 ms Warten.
  5. 3 Warterunden: Der Kunde wartet etwa 600 ms, und der Prozessor steht die ganze Zeit still.
  6. Nebeneinander: Die drei Fragen gehen auf einmal raus, jede in einem eigenen Thread, und die drei Wartezeiten überlappen sich.
  7. Das Programm wartet auf die Antworten. Bis die erste kommt, sind die anderen beiden auch fertig.
  8. 1 Warterunde: etwa 200 ms, so lange wie die langsamste Antwort. Dieselben Antworten, dieselbe Reihenfolge, ein Drittel der Wartezeit.

Die Antworten sind dieselben und in derselben Reihenfolge. Es gibt eine Warterunde: Alle drei Fragen gehen raus, bevor eine Antwort gekommen ist. Der Kunde wartet etwa 200 ms, so lange wie der langsamste Dienst, statt 600.

Die Threads brauchen hier kaum den Prozessor: drei kurze Läufe von call und drei Pausen. Deshalb funktioniert das auch auf einem Server mit einem Kern.
Genauer, für Neugierige
Die Threads im Pool sind echte Betriebssystem-Threads. Jeder hat einen eigenen Stack und kostet Speicher, also sind tausend gleichzeitige Fragen mit tausend Threads teuer. Java 21 hat virtuelle Threads (Executors.newVirtualThreadPerTaskExecutor()): Ein virtueller Thread hält beim Warten keinen echten fest, deshalb kann es Hunderttausende geben. Der Code ist derselbe.

Probier es aus

Die E-Mail wartet auf die Zahlung

Ein Kollege ändert die Bestellung: Die E-Mail muss sagen, ob bezahlt wurde und ob der Artikel lagernd ist, also geht sie erst raus, nachdem Zahlung und Lager geantwortet haben.

Network net = new Network();
ExecutorService pool = Executors.newFixedThreadPool(3);
Future<String> payment =
pool.submit(() -> net.call("Zahlung", "akzeptiert"));
Future<String> stock =
pool.submit(() -> net.call("Lager", "verfügbar"));
// die E-Mail sagt, ob bezahlt wurde und ob der Artikel lagernd ist
System.out.println(payment.get());
System.out.println(stock.get());
Future<String> email =
pool.submit(() -> net.call("E-Mail", "gesendet"));
System.out.println(email.get());
pool.shutdown();
System.out.println("Warterunden: " + net.rounds());
Antwort

Probier es aus

Auf die Antwort warten

Was wartet auf die Antwort der Zahlung?

Future<String> payment =
        pool.submit(() -> net.call("Zahlung", "akzeptiert"));
System.out.println(payment.);

Problem

Tausend Rechnungen am Monatsende

Am Monatsende erstellt der Shop für jede von 1000 Bestellungen eine PDF-Rechnung. Hier wartet nichts auf das Netzwerk: Der Prozessor rechnet die ganze Zeit. render steht für das Zeichnen des PDFs mit einer Berechnung, die nur den Prozessor belastet und eine Prüfsumme zurückgibt:

// Erzeugt die Rechnung (hier: eine Rechnung, die nur die CPU auslastet).
static int render(int invoice) {
int h = invoice;
for (int i = 0; i < 2000; i++) {
h = (h * 31 + i) % 1_000_003;
}
return h;
}
// Die Rechnungen von from bis to, einschließlich.
static long renderRange(int from, int to) {
long sum = 0;
for (int n = from; n <= to; n++) {
sum += render(n);
}
return sum;
}
long total = renderRange(1, 1000);
System.out.println("Rechnungen: 1000");
System.out.println("Prüfsumme: " + total);
Ausgabe
Rechnungen: 1000
Prüfsumme: 499409728

Ein Kern rechnet alles, während die anderen untätig sind. Warterunden gibt es hier keine: Nichts wartet.

Sieh es dir an

Vier Threads, vier Kerne

Die Rechnungen werden in 4 Teile zu je 250 geteilt, und jeder Teil ist eine Aufgabe in einem Pool aus 4 Threads. Jeder Thread rechnet auf einem eigenen Kern:
ExecutorService pool = Executors.newFixedThreadPool(4);
List<Future<Long>> parts = new ArrayList<>();
for (int p = 0; p < 4; p++) {
int from = p * 250 + 1;
parts.add(pool.submit(() -> renderRange(from, from + 249)));
}
long total = 0;
for (Future<Long> part : parts) {
total += part.get();
}
pool.shutdown();
System.out.println("Rechnungen: 1000");
System.out.println("Prüfsumme: " + total);
Ausgabe
Rechnungen: 1000
Prüfsumme: 499409728

Die Prüfsumme ist dieselbe: Das Teilen ändert das Ergebnis nicht, nur wer es berechnet.

Wie viel schneller es ist, hängt von der Maschine ab. Auf meinem Laptop dauerte es bei einer 250-mal schwereren Berechnung pro Rechnung (500.000 Schritte statt 2000) mit einem Kern etwa 1,9 s und mit vier etwa 0,5 s.

Das sind Beispielzeiten, kein Versprechen. Mehr Teile als Kerne helfen nicht: Sie wechseln sich nur auf denselben Kernen ab.

Genauer, für Neugierige
  • Wie viele Threads. Für Arbeit, die rechnet, so viele wie Kerne: Runtime.getRuntime().availableProcessors() sagt dir, wie viele. Für Arbeit, die wartet, sind viel mehr in Ordnung: Sie schlafen nur.
  • Nicht alles lässt sich teilen. Wenn sich ein Zehntel der Arbeit nicht teilen lässt (zum Beispiel das Addieren der Teile oder das Schreiben einer Datei), macht keine Zahl von Kernen es mehr als 10-mal schneller. Das ist das Amdahlsche Gesetz.

Die Idee

Was hilft

Arbeit, die wartet Arbeit, die rechnet
Was sie bremst die Antwort vom Netzwerk, von der Festplatte, von der Datenbank das Rechnen selbst
Was hilft auf mehrere Dinge gleichzeitig warten mehr Kerne
In Java ein Thread-Pool, virtuelle Threads ein Pool mit so vielen Threads wie Kernen
Auf einem Kern hilft hilft nicht

Threads haben ihren Preis. Solange jeder mit seinen eigenen Daten arbeitet (sein eigener Dienst, seine eigenen 250 Rechnungen), ist alles gut. Sobald zwei Threads dasselbe anfassen (ein gemeinsamer Zähler, der Bestand im Lager), beginnen Fehler, die nicht bei jedem Lauf auftreten. Das ist die nächste Lektion: „Race Condition“.

Übung

Die Produktseite

Löse die Aufgabe in deinem Editor.

Die Produktseite. Bevor der Server die Seite zeigt, fragt er drei Dienste: den Preis, die Bewertungen und die Lieferung. Jeder antwortet in etwa 200 ms und hängt nicht von den anderen ab. Nimm Network aus der ersten Karte und frage alle auf einmal, sodass es eine Warterunde gibt:

Ausgabe
Preis: 89 €
Bewertungen: 4,6 von 5
Lieferung: morgen
Warterunden: 1

Weitere Aufgaben

  • Fünf Dienste. Füge das Lager und einen Rabatt hinzu. Wie viele Runden sind es, wenn der Rabatt vom Preis abhängt?
  • Wie viele Kerne. Führe die Rechnungen mit 1, 2, 4 und 8 Teilen aus und miss die Zeit auf deiner Maschine. Wann wird es nicht mehr schneller? Vergleiche mit der Zahl der Kerne deiner Maschine. - Das falsche Werkzeug. Setze in render bei jeder zehnten Rechnung eine pause/sleep von 200 ms ein, als würde sie auf die Festplatte warten. Was hilft jetzt mehr: mehr Threads oder mehr Kerne?

Test

Teste dich

1. Eine Bestellung wartet auf drei Dienste mit je 200 ms, die nicht voneinander abhängen. Wie lange wartet der Kunde, wenn du alle auf einmal fragst?
2. Der Server hat einen Kern. Helfen Threads bei Arbeit, die auf das Netzwerk wartet?
3. Du musst 1000 Rechnungen berechnen, und der Prozessor hat 4 Kerne. Wie viele Threads helfen am meisten?
4. Warum zählt die Lektion Warterunden und keine Millisekunden?

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