mitmario.dev

Parallel oder nacheinander

Node.js Sandbox 3 Min Lesezeit 4 BeispieleLektion 4 von 7

Du weißt jetzt, dass Warten nichts kostet. Der häufigste Fehler in echtem Node-Code ist trotzdem genau der, bei dem gewartet wird, obwohl niemand warten müsste. Und er sieht harmlos aus.

Die Schleife, die keiner für langsam hält

Die Schleife mit await darin
const holen = (nummer) =>
  new Promise((fertig) => setTimeout(() => fertig(`Datei ${nummer}`), 100));

const start = performance.now();
const ergebnisse = [];

for (const nummer of [1, 2, 3, 4, 5]) {
  ergebnisse.push(await holen(nummer));
}

console.log(`${ergebnisse.length} geholt`);
console.log(`Gedauert: etwa ${Math.round((performance.now() - start) / 100) * 100} ms`);

Fünf Vorgänge zu je hundert Millisekunden, macht fünfhundert. Das steht so da und ist sofort einleuchtend, sobald man es einmal gesehen hat. Beim Schreiben sieht man es nicht, weil await in einer Schleife völlig normal aussieht.

Bei fünf Dateien fällt das niemandem auf. Bei zwanzig sind es zwei Sekunden statt hundert Millisekunden, und dann wundert man sich, warum die Seite so lahm ist.

Erst alle starten, dann einmal warten

Dieselbe Arbeit mit Promise.all
const holen = (nummer) =>
  new Promise((fertig) => setTimeout(() => fertig(`Datei ${nummer}`), 100));

const start = performance.now();

const ergebnisse = await Promise.all([1, 2, 3, 4, 5].map(holen));

console.log(`${ergebnisse.length} geholt`);
console.log(`Gedauert: etwa ${Math.round((performance.now() - start) / 100) * 100} ms`);

Der Unterschied steckt in einer Kleinigkeit: [1, 2, 3, 4, 5].map(holen) ruft holen fünfmal auf, ohne dazwischen zu warten. Damit laufen fünf Vorgänge, und Promise.all wartet einmal auf alle zusammen.

Das Ergebnis ist ein Array in derselben Reihenfolge wie die Eingabe. Wer zuerst fertig ist, spielt keine Rolle, du bekommst die Ergebnisse sortiert zurück, wie du sie hineingegeben hast.

Der Haken an Promise.all

Promise.all ist ein Alles-oder-nichts-Versprechen.

Einer scheitert, alles scheitert
const holen = (nummer) =>
  nummer === 3
    ? Promise.reject(new Error("Datei 3 fehlt"))
    : Promise.resolve(`Datei ${nummer}`);

try {
  const ergebnisse = await Promise.all([1, 2, 3, 4, 5].map(holen));
  console.log(`${ergebnisse.length} geholt`);
} catch (fehler) {
  console.log(`Abgebrochen: ${fehler.message}`);
  console.log("Von den vier heilen Dateien ist nichts uebrig.");
}

Sobald ein einziges Promise scheitert, wirft Promise.all sofort. Die vier anderen sind gelaufen oder laufen noch, aber du kommst an ihre Ergebnisse nicht mehr heran. Das Promise, das dir sie gegeben hätte, ist bereits im Fehlerzustand.

Wenn dein Programm ohne alle Teile ohnehin nicht weiterkann, ist das genau richtig. Wenn nicht, ist es ein Datenverlust auf Ansage.

allSettled, wenn jedes Ergebnis für sich zählt

allSettled behält jedes Ergebnis
const holen = (nummer) =>
  nummer === 3
    ? Promise.reject(new Error("Datei 3 fehlt"))
    : Promise.resolve(`Datei ${nummer}`);

const ergebnisse = await Promise.allSettled([1, 2, 3, 4, 5].map(holen));

for (const eintrag of ergebnisse) {
  console.log(
    eintrag.status === "fulfilled"
      ? `ok: ${eintrag.value}`
      : `fehlt: ${eintrag.reason.message}`,
  );
}

Promise.allSettled wartet ab, bis alle fertig sind, und wirft nie. Du bekommst für jedes ein Objekt: status ist fulfilled oder rejected, und dazu gibt es entweder ein value oder ein reason.

Der Preis ist, dass du die Ergebnisse selbst auseinandersortieren musst. Dafür verlierst du nichts.

Die beiden Geschwister dazu, je in einem Satz: Promise.race gibt dir das erste Ergebnis, egal ob Erfolg oder Fehler, und ist der übliche Weg für eine Zeitgrenze. Promise.any gibt dir das erste erfolgreiche und scheitert nur, wenn alle scheitern.

Wann nacheinander richtig ist

Gleichzeitig ist nicht immer besser. Nacheinander ist richtig, wenn der zweite Vorgang das Ergebnis des ersten braucht. Erst anmelden, dann mit der Kennung weiterarbeiten, da hilft keine Gleichzeitigkeit.

Und es ist richtig, wenn die Gegenstelle nicht mehr verträgt. Eine fremde Schnittstelle mit einem Limit von zehn Anfragen pro Sekunde freut sich nicht über zweihundert auf einmal, und eine Datenbank mit zwanzig Verbindungen im Pool auch nicht.

Damit zur Zahl, die man im Kopf haben sollte: „alle gleichzeitig” heißt bei fünftausend Dateien wirklich fünftausend gleichzeitig. Promise.all fragt nicht nach, ob das eine gute Idee ist. Es öffnet fünftausend Dateihandles, und dann steht in der Fehlermeldung EMFILE: too many open files.

Wo die Grenze liegt, hängt vom Fall ab. Die Faustregel ist einfacher: Bei einer festen, kleinen Liste nimm Promise.all oder allSettled ohne nachzudenken. Bei einer Liste, deren Länge von außen kommt, denk kurz nach. Wie man eine Obergrenze von Hand baut, steht in Lektion 6.6.

Zum Mitnehmen

Ein await in einer Schleife wartet zwanzigmal hintereinander. Promise.all wartet einmal auf alle. Und bei Promise.all reißt ein Fehlschlag alles mit.

Jetzt du

Basis Konto, kostenlos

Zu dieser Lektion gehört eine Aufgabe. Du schreibst den Code selbst, und nach jedem Lauf sagt dir eine Prüfliste, was schon stimmt.

Dafür brauchst du das Basis Konto. Es kostet nichts, und ein Passwort gibt es auch nicht.

In diesem Kurs läuft dein Code auf einem Server. Dafür hat das Basis Konto 1 Stunde im Monat, mehr Zeit gibt es mit dem Premium Konto.

Was in dieser Lektion steckt

  • Artikel mit 4 Beispielen zum Ausprobieren

    Steht hier, ohne Konto lesbar.

  • Aufgabe, dein Code läuft auf einem Server

    Öffnet sich mit dem Basis Konto.