aslain.dev
0%
01 Hizmetler 02 Hakkımda 03 Projeler 04 Stack 05 Blog 06 İletişim
← Tüm makaleler Webentwicklung

Node.js async/await: Leitfaden zur asynchronen Programmierung

Der node async await Ablauf wirkt auf den ersten Blick verwirrend: Callbacks, Promises und async/await scheinen alle dieselbe Aufgabe auf unterschiedliche Weise zu erledigen. In Wirklichkeit sind sie drei Generationen derselben Idee. Node.js läuft auf einem einzigen Thread, und Operationen wie das Lesen einer Datei, eine Datenbankabfrage oder ein HTTP-Request brauchen Zeit. In diesem Leitfaden lernst du, warum ein asynchroner Ablauf nötig ist, wie wir von der Callback-Hölle zu Promises und dann zur sauberen Syntax von async/await gekommen sind, wie du Fehler behandelst und wie du Arbeit parallel ausführst.

Warum asynchron? Ein kurzer Blick auf die Event Loop

Node.js läuft auf einem einzigen Haupt-Thread und ist auf dem Prinzip aufgebaut, diesen Thread niemals zu blockieren. Eine Datei von der Festplatte zu lesen dauert Millisekunden; würde Node diese ganze Zeit einfach warten, könnte es währenddessen keine andere eingehende Anfrage verarbeiten. Stattdessen übergibt es die Operation an das Betriebssystem, widmet sich anderer Arbeit und kommt zurück, sobald das Ergebnis bereit ist. Dieser Mechanismus heißt Event Loop.

Eine wichtige Unterscheidung: Asynchroner Code bedeutet nicht parallel laufende Threads. Es gibt einen einzigen Thread; nur die Wartezeiten werden clever verwaltet. Deshalb blockiert eine lange CPU-Berechnung (etwa eine riesige Schleife) trotzdem die Event Loop — async/await hilft dort nicht; es ist nur für Operationen gedacht, die auf I/O warten.

Erste Generation: Callbacks und die „Callback-Hölle"

In den Anfangstagen von Node wurden asynchrone Operationen mit Callback-Funktionen verwaltet. Das Ergebnis einer Operation kommt über eine Funktion zurück, die du als Argument übergibst. Die Konvention von Node ist der „Error-First-Callback": Der erste Parameter ist der Fehler, der zweite das Ergebnis.

const fs = require("fs");

fs.readFile("a.txt", "utf8", (err, data) => {
  if (err) return console.error(err);
  console.log(data);
});

Für eine einzelne Operation ist das in Ordnung. Aber sobald Operationen voneinander abhängen, verwandelt sich der Code in eine Pyramide, die nach rechts driftet. Das nennt man Callback-Hölle (callback hell):

readFile("a.txt", (err, a) => {
  readFile("b.txt", (err, b) => {
    readFile("c.txt", (err, c) => {
      // die Fehlerprüfung wiederholt sich auf jeder Ebene
    });
  });
});

Diese Struktur ist schwer zu lesen, schwer bei der Fehlerbehandlung und schwer zu warten. Promises kamen genau, um dieses Problem zu lösen.

Zweite Generation: Was ist ein Promise?

Ein Promise ist ein Objekt, das „einen Wert, der noch nicht bereit ist, aber in der Zukunft eintrifft" repräsentiert. Es hat drei Zustände: pending (ausstehend), fulfilled (erfolgreich abgeschlossen) und rejected (mit einem Fehler beendet). Das Ergebnis fängst du mit .then() ab, den Fehler mit .catch():

const fs = require("fs/promises");

fs.readFile("a.txt", "utf8")
  .then((data) => console.log(data))
  .catch((err) => console.error(err));

Die wahre Stärke von Promises ist das Verketten (chaining). Jedes .then() gibt ein neues Promise zurück, sodass du statt einer Pyramide einen flachen Ablauf erhältst. Trotzdem sinkt die Lesbarkeit bei sehr langen Ketten — und hier kommt async/await ins Spiel.

Dritte Generation: synchron aussehender Code mit node async await

async/await ist syntaktischer Zucker (syntactic sugar), der auf Promises aufbaut; darunter laufen weiterhin Promises. Wenn du eine Funktion mit async markierst, kannst du darin await verwenden. await „wartet" in dieser Zeile, bis ein Promise aufgelöst wird, blockiert aber die Event Loop nicht.

const fs = require("fs/promises");

async function lesen() {
  const a = await fs.readFile("a.txt", "utf8");
  const b = await fs.readFile("b.txt", "utf8");
  return a + b;
}

Dieser Code erledigt dieselbe Aufgabe wie die Callback-Pyramide, liest sich aber von oben nach unten, wie synchroner Code. Drei Regeln, die du beachten solltest:

  • await kann nur innerhalb einer async-Funktion verwendet werden (oder, in modernem Node, auf der obersten Ebene von Modulen).
  • Eine async-Funktion gibt immer ein Promise zurück; was auch immer du darin per return zurückgibst, der Aufrufer erhält es über await oder .then().
  • await beschleunigt nicht nur I/O; CPU-intensive Arbeit blockiert weiterhin den Haupt-Thread.

Fehlerbehandlung: sauberes Abfangen mit try/catch

Bei Callbacks musstest du auf jeder Ebene if (err) schreiben. Mit async/await verwendest du try/catch genau wie in gewöhnlichem, synchronem Code. Das ist einer der größten praktischen Vorteile von async/await:

async function lesen() {
  try {
    const data = await fs.readFile("fehlt.txt", "utf8");
    return data;
  } catch (err) {
    console.error("Lesen fehlgeschlagen:", err.message);
    return null;
  }
}

Wird ein await-Ausdruck abgelehnt (rejected), wird der catch-Block ausgelöst. Fängst du den Fehler nicht ab, wird daraus eine unhandled rejection, die in modernen Node-Versionen den Prozess zum Absturz bringen kann. Daher braucht jede asynchrone Operation eine Fehlerstrategie: entweder ein lokales try/catch oder eine Promise-Ablehnung, die weiter oben vom Aufrufer abgefangen wird.

Parallel ausführen: Promise.all und allSettled

Ein häufiger Fehler ist, unabhängige Operationen ohne Grund nacheinander abzuwarten. Der folgende Code liest zwei Dateien sequenziell; die zweite wartet, bis die erste fertig ist:

const a = await fs.readFile("a.txt", "utf8"); // diese endet zuerst
const b = await fs.readFile("b.txt", "utf8"); // dann startet diese

Wenn die Operationen nicht voneinander abhängen, ist es viel schneller, beide gleichzeitig zu starten und zusammen abzuwarten. Dafür verwendest du Promise.all:

const [a, b] = await Promise.all([
  fs.readFile("a.txt", "utf8"),
  fs.readFile("b.txt", "utf8"),
]);

Promise.all wird als Ganzes abgelehnt, wenn auch nur eine seiner Operationen abgelehnt wird. Möchtest du zulassen, dass einige Operationen fehlschlagen, und trotzdem jedes Ergebnis sehen, verwende Promise.allSettled; es gibt den Status (fulfilled/rejected) jeder Operation einzeln zurück.

Häufige Fehler

  • await mit forEach in einer Schleife. array.forEach(async ...) funktioniert nicht wie erwartet; forEach wartet nicht auf Promises. Verwende ein klassisches for...of, um sequenziell zu warten, oder Promise.all(array.map(...)), um parallel auszuführen.
  • await vergessen. Ohne await enthält die Variable ein nicht aufgelöstes Promise-Objekt, nicht den Wert.
  • Unnötiges sequenzielles Warten. Unabhängige Operationen einzeln abzuwarten verlangsamt deine Anfragen ohne Grund.

Häufige Fragen

Sollte ich async/await oder .then() verwenden?

Beide nutzen dieselbe Promise-Grundlage; die Wahl ist eine Frage der Lesbarkeit. In Abläufen mit vielen abhängigen Schritten liest sich async/await meist sauberer. Für eine einzelne kurze Transformation kann .then() praktisch sein. Innerhalb derselben Codebasis konsistent zu bleiben ist am besten.

Verlangsamt await den Code?

await allein verlangsamt den Code nicht; es verzögert nur den Fortschritt der Funktion, bis dieses Promise aufgelöst ist, und die Event Loop erledigt währenddessen weiter andere Arbeit. Langsamkeit entsteht meist dadurch, unabhängige Operationen ohne Grund sequenziell abzuwarten — parallelisiere sie mit Promise.all.

Reicht async/await für CPU-intensive Arbeit?

Nein. async/await verwaltet nur I/O-Wartezeiten effizient. Eine schwere Berechnung (Verschlüsselung, Bildverarbeitung) blockiert weiterhin den Haupt-Thread. Dafür solltest du das Modul worker_threads oder einen separaten Prozess in Betracht ziehen.

Möchtest du den asynchronen Ablauf in deinem Node.js-Projekt in Ordnung bringen? Um eine zur Callback-Hölle gewordene Codebasis mit async/await aufzuräumen, die Performance durch Parallelität zu steigern oder von Grund auf eine solide API zu bauen, nimm Kontakt mit mir auf.

Bu kategorideki tüm yazılar →

Devamı için