Zum Inhalt springen

4. Nebenläufigkeit II - Koordination

Zu Zen-Modus wechseln

Mit async/await lassen sich einzelne asynchrone Abläufe schreiben. Reale Anwendungen haben aber selten nur einen: Drei Datenquellen sollen gleichzeitig geladen werden, eine Anfrage soll nach zwei Sekunden aufgeben, ein ungeduldiger Doppelklick darf keine doppelte Bestellung auslösen. Dieses Kapitel behandelt die Koordination mehrerer nebenläufiger Abläufe: die Kombinatoren der Promise-Familie, Zeitsteuerung und Abbruch, die Vermeidung von Race Conditions und schließlich echte Parallelität mit Workern, dem Threading-Modell von JavaScript.

Vier statische Methoden kombinieren mehrere Promises zu einem; sie unterscheiden sich darin, wann das Gesamtergebnis feststeht und was bei Fehlern passiert.

Kombinatorerfüllt wenn …scheitert wenn …typischer Einsatz
Promise.allalle erfüllt sindeines scheitertzusammengehörige Daten laden
Promise.allSettledalle fertig sind (egal wie)nieSammelaktionen mit Einzelbericht
Promise.racedas erste fertig ist (egal wie)das erste scheitertTimeouts
Promise.anydas erste erfüllt istalle scheiternschnellste von mehreren Quellen

Je ein realistischer Fall:

// all: a dashboard needs user, jobs and quota; missing one means broken page
const [user, jobs, quota] = await Promise.all([
loadUser(id), loadJobs(id), loadQuota(id),
]);
// allSettled: send 50 newsletters; one bounce must not stop the rest
const results = await Promise.allSettled(recipients.map((r) => sendMail(r)));
const failed = results.filter((r) => r.status === "rejected").length;
console.log(`${results.length - failed} sent, ${failed} failed`);
// any: three mirror servers, take whichever answers first
const file = await Promise.any(mirrors.map((url) => download(url)));

Die Entscheidung zwischen all und allSettled ist eine fachliche: Ist das Gesamtergebnis ohne einen Teil wertlos (Dashboard), dann all mit gemeinsamem catch. Ist jeder Teil für sich wertvoll (Mailversand), dann allSettled mit Auswertung pro Eintrag. allSettled liefert dafür pro Promise ein Objekt { status: "fulfilled", value } oder { status: "rejected", reason }.

Promise.race lässt zwei Abläufe gegeneinander antreten; mit einem Timer als Gegner entsteht ein Timeout:

function timeout(milliseconds: number): Promise<never> {
return new Promise((_, reject) =>
setTimeout(() => reject(new Error(`timed out after ${milliseconds} ms`)),
milliseconds));
}
const jobs = await Promise.race([loadJobs(id), timeout(2000)]);

Antwortet loadJobs innerhalb von zwei Sekunden, gewinnt es; sonst gewinnt der Timer und die Kette landet im catch. Der Typ Promise<never> sagt präzise: Dieses Promise wird niemals erfüllt, es kann nur scheitern.

race hat einen Schönheitsfehler: Der Verlierer läuft im Hintergrund weiter und verbraucht Ressourcen. Für echtes Abbrechen gibt es den AbortController, den unter anderem fetch (nächstes Kapitel) direkt unterstützt:

const controller = new AbortController();
setTimeout(() => controller.abort(), 2000); // give up after 2 s
try {
const response = await fetch(url, { signal: controller.signal });
} catch (error) {
// on abort: an AbortError lands here
}

Der Controller besitzt ein signal, das an die Operation übergeben wird; abort() bricht alles ab, was an diesem Signal hängt. Dasselbe Muster bricht auch veraltete Anfragen ab, etwa wenn der Benutzer bei einer Suche weitertippt, bevor die letzte Antwort da ist.

Eine Race Condition ist ein Fehler, der davon abhängt, in welcher Reihenfolge nebenläufige Abläufe fertig werden. Das greifbarste Beispiel liefert jeder Webshop:

// BUG: double-click means double order
orderButton.addEventListener("click", async () => {
await submitOrder(cart); // takes 800 ms; second click fits in here
showConfirmation();
});

Zwischen Klick und Antwort liegen hunderte Millisekunden, und in dieser Lücke passt ein zweiter Klick: Zwei Bestellungen laufen los, beide erfolgreich, der Kunde zahlt doppelt. Der Fehler ist tückisch, weil er beim Testen fast nie auftritt; genau das ist typisch für Race Conditions. Die Standardabwehr ist ein In-Flight-Flag (plus deaktivierter Knopf für die Sichtbarkeit):

let submitting = false;
orderButton.addEventListener("click", async () => {
if (submitting) return; // a request is already on its way
submitting = true;
orderButton.disabled = true;
try {
await submitOrder(cart);
showConfirmation();
} finally {
submitting = false;
orderButton.disabled = false;
}
});

Die zweite häufige Race Condition ist die überholte Antwort: Bei einer Live-Suche wird nach jedem Buchstaben angefragt, aber die Antworten kommen nicht zwingend in Sendereihenfolge zurück; die Antwort auf “ka” kann die auf “kam” überholen und veraltete Ergebnisse anzeigen. Abwehr: Entweder alte Anfragen per AbortController abbrechen, oder mitzählen und nur die Antwort auf die jüngste Anfrage übernehmen (latest wins).

Merksatz: Sobald zwischen Auslöser und Wirkung eine Wartezeit liegt, muss die Frage gestellt werden: Was passiert, wenn in dieser Lücke noch einmal ausgelöst wird?

Der Event Loop überbrückt Wartezeiten, aber er hilft nicht gegen Rechenzeit: Eine Funktion, die zehn Sekunden rechnet (Bildfilter, Videoanalyse, große Sortierung), blockiert den einen Thread komplett, samt Oberfläche. Für diesen Fall bietet JavaScript Worker: eigenständige Threads mit eigenem Event Loop, im Browser als Web Worker, in Node als Worker Threads. Damit landet das Lehrplan-Thema Threading in der Praxis.

// main.js: start the worker, send work, receive the result
const worker = new Worker("histogram-worker.js");
worker.postMessage({ pixels });
worker.addEventListener("message", (event) => {
drawHistogram(event.data.histogram);
});
// histogram-worker.js: runs in its own thread
self.addEventListener("message", (event) => {
const histogram = computeHistogram(event.data.pixels); // heavy work
self.postMessage({ histogram });
});

Die entscheidende Design-Entscheidung: Worker teilen keinen Speicher mit dem Hauptthread. Kommunikation läuft ausschließlich über Nachrichten (postMessage), deren Daten kopiert werden. Das macht Worker etwas umständlich, erspart JavaScript aber die klassischen Probleme geteilten Speichers, die Threading in anderen Sprachen (Java, C++, Python) berüchtigt machen: Dort können mehrere Threads dieselben Variablen gleichzeitig ändern, und ohne penible Absicherung mit Sperren (Locks) entstehen Wettlauf- und Verklemmungsfehler (Deadlocks), die zu den schwersten Fehlern der Informatik zählen.

Damit ergibt sich eine klare Arbeitsteilung, die auch als Prüfungsantwort taugt:

  • Warten (Netzwerk, Dateien, Timer): Event Loop mit async/await; ein Thread genügt, Worker wären Overhead.
  • Rechnen (CPU-lastige Arbeit ab spürbarer Dauer): Worker, damit der Hauptthread bedienbar bleibt.

Behauptungen über Parallelität sind billig; Zahlen überzeugen. Das Messwerkzeug ist performance.now():

async function measure(label: string, action: () => Promise<unknown>) {
const start = performance.now();
await action();
console.log(`${label}: ${Math.round(performance.now() - start)} ms`);
}
await measure("sequential", async () => {
for (const id of ids) await fetchJobState(id);
});
await measure("parallel", () => Promise.all(ids.map((id) => fetchJobState(id))));

Bei fünf simulierten Anfragen zu je rund 300 Millisekunden liefert das typischerweise etwa 1500 gegen 350 Millisekunden. Solche Messungen gehören zu jeder Parallelisierungs-Entscheidung: Sie belegen den Gewinn, und sie entlarven Fälle, in denen Parallelität nichts bringt (etwa wenn ein einzelner langsamer Teilnehmer dominiert; Promise.all ist immer so langsam wie sein langsamstes Promise).

Lernergebnisse: Was Sie nach diesem Kapitel können sollten

Abschnitt betitelt „Lernergebnisse: Was Sie nach diesem Kapitel können sollten“

Nach Abschluss dieses Kapitels sollten Schülerinnen und Schüler in der Lage sein:

  • Anwenden: mehrere asynchrone Abläufe mit Promise.all, allSettled, race und any koordinieren und die Wahl fachlich begründen.
  • Anwenden: Timeouts umsetzen und Operationen mit AbortController tatsächlich abbrechen.
  • Analysieren: Race Conditions (Doppelauslösung, überholte Antworten) erkennen und mit In-Flight-Flag, Abbruch oder Latest-wins absichern.
  • Erklären: die Arbeitsteilung zwischen Event Loop (Warten) und Workern (Rechnen) erklären und das Nachrichtenmodell der Worker beschreiben.
  • Erklären: einordnen, warum Threading mit geteiltem Speicher in anderen Sprachen zusätzliche Absicherung erfordert.
  • Anwenden: Laufzeiten messen und Parallelisierungs-Entscheidungen mit Zahlen belegen.
  • Aufgabe 07 - Parallel oder nacheinander?
  • Aufgabe 08 - Der Wettlauf