Zum Inhalt springen

3. Nebenläufigkeit I - Asynchrones JavaScript

Zu Zen-Modus wechseln

Programme warten ständig: auf eine Datei, auf eine Netzwerkantwort, auf einen Timer, auf den nächsten Klick. Die Frage ist, was während des Wartens passiert. Ein Programm, das bei jeder Wartezeit stillsteht (blockiert), ist auf dem Server ein Durchsatzproblem und im Browser eine Katastrophe: Die Oberfläche friert ein, kein Klick, kein Scrollen, nichts. Nebenläufigkeit heißt, Wartezeiten zu überbrücken, indem in der Zwischenzeit anderes erledigt wird; der Lehrplan verlangt ausdrücklich, Anwendungssysteme unter Verwendung von Nebenläufigkeit zu entwickeln.

JavaScript löst das mit einem eigenwilligen, aber sehr erfolgreichen Modell: eine Ausführungsspur plus eine Warteschlange für alles, was später passieren soll. Dieses Kapitel baut das Modell von unten auf: erst der Event Loop, dann Promises als Ordnung für “später”, schließlich async/await als lesbare Schreibweise. Die drei Wochen dieses Kapitels sind gut investiert: Asynchroner Code ist die Grundlage von allem, was in diesem Jahr folgt, vom API-Aufruf bis zur Server-Komponente.

JavaScript führt Code single-threaded aus: genau eine Anweisung zur Zeit, auf genau einem Call Stack. Langsame Operationen (Timer, Netzwerk, Dateizugriff) reicht die Laufzeitumgebung an das System weiter und nimmt stattdessen einen Callback entgegen: eine Funktion, die aufgerufen werden soll, wenn das Ergebnis da ist. Fertige Callbacks landen in einer Warteschlange, und der Event Loop arbeitet sie ab, sobald der Call Stack leer ist.

flowchart LR
    code["Call Stack (Ihr Code)"] -- "setTimeout, fetch, ..." --> apis["Web-APIs / Laufzeit"]
    apis -- "done: enqueue callback" --> queue["Task Queue"]
    queue -- "event loop: when stack is empty" --> code

Das klassische Experiment dazu:

console.log("one");
setTimeout(() => console.log("two"), 0);
console.log("three");
// output: one, three, two

Obwohl der Timer auf 0 Millisekunden steht, kommt two zuletzt: setTimeout legt den Callback nur in die Warteschlange, und die kommt erst dran, wenn der laufende Code vollständig fertig ist. Daraus folgen zwei Merksätze, die das ganze Modell tragen:

  1. Asynchron heißt später, nie zwischendurch. Laufender Code wird nie unterbrochen; Callbacks laufen erst, wenn der Stack leer ist.
  2. Blockieren blockiert alles. Eine Endlosschleife oder eine schwere Berechnung legt auch alle wartenden Callbacks lahm, im Browser die komplette Oberfläche.

Callbacks funktionieren, skalieren aber schlecht. Sobald Schritte aufeinander aufbauen (lade A, dann damit B, dann damit C), verschachteln sich die Funktionen zur berüchtigten Treppe:

loadUser(userId, (user) => {
loadOrders(user, (orders) => {
loadInvoice(orders[0], (invoice) => {
console.log(invoice.total);
// and error handling for each step? somewhere in here, three times
});
});
});

Diese Form (Callback Hell) ist schwer zu lesen, und die Fehlerbehandlung muss auf jeder Stufe einzeln erfunden werden. Moderne JavaScript-APIs verwenden Callbacks deshalb fast nur noch für Ereignisse (Klicks, Timer); für “Ergebnis kommt später” gibt es eine bessere Abstraktion.

Ein Promise ist ein Objekt, das ein zukünftiges Ergebnis vertritt. Es befindet sich in genau einem von drei Zuständen:

ZustandBedeutung
pendingErgebnis steht noch aus
fulfillederfolgreich, Wert liegt vor
rejectedfehlgeschlagen, Fehler liegt vor

Der Übergang passiert genau einmal: von pending nach fulfilled oder rejected, nie zurück. Mit dem Promise arbeitet man über seine Methoden:

loadUser(userId)
.then((user) => loadOrders(user))
.then((orders) => loadInvoice(orders[0]))
.then((invoice) => console.log(invoice.total))
.catch((error) => console.error("failed:", error.message))
.finally(() => console.log("done either way"));

then registriert die Weiterverarbeitung und liefert selbst wieder ein Promise; dadurch entsteht statt der Treppe eine flache Kette. Gibt ein then-Callback ein Promise zurück, wartet die Kette darauf. Und ein einziges catch am Ende fängt Fehler aus jeder Stufe: die Fehlerbehandlung, die bei Callbacks dreimal erfunden werden musste, steht genau einmal.

Selbst erzeugen lässt sich ein Promise mit dem Konstruktor; das braucht man vor allem, um alte Callback-APIs zu verpacken oder Wartezeiten zu bauen:

function sleep(milliseconds) {
return new Promise((resolve) => setTimeout(resolve, milliseconds));
}

In TypeScript sind Promises generisch: Promise<Invoice> ist das Versprechen auf eine Rechnung, Promise<void> das Versprechen auf “fertig, aber ohne Wert”. Diese Typen dokumentieren asynchrone Funktionen präzise und tauchen ab jetzt in jeder Signatur auf.

Promise-Ketten sind Callbacks weit überlegen, aber immer noch eine eigene Denkform. Die Schlüsselwörter async und await machen daraus Code, der wie gewöhnlicher, zeilenweiser Code aussieht:

async function printInvoiceTotal(userId: string): Promise<void> {
try {
const user = await loadUser(userId);
const orders = await loadOrders(user);
const invoice = await loadInvoice(orders[0]);
console.log(invoice.total);
} catch (error) {
console.error("failed:", error);
} finally {
console.log("done either way");
}
}

Die Regeln:

  • await darf nur in Funktionen stehen, die mit async markiert sind.
  • await promise pausiert diese Funktion, bis das Promise erfüllt ist, und liefert dessen Wert. Wichtig: Pausiert wird nur die Funktion, nicht das Programm; der Event Loop arbeitet währenddessen anderes ab.
  • Eine async-Funktion liefert immer ein Promise, auch wenn sie scheinbar einen normalen Wert zurückgibt (return invoice wird zu Promise<Invoice>).
  • Ein rejected Promise wird bei await zu einer geworfenen Exception; die Fehlerbehandlung ist damit das vertraute try/catch/finally, mit denselben Strategiefragen wie bei jeder Exception-Behandlung: fangen, wo eine sinnvolle Reaktion möglich ist, sonst weiterreichen.

async/await ist reine Schreibweise über Promises, kein neues Ausführungsmodell: Hinter jedem await steckt ein then. Wer den Event Loop verstanden hat, kann beide Formen lesen; im eigenen Code ist async/await der Standard, und then-Ketten bleiben für kleine Einzeiler.

Drei Fehler verursachen den Großteil aller Async-Bugs; alle drei sind mit TypeScript und etwas Aufmerksamkeit vermeidbar.

async function loadCount(): Promise<number> { /* ... */ }
const count = loadCount(); // forgot await
console.log(count + 1); // "[object Promise]1" nonsense

Ohne await landet das Promise selbst in der Variablen, nicht sein Wert. TypeScript macht daraus meist einen Compile-Fehler (Promise<number> ist keine number), einer der spürbarsten Vorteile der Typisierung in diesem Kapitel. Verwandt ist das Floating Promise: eine aufgerufene async-Funktion, deren Ergebnis niemand beachtet; Fehler darin verschwinden dann spurlos.

// sequential: second request starts only after the first finished
const user = await loadUser(id);
const settings = await loadSettings(id); // does NOT depend on user
// parallel: both start immediately, wait for both
const [user2, settings2] = await Promise.all([loadUser(id), loadSettings(id)]);

Jedes await wartet, bevor die nächste Zeile startet. Bei unabhängigen Operationen verschenkt das Zeit: Zwei Anfragen zu je einer Sekunde dauern nacheinander zwei Sekunden, parallel eine. Die Regel: Abhängige Schritte nacheinander awaiten, unabhängige gemeinsam starten. Die Koordinationswerkzeuge dafür (Promise.all und Verwandte) behandelt das nächste Kapitel im Detail; hier genügt das Muster.

// one after another: n * latency
for (const id of ids) {
results.push(await loadUser(id));
}
// all at once: roughly 1 * latency
const results = await Promise.all(ids.map((id) => loadUser(id)));

Die Schleifenform ist nicht falsch (manchmal ist Reihenfolge oder Schonung des Servers gewollt), aber sie muss eine Entscheidung sein, kein Versehen.

Ein Ausschnitt aus einem Render-Dashboard: Der Status mehrerer Render-Jobs wird simuliert abgefragt, mit Wartezeit, Fehlerfall und Messung.

interface RenderJob {
id: string;
state: "queued" | "running" | "done" | "failed";
}
function sleep(milliseconds: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, milliseconds));
}
async function fetchJobState(id: string): Promise<RenderJob> {
await sleep(300 + Math.random() * 200); // simulated network delay
if (id === "job-13") {
throw new Error(`job ${id}: render node not reachable`);
}
return { id, state: "running" };
}
async function report(ids: string[]): Promise<void> {
const start = performance.now();
try {
const jobs = await Promise.all(ids.map((id) => fetchJobState(id)));
for (const job of jobs) {
console.log(`${job.id}: ${job.state}`);
}
} catch (error) {
console.error("report incomplete:", (error as Error).message);
}
console.log(`took ${Math.round(performance.now() - start)} ms`);
}
await report(["job-11", "job-12", "job-14"]);

Messen Sie selbst: Mit drei Jobs dauert der parallele Bericht rund eine halbe Sekunde; die sequentielle Schleifenvariante das Dreifache. Und ersetzen Sie eine ID durch job-13, um den Fehlerpfad zu sehen: Ein einziges catch meldet den Ausfall, statt dass er irgendwo verschwindet.

Das folgende Video zeigt die Abarbeitung von Promises Schritt für Schritt animiert und ergänzt die Abschnitte oben um eine visuelle Darstellung von Microtasks, Task Queue und async/await.

JavaScript Visualized - Promise Execution von Lydia Hallie:

Abspielen

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:

  • Erklären: das Ausführungsmodell von JavaScript (Single Thread, Event Loop, Warteschlange) erklären und die Ausgabereihenfolge von Code mit Timern vorhersagen.
  • Erklären: die drei Promise-Zustände und den Weg von Callbacks über Promise-Ketten zu async/await beschreiben.
  • Anwenden: asynchrone Abläufe mit async/await schreiben, inklusive Fehlerbehandlung mit try/catch/finally.
  • Anwenden: asynchrone Funktionen in TypeScript mit Promise<T> typisieren.
  • Analysieren: die typischen Fallen (vergessenes await, unnötig sequentielle Abläufe, await in Schleifen) in fremdem Code erkennen und beheben.
  • Beurteilen: entscheiden, welche Schritte eines Ablaufs voneinander abhängen und welche parallel laufen können.
  • Aufgabe 04 - Reihenfolge-Rätsel
  • Aufgabe 05 - Promise-Werkstatt
  • Aufgabe 06 - Promise-Fehlerjagd