3. Nebenläufigkeit I - Asynchrones JavaScript
Nebenläufigkeit I: Asynchrones JavaScript
Abschnitt betitelt „Nebenläufigkeit I: Asynchrones JavaScript“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.
Der Event Loop: eine Spur, viele Aufgaben
Abschnitt betitelt „Der Event Loop: eine Spur, viele Aufgaben“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, twoObwohl 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:
- Asynchron heißt später, nie zwischendurch. Laufender Code wird nie unterbrochen; Callbacks laufen erst, wenn der Stack leer ist.
- Blockieren blockiert alles. Eine Endlosschleife oder eine schwere Berechnung legt auch alle wartenden Callbacks lahm, im Browser die komplette Oberfläche.
Callbacks und ihr Problem
Abschnitt betitelt „Callbacks und ihr Problem“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.
Promises: ein Platzhalter für später
Abschnitt betitelt „Promises: ein Platzhalter für später“Ein Promise ist ein Objekt, das ein zukünftiges Ergebnis vertritt. Es befindet sich in genau einem von drei Zuständen:
| Zustand | Bedeutung |
|---|---|
pending | Ergebnis steht noch aus |
fulfilled | erfolgreich, Wert liegt vor |
rejected | fehlgeschlagen, 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.
async und await: asynchron schreiben wie synchron
Abschnitt betitelt „async und await: asynchron schreiben wie synchron“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:
awaitdarf nur in Funktionen stehen, die mitasyncmarkiert sind.await promisepausiert 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 invoicewird zuPromise<Invoice>). - Ein
rejectedPromise wird beiawaitzu einer geworfenen Exception; die Fehlerbehandlung ist damit das vertrautetry/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.
Die typischen Fallen
Abschnitt betitelt „Die typischen Fallen“Drei Fehler verursachen den Großteil aller Async-Bugs; alle drei sind mit TypeScript und etwas Aufmerksamkeit vermeidbar.
Das vergessene await
Abschnitt betitelt „Das vergessene await“async function loadCount(): Promise<number> { /* ... */ }
const count = loadCount(); // forgot awaitconsole.log(count + 1); // "[object Promise]1" nonsenseOhne 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.
Sequentiell statt parallel
Abschnitt betitelt „Sequentiell statt parallel“// sequential: second request starts only after the first finishedconst user = await loadUser(id);const settings = await loadSettings(id); // does NOT depend on user
// parallel: both start immediately, wait for bothconst [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.
await in Schleifen
Abschnitt betitelt „await in Schleifen“// one after another: n * latencyfor (const id of ids) { results.push(await loadUser(id));}
// all at once: roughly 1 * latencyconst 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 vollständiges Beispiel
Abschnitt betitelt „Ein vollständiges Beispiel“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.
Zum Weiterschauen: Promises visualisiert
Abschnitt betitelt „Zum Weiterschauen: Promises visualisiert“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:
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/awaitbeschreiben. - Anwenden: asynchrone Abläufe mit
async/awaitschreiben, inklusive Fehlerbehandlung mittry/catch/finally. - Anwenden: asynchrone Funktionen in TypeScript mit
Promise<T>typisieren. - Analysieren: die typischen Fallen (vergessenes
await, unnötig sequentielle Abläufe,awaitin Schleifen) in fremdem Code erkennen und beheben. - Beurteilen: entscheiden, welche Schritte eines Ablaufs voneinander abhängen und welche parallel laufen können.
Passende Übungen
Abschnitt betitelt „Passende Übungen“- Aufgabe 04 - Reihenfolge-Rätsel
- Aufgabe 05 - Promise-Werkstatt
- Aufgabe 06 - Promise-Fehlerjagd