Zum Inhalt
wannacode

Async, await und Threads

Kurz gesagt: JavaScript führt den Code in einem Thread mit einer Event Loop aus: async und await warten, ohne das Programm anzuhalten, und Worker-Threads rechnen wirklich parallel.

Problem

Drei Berichte, einer nach dem anderen

Das Programm lädt drei Monatsberichte vom Server herunter. Der Download ist hier eine Pause: await sleep(ms) wartet so viele Millisekunden. sleep ist setTimeout aus node:timers/promises, und async vor der Funktion erlaubt await darin.
reports.js
import { setTimeout as sleep } from "node:timers/promises";
async function download(file, ms) {
await sleep(ms);
console.log(`Heruntergeladen: ${file}`);
}
await download("maerz.pdf", 600);
await download("april.pdf", 200);
await download("mai.pdf", 400);
console.log("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. Während es wartet, ist Node nicht blockiert: Du wirst sehen, dass es auch anderes erledigen kann. Aber jedes await wartet, bis der vorige Download fertig ist, bevor der nächste beginnt. 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 die Datei von oben nach unten 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.

JavaScript ist anders. Node führt deinen Code in einem Thread aus. Wenn der Code wartet (ein Timer, das Netzwerk, eine Datei), steht Node nicht still: Es überlässt das Warten dem System und macht mit anderem Code weiter. Wenn das Ergebnis da ist, kommt die Funktion, die darauf wartet, in eine Warteschlange. Das ist die Event Loop (Ereignisschleife): nebenläufig, in einem Thread.

Der Preis: Solange dein Code rechnet, ohne zu warten, kann nichts anderes starten. Der Timer hier ist auf 0 ms gestellt, kommt aber erst nach der Schleife dran:

setTimeout(() => console.log("der Timer"), 0);
const end = Date.now() + 200;
while (Date.now() < end) {
// 0,2 s Rechnen, ohne ein einziges await
}
console.log("die Schleife ist vorbei");
Ausgabe
die Schleife ist vorbei
der Timer

Für wirklich parallele Berechnungen hat Node Worker-Threads (node:worker_threads): jeder mit eigenem Speicher. Du siehst sie in der Mitte der Lektion.

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

Das erste Promise

download ist eine async-Funktion. Wenn du sie ohne await aufrufst, startet sie und gibt sofort ein Promise (ein Versprechen) zurück: ein Objekt, das sagt „das Ergebnis kommt noch“. Das Programm macht weiter:

const worker = download("april.pdf", 200);
console.log("Das Programm wartet nicht");
await worker;
console.log("Der Bericht ist heruntergeladen");
Ausgabe
Das Programm wartet nicht
Heruntergeladen: april.pdf
Der Bericht ist heruntergeladen
  • Der Aufruf ohne await startet den Download und kehrt sofort zurück, deshalb steht „Das Programm wartet nicht“ zuerst da.
  • await worker wartet, bis das Promise erfüllt ist. Das ist wie join() bei Threads in anderen Sprachen.

Einen Thread gibt es hier nicht: Der Download ist ein Timer, und Node ruft den Rest von download auf, wenn der Timer abläuft.

Genauer, für Neugierige
await ist der neueste Weg, „mach das, wenn es fertig ist“ zu sagen. In älterem Code begegnen dir die anderen zwei:
setTimeout(() => {
console.log("1. eine Funktion, die Node später aufruft (Callback)");
}, 0);
await sleep(50);
sleep(50).then(() => console.log("2. then auf einem Promise"));
await sleep(100);
await sleep(50);
console.log("3. await");
Ausgabe
1. eine Funktion, die Node später aufruft (Callback)
2. then auf einem Promise
3. await
Ein Callback ist eine Funktion, die du übergibst, damit Node sie später aufruft. then hängt eine solche Funktion an ein Promise. await macht dasselbe, aber der Code liest sich von oben nach unten, deshalb schreibt man ihn heute so.

Sieh es dir an

Drei auf einmal

Jetzt starten wir die drei Downloads, ohne zu warten, und warten mit Promise.all gemeinsam auf die drei Promises:
const start = Date.now();
await Promise.all([
download("maerz.pdf", 600),
download("april.pdf", 200),
download("mai.pdf", 400),
]);
const ms = Date.now() - start;
console.log(`Fertig in unter einer Sekunde: ${ms < 1000}`);
Ausgabe
Heruntergeladen: april.pdf
Heruntergeladen: mai.pdf
Heruntergeladen: maerz.pdf
Fertig in unter einer Sekunde: true

Die Downloads 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.

Promise.all wartet auf alle Promises und gibt dir ein Array mit ihren Ergebnissen. Es gibt einen Thread: Die Timer laufen in ihrer Reihenfolge ab, und Node ruft die Fortsetzungen nacheinander auf.

Probier es aus

await in einer Schleife

Was gibt das Programm aus?

const start = Date.now();
for (const [file, ms] of [["maerz.pdf", 600], ["april.pdf", 200]]) {
await download(file, ms);
}
console.log(`Dauerte über 0,7 s: ${Date.now() - start > 700}`);
Antwort

Die Idee

Das Leben eines Promise

Ein Promise ist in einem von drei Zuständen. In welchem, kannst du im Code nicht abfragen, aber inspect aus node:util zeigt es, und console.log auch:
const report = sleep(500, "mai.pdf");
console.log(inspect(report));
await report;
console.log(inspect(report));
const broken = Promise.reject(new Error("keine Verbindung"));
try {
await broken;
} catch (error) {
console.log(`Fehler: ${error.message}`);
}
Ausgabe
Promise { <pending> }
Promise { 'mai.pdf' }
Fehler: keine Verbindung
Zustand Wann
pending (ausstehend) die Arbeit ist noch nicht fertig
fulfilled (erfüllt) es gibt ein Ergebnis: await gibt es zurück
rejected (abgelehnt) es gibt einen Fehler: await wirft ihn, und try/catch fängt ihn

Ein Promise ändert seinen Zustand nur einmal. Danach gibt await darauf sofort dasselbe Ergebnis zurück.

Genauer, für Neugierige
  • Einen Timer oder eine Anfrage brichst du mit AbortController ab: Du gibst sleep oder fetch ein signal mit, und abort() lehnt das Promise mit AbortError ab.
  • Promise.allSettled wartet auf alle Promises, auch wenn eines abgelehnt wird, und Promise.race gibt dir das erste, das fertig ist.

Problem

Die verlorenen Besuche

Die Website zählt ihre Besuche. Der Zähler liegt in einem SharedArrayBuffer: Speicher, den mehrere Worker sehen. Ein Int32Array liest und schreibt darin ganze Zahlen. Zwei Worker bedienen Besucher, und jeder erhöht den Zähler 1 000 000-mal. new Worker(new URL(import.meta.url)) führt dieselbe Datei in einem neuen Thread aus, und dort ist isMainThread gleich false:
visits.js
import { Worker, isMainThread, workerData } from "node:worker_threads";
import { once } from "node:events";
if (isMainThread) {
const visits = new Int32Array(new SharedArrayBuffer(4));
const pages = [1, 2].map(() => new Worker(new URL(import.meta.url), { workerData: visits }));
await Promise.all(pages.map((page) => once(page, "exit")));
console.log(`Besuche: ${visits[0]}`);
} else {
for (let i = 0; i < 1_000_000; i++) {
workerData[0]++;
}
}

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

Ausgabe (jedes Mal anders)
Besuche: 1091916
Besuche: 1063068
Besuche: 1072106
Besuche: 1119773
Besuche: 1080911

Jedes Mal weniger, und jedes Mal anders. Der Grund ist, dass workerData[0]++ kein einzelner Schritt ist, sondern drei:

let value = workerData[0]; // 1. lesen
value = value + 1; // 2. 1 addieren
workerData[0] = value; // 3. schreiben

// counts ist ein Int32Array in einem SharedArrayBuffer: Speicher, den beide Worker teilen.
function increment(counts) {
counts[0]++; // lesen, 1 addieren, schreiben
}
function safeIncrement(counts) {
Atomics.add(counts, 0, 1);
}
Schritt für Schritt, in Worten
  1. counts[0] ist 5. Worker A und Worker 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 Atomics.add: A liest 5 und schreibt 6 in einem unteilbaren Schritt.
  7. B erreicht Atomics.add und wartet, bis der Schritt von A fertig ist.
  8. B 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

Atomics: die drei Schritte auf einmal

Die Lösung ist, aus den drei Schritten einen zu machen, den niemand unterbrechen kann. Das tut Atomics.add (eine atomare, unteilbare Addition):
import { Worker, isMainThread, workerData } from "node:worker_threads";
import { once } from "node:events";
if (isMainThread) {
const visits = new Int32Array(new SharedArrayBuffer(4));
const pages = [1, 2].map(() => new Worker(new URL(import.meta.url), { workerData: visits }));
await Promise.all(pages.map((page) => once(page, "exit")));
console.log(`Besuche: ${visits[0]}`);
} else {
for (let i = 0; i < 1_000_000; i++) {
Atomics.add(workerData, 0, 1);
}
}
Ausgabe
Besuche: 2000000

Atomics.add(array, index, amount) liest, addiert und schreibt in einem Schritt. Die zwei Worker können ihn nicht aufteilen, deshalb ist das Ergebnis bei jedem Lauf 2 000 000. Atomics.load liest einen Wert auf dieselbe sichere Weise. Locks wie in Java gibt es nicht: Gewöhnliche Objekte und Arrays werden zwischen Workern nicht geteilt, sondern kopiert.

Genauer, für Neugierige
  • Producer und Consumer in einem Thread brauchen keinen Lock: Die Küche wartet mit await auf eine Bestellung, und put erfüllt ihr Promise:
class OrderBox {
#orders = [];
#waiting = [];
put(order) {
const kitchen = this.#waiting.shift();
if (kitchen) {
kitchen(order);
} else {
this.#orders.push(order);
}
}
take() {
if (this.#orders.length > 0) {
return Promise.resolve(this.#orders.shift());
}
return new Promise((resolve) => this.#waiting.push(resolve));
}
}
const box = new OrderBox();
async function kitchen() {
for (let i = 0; i < 3; i++) {
console.log(`Die Küche nimmt ${await box.take()}`);
}
}
const cook = kitchen();
box.put("Pizza");
box.put("Salat");
box.put("Suppe");
await cook;
Ausgabe
Die Küche nimmt Pizza
Die Küche nimmt Salat
Die Küche nimmt Suppe
  • Einen Deadlock (Verklemmung) zwischen Locks gibt es in einem Thread nicht, weil es keine Locks gibt. Worker können mit Atomics.wait aufeinander warten, und dort ist ein Deadlock möglich, aber das brauchst du selten.
  • Worker haben keine Prioritäten.

Probier es aus

Zwei Geldautomaten

Zwei Personen heben im selben Moment je 80 € von einem Konto mit 100 € ab. Die Bank bestätigt jede Abhebung in 0,1 s. Das Programm läuft in einem Thread, und trotzdem bekommen beide das Geld, und der Kontostand wird -60 €. In welcher Zeile steckt der Fehler?

Wähle die Zeile mit dem Fehler.

Die Idee

Aufgaben-Pool und Promise

Tausend Downloads auf einmal überlasten den Server. Ein Pool begrenzt, wie viele Aufgaben gleichzeitig laufen: Sobald eine fertig ist, startet die nächste. Node hat keinen fertigen Pool für Promises, aber er braucht nur ein paar Zeilen: runLimited startet limit „Spuren“, und jede nimmt sich die nächste Aufgabe, sobald sie ihre eigene fertig hat:
async function sizeOf(file, ms) {
await sleep(ms);
return file.length * 100;
}
async function runLimited(limit, jobs) {
const results = [];
let next = 0;
async function lane() {
while (next < jobs.length) {
const i = next++;
results[i] = await jobs[i]();
}
}
await Promise.all(Array.from({ length: limit }, lane));
return results;
}
const files = ["maerz.pdf", "april.pdf", "mai.pdf", "juni.pdf"];
const sizes = await runLimited(2, files.map((file) => () => sizeOf(file, 300)));
files.forEach((file, i) => console.log(`${file}: ${sizes[i]} KB`));
Ausgabe
maerz.pdf: 900 KB
april.pdf: 900 KB
mai.pdf: 700 KB
juni.pdf: 800 KB
  • Die Aufgabe ist eine Funktion, die ein Promise zurückgibt: () => sizeOf(file, 300). So startet sie erst, wenn eine Spur sie nimmt.
  • Ein Promise ist das Future von JavaScript: Das Ergebnis kommt, und await wartet darauf.
  • runLimited gibt ein Array mit den Ergebnissen in der Reihenfolge der Aufgaben zurück, nicht in der Reihenfolge, in der sie fertig werden.

Für schwere Berechnungen besteht der Pool aus Workern. Auch den hat Node nicht fertig, aber Bibliotheken wie Piscina haben ihn.

Genauer, für Neugierige
Gibt die Aufgabe nichts zurück, wartest du nur auf runLimited:
const reports = [1, 2, 3, 4, 5].map((i) => `bericht-${i}.pdf`);
await runLimited(2, reports.map((file) => () => download(file, 100)));
console.log("Alles fertig.");
Ausgabe
Heruntergeladen: bericht-1.pdf
Heruntergeladen: bericht-2.pdf
Heruntergeladen: bericht-3.pdf
Heruntergeladen: bericht-4.pdf
Heruntergeladen: bericht-5.pdf
Alles fertig.
Die ersten zwei Berichte laufen gleichzeitig, aber in einem Thread: Ihre Timer laufen in der Reihenfolge ab, in der sie gestartet wurden, deshalb ist die Reihenfolge bei jedem Lauf gleich.

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?

const big = sizeOf("archiv.zip", 600);
const small = sizeOf("notiz.txt", 100);
console.log(`Groß: ${await big}`);
console.log(`Klein: ${await small}`);
Antwort

Übung

Programme mit async und Worker

Löse die Aufgaben in deinem Editor.

1. Parallele Summe. Das Array sales enthält 1 000 000 Verkäufe, sales[i] = i % 100, in einem Int32Array auf einem SharedArrayBuffer. Teile es in 4 gleiche Teile und summiere jeden in seinem eigenen Worker. Gib jedem in workerData das Array, die Nummer seines Teils und das gemeinsame Float64Array partial mit. Jeder Worker schreibt an seinen eigenen Platz in partial, deshalb brauchst du kein Atomics. Warte mit once(worker, "exit") 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 runLimited, je 3 auf einmal 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 Worker mit je 50 000 Besuchen erhöhen einen gemeinsamen Zähler in einem SharedArrayBuffer. Schreib ihn so, dass das Ergebnis immer 200 000 ist. Probier es zuerst mit ++ und sieh, wie viel verloren geht (mit 1 000 000 pro Worker sieht man es deutlicher), dann korrigiere es mit Atomics.add.

Ausgabe
Besuche: 200000

Weitere Aufgaben

  • Ein Test für den Zähler. Schreib einen node:test-Test, der 8 Worker mit je 100 000 Erhöhungen startet und mit assert.equal auf 800 000 prüft. Lass ihn ein paarmal gegen den Zähler mit ++ laufen. Ein Test, der nur manchmal fehlschlägt, ist das typische Zeichen einer Race Condition.
  • Die Geldautomaten. Lege im Programm mit den zwei Geldautomaten die Prüfung und die Abhebung in eine eigene Funktion ohne await dazwischen und prüfe, dass der Kontostand auch bei fünf Geldautomaten 20 € bleibt.

Test

Teste dich

1. Was macht const report = download("april.pdf", 200); ohne await?
2. Drei Downloads stehen in einer for-Schleife, jeder mit await. Wie lange dauern sie?
3. Zwei Worker führen auf einem gemeinsamen SharedArrayBuffer je 1 000 000-mal workerData[0]++ aus. Was steht am Ende darin?
4. Warum braucht JavaScript-Code in einem Thread für seine gewöhnlichen Variablen keine Locks?
5. Du startest zwei Aufgaben und wartest zuerst mit await big, dann mit await small. Die kleine ist zuerst fertig. Was wird zuerst ausgegeben?

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