Warum Threads
Kurz gesagt: In JavaScript braucht Arbeit, die wartet (Netzwerk, Festplatte), keine Threads, Promise.all reicht; Arbeit, die rechnet, braucht echte Threads, Worker, und mehr Kerne.
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:
const net = new Network();const payment = await net.call("Zahlung", "akzeptiert");const stock = await net.call("Lager", "verfügbar");const email = await net.call("E-Mail", "gesendet");console.log(payment);console.log(stock);console.log(email);console.log(`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. sleep ist setTimeout aus node:timers/promises, wie in der Lektion „Async, await und Threads“. Es gibt einen Thread, also brauchen die Zähler keinen Schutz.
class Network { inFlight = 0; rounds = 0;
// Fragt einen Dienst über das Netzwerk: Die Antwort kommt nach ~200 ms. async call(service, answer) { if (this.inFlight === 0) this.rounds++; // niemand wartet: eine neue Runde this.inFlight++; await sleep(200); this.inFlight--; return `${service}: ${answer}`; }}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
Promise.all gemeinsam auf die drei Promises (die Lektion „Async, await und Threads“). Ein Thread ist dafür nicht nötig:const net = new Network();const [payment, stock, email] = await Promise.all([ net.call("Zahlung", "akzeptiert"), net.call("Lager", "verfügbar"), net.call("E-Mail", "gesendet"),]);console.log(payment);console.log(stock);console.log(email);console.log(`Warterunden: ${net.rounds}`);Zahlung: akzeptiertLager: verfügbarE-Mail: gesendetWarterunden: 1const net = new Network();const [payment, stock, email] = await Promise.all([ net.call("Zahlung", "akzeptiert"), net.call("Lager", "verfügbar"), net.call("E-Mail", "gesendet"),]);console.log(payment);console.log(stock);console.log(email);console.log(`Warterunden: ${net.rounds}`);const net = new Network();const payment = await net.call("Zahlung", "akzeptiert");const stock = await net.call("Lager", "verfügbar");const email = await net.call("E-Mail", "gesendet");console.log(payment);console.log(stock);console.log(email);console.log(`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 drei Timer feuern in der Reihenfolge, in der sie gesetzt wurden, und Node ruft die Fortsetzungen nacheinander auf. Alles läuft in einem Thread.Genauer, für Neugierige
await liegt, zeigt die Lektion „async ist kein Thread“.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.
const net = new Network();const [payment, stock] = await Promise.all([ net.call("Zahlung", "akzeptiert"), net.call("Lager", "verfügbar"),]);// die E-Mail sagt, ob bezahlt wurde und ob der Artikel lagernd istconsole.log(payment);console.log(stock);console.log(await net.call("E-Mail", "gesendet"));console.log(`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 das Promise der Zahlung?
const payment = net.call("Zahlung", "akzeptiert");
console.log( payment);await wartet auf das Promise und liefert das Ergebnis. Ohne es gibt console.log das Promise selbst aus: Promise { <pending> }.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).function render(invoice) { let h = invoice; for (let i = 0; i < 2000; i++) { h = (h * 31 + i) % 1_000_003; } return h;}
// Die Rechnungen von from bis to, einschließlich.function renderRange(from, to) { let sum = 0; for (let n = from; n <= to; n++) { sum += render(n); } return sum;}const total = renderRange(1, 1000);console.log("Rechnungen: 1000");console.log(`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 Worker, vier Kerne
postMessage zurück, und der Haupt-Thread empfängt sie mit once(worker, "message"):if (isMainThread) { const workers = [0, 1, 2, 3].map((p) => { const from = p * 250 + 1; const range = { from, to: from + 249 }; return new Worker(new URL(import.meta.url), { workerData: range }); }); const parts = await Promise.all( workers.map(async (worker) => (await once(worker, "message"))[0]), ); const total = parts.reduce((sum, part) => sum + part, 0); console.log("Rechnungen: 1000"); console.log(`Prüfsumme: ${total}`);} else { parentPort.postMessage(renderRange(workerData.from, workerData.to));}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:
os.availableParallelism()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.
- Worker teilen keine gewöhnlichen Objekte:
workerDataundpostMessagekopieren sie. Gemeinsamer Speicher existiert nur inSharedArrayBuffer, und dort beginnen die Probleme der nächsten Lektion.
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 JavaScript | Promise.all, keine Threads | Worker-Threads |
| 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. Führe die 1000 Rechnungen mit
Promise.allund einerasync-Funktion statt mit Workern aus. Wird es schneller? Warum?
Test
Teste dich
Verwandte Lektionen
Abschnitt „Verwandte Lektionen“- Baut auf: Exceptions · Arrays: push, splice und sort · Pfeilfunktionen · Async, await und Threads
- Geht weiter in: Race Condition · Sperren · Atomare Operationen und unveränderliche Objekte · Producer und Consumer
- Aufgaben zum Thema: Nebenläufigkeit und Race Conditions