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());Zahlung: akzeptiertLager: verfügbarE-Mail: gesendetWarterunden: 3Jede 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.
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
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());Zahlung: akzeptiertLager: verfügbarE-Mail: gesendetWarterunden: 1Network 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());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());Schritt für Schritt, in Worten
- Eine Bestellung braucht drei Antworten über das Netzwerk: die Zahlung, die Lagerprüfung und die Bestätigungs-E-Mail. Jede wartet etwa 200 ms.
- Nacheinander: Der Thread fragt nach die Zahlung und tut nichts, bis die Antwort kommt.
- Erst dann fragt er nach das Lager. Weitere 200 ms Warten.
- Erst dann fragt er nach die E-Mail. Weitere 200 ms Warten.
- 3 Warterunden: Der Kunde wartet etwa 600 ms, und der Prozessor steht die ganze Zeit still.
- Nebeneinander: Die drei Fragen gehen auf einmal raus, jede in einem eigenen Thread, und die drei Wartezeiten überlappen sich.
- Das Programm wartet auf die Antworten. Bis die erste kommt, sind die anderen beiden auch fertig.
- 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 voncall und drei Pausen. Deshalb funktioniert das auch auf einem Server mit einem Kern.Genauer, für Neugierige
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 istSystem.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());Die Zahlung und die Lagerprüfung gehen zusammen raus: Das ist die erste Runde. Die E-Mail geht erst nach beiden Antworten raus, wenn auf nichts anderes gewartet wird: eine zweite Runde. Etwa 400 ms. Arbeit, die von anderer Arbeit abhängt, kann sich nicht mit ihr überlappen.
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.);get() wartet, bis die Aufgabe fertig ist, und gibt ihr Ergebnis zurück.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);Rechnungen: 1000Prüfsumme: 499409728Ein 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
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);Rechnungen: 1000Prüfsumme: 499409728Die 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:
Preis: 89 €Bewertungen: 4,6 von 5Lieferung: morgenWarterunden: 1Weitere 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
renderbei jeder zehnten Rechnung einepause/sleepvon 200 ms ein, als würde sie auf die Festplatte warten. Was hilft jetzt mehr: mehr Threads oder mehr Kerne?
Test
Teste dich
Verwandte Lektionen
Abschnitt „Verwandte Lektionen“- Baut auf: Exceptions · Listen · Lambdas · Threads
- Geht weiter in: Race Condition · Sperren · Atomare Operationen und unveränderliche Objekte · Producer und Consumer
- Aufgaben zum Thema: Nebenläufigkeit und Race Conditions