mitmario.dev

Mehrere Versprechen

JavaScript Im Browser 5 Min Lesezeit 3 BeispieleLektion 6 von 8

await in einer Schleife ist der Punkt, an dem eine Seite ohne Not langsam wird. Zehn Anfragen, jede wartet auf die vorige, und am Ende dauert es zehnmal so lange wie nötig.

Diese Lektion zeigt, wie man mehrere Dinge gleichzeitig anstößt und dann auf alle zusammen wartet.

Promise.all

Nacheinander gegen gleichzeitig
function laden(marke) {
  return new Promise((aufloesen) => {
    setTimeout(() => aufloesen(marke), 300);
  });
}

// Nacheinander: jedes await wartet auf das vorige.
async function nacheinander() {
  const start = Date.now();
  const a = await laden("a");
  const b = await laden("b");
  const c = await laden("c");
  console.log("nacheinander:", [a, b, c], Date.now() - start, "ms");
}

// Gleichzeitig: alle drei sind gestartet, BEVOR gewartet wird.
async function gleichzeitig() {
  const start = Date.now();
  const ergebnisse = await Promise.all([laden("a"), laden("b"), laden("c")]);
  console.log("gleichzeitig:", ergebnisse, Date.now() - start, "ms");
}

nacheinander().then(gleichzeitig);

Promise.all bekommt eine Liste von Promises und gibt ein einziges Promise zurück. Das löst auf, wenn alle aufgelöst haben, und liefert eine Liste der Ergebnisse.

Zwei Eigenschaften davon sind wichtig:

Die Reihenfolge der Ergebnisse ist die Reihenfolge der Liste, nicht die Reihenfolge, in der sie fertig geworden sind. Auch wenn das dritte zuerst antwortet, steht sein Ergebnis an dritter Stelle. Damit kannst du direkt destrukturieren, so wie in Lektion 7.5.

Beim ersten Fehler bricht alles ab. Wird auch nur eines der Promises abgelehnt, wird das Gesamt-Promise sofort abgelehnt, mit genau diesem Grund. Die anderen laufen zwar zu Ende, ihre Ergebnisse bekommst du aber nicht mehr zu sehen.

Im ersten Beispiel siehst du den Unterschied in Zahlen: dreimal 300 Millisekunden nacheinander sind rund 900, gleichzeitig sind es rund 300.

Die Zahl sagt dir, dass es schneller ist. Warum, das steht im Reiter Debug, und das ist hier die interessantere Hälfte. Zeichne auf und sieh dir das Muster der Schritte an.

In der ersten Fassung wechseln sie sich ab: const a = await laden("a"), dann die zwei Zeilen aus laden, dann const b = await laden("b"), dann wieder die zwei Zeilen, dann dasselbe für c. Zwischen je zwei Aufrufen steht eine Zeile der wartenden Funktion, und genau dort ist sie zwischendurch weggegangen.

In der Fassung mit Promise.all steht eine Zeile mit dem await, und die zwei Zeilen aus laden kommen dreimal hintereinander, ohne dass etwas dazwischen liegt: marke=a, marke=b, marke=c. Alle drei Timer sind zu diesem Zeitpunkt schon angemeldet. Danach folgt genau eine Fortsetzung.

Das ist der ganze Unterschied, und er hat nichts mit Geschwindigkeit zu tun: Dreimal warten hintereinander gegen einmal warten auf drei.

Promise.allSettled

all bricht ab, allSettled nicht
function laden(marke, scheitert) {
  return new Promise((aufloesen, ablehnen) => {
    setTimeout(() => {
      if (scheitert) {
        ablehnen(new Error(`${marke} nicht erreichbar`));
        return;
      }
      aufloesen(marke);
    }, 150);
  });
}

async function mitAll() {
  try {
    const alle = await Promise.all([
      laden("a", false),
      laden("b", true),
      laden("c", false),
    ]);
    console.log("all:", alle);
  } catch (fehler) {
    console.warn("all bricht beim ersten Fehler ab:", fehler.message);
  }
}

async function mitAllSettled() {
  const alle = await Promise.allSettled([
    laden("a", false),
    laden("b", true),
    laden("c", false),
  ]);
  console.log("allSettled roh:", alle);
  console.log(
    "allSettled ausgewertet:",
    alle.map((eintrag) => (eintrag.status === "fulfilled" ? eintrag.value : null))
  );
}

mitAll().then(mitAllSettled);

Promise.allSettled macht dasselbe, nur ohne das Abbrechen. Es wartet, bis alle fertig sind, egal wie sie ausgegangen sind, und wird selbst nie abgelehnt.

Dafür sehen die Ergebnisse anders aus. Statt der Werte bekommst du je Eintrag ein kleines Objekt:

  • Bei Erfolg: status steht auf "fulfilled", der Wert liegt in value.
  • Bei Ablehnung: status steht auf "rejected", der Grund liegt in reason.

Im zweiten Beispiel steht diese rohe Liste in der Console, und du kannst sie dort aufklappen: {status: "fulfilled", value: "a"}, dann {status: "rejected", reason: Error: b nicht erreichbar}, dann wieder ein fulfilled. Darunter steht dieselbe Liste noch einmal, ausgewertet zu ["a", null, "c"].

Deshalb steht hinter einem allSettled fast immer ein map, das daraus wieder eine brauchbare Liste macht.

Wann nimmt man was? all ist richtig, wenn ein Teilergebnis wertlos ist: Wenn du drei Teile einer Rechnung lädst und einer fehlt, willst du keine falsche Summe anzeigen. allSettled ist richtig, wenn die Ansicht auch mit Lücken funktioniert: acht Kacheln, von denen eine nicht laden konnte, sind besser als eine leere Seite.

race und any

Zwei weitere gibt es noch, beide in einem Satz erklärt.

Promise.race liefert das Ergebnis des ersten, das fertig wird, egal ob erfüllt oder abgelehnt. Der typische Einsatz ist ein Zeitlimit: Man lässt die echte Anfrage gegen einen Timer antreten, der nach fünf Sekunden ablehnt, und nimmt, was zuerst da ist.

Promise.any liefert das erste, das erfolgreich ist, und ignoriert Ablehnungen, solange noch Hoffnung besteht. Praktisch, wenn man dieselbe Sache von mehreren Quellen holen kann und nur eine davon braucht.

Beide begegnen dir seltener als die ersten zwei. Es reicht, zu wissen, dass es sie gibt.

Der Fehler, der alles wieder langsam macht

Der Fehler, der alles wieder langsam macht
function laden(marke) {
  return new Promise((aufloesen) => {
    setTimeout(() => aufloesen(marke), 300);
  });
}

// Richtig: laden() wird sofort aufgerufen, alle drei laufen.
async function richtig() {
  const start = Date.now();
  const aufgaben = [laden("a"), laden("b"), laden("c")];
  const alle = await Promise.all(aufgaben);
  console.log("richtig:", alle, Date.now() - start, "ms");
}

// Falsch: hier steht in der Liste je ein await, und damit
// ist die Reihe längst abgearbeitet, bevor Promise.all
// überhaupt etwas zu tun bekommt.
async function falsch() {
  const start = Date.now();
  const alle = [await laden("a"), await laden("b"), await laden("c")];
  console.log("falsch:", alle, Date.now() - start, "ms");
}

richtig().then(falsch);

Und jetzt der Punkt, an dem Promise.all gerne wirkungslos eingebaut wird.

Die Aufrufe müssen gestartet sein, bevor gewartet wird. Ein Promise beginnt zu arbeiten, sobald es entsteht, nicht wenn man es awaitet. Deshalb ist die richtige Reihenfolge: erst alle anstoßen, dann await auf die Liste.

Im dritten Beispiel steht in der falschen Fassung [await laden("a"), await laden("b"), await laden("c")]. Das sieht nach einer Liste aus und ist keine Gleichzeitigkeit: Jedes await hält an, bis es fertig ist, und wenn Promise.all die Liste bekommt, ist alles längst gelaufen. Die gemessene Zeit sagt es dir.

Und hier stößt der Reiter „Debug” an seine Grenze, was man einmal gesehen haben sollte. Zeichne dieses Beispiel auf: Beide Fassungen sehen im Protokoll gleich aus, dreimal derselbe Rumpf hintereinander, obwohl die eine 300 Millisekunden braucht und die andere 900. Der Grund ist, dass beide Fassungen dieselben Anweisungen in derselben Reihenfolge ausführen. Was sie unterscheidet, sind die Pausen dazwischen, und die stehen nirgends: Das Protokoll zählt Schritte, keine Zeit. Im ersten Beispiel war der Unterschied sichtbar, weil dort jedes await eine eigene Zeile hatte. Hier stehen alle drei in einer.

Dasselbe passiert mit einer Schleife, in der await steht. Wenn die Durchläufe voneinander unabhängig sind, sammel stattdessen die Promises ein und wart am Ende einmal auf alle.

Und bei sehr vielen?

Eine praktische Grenze zum Schluss: Zweihundert Anfragen gleichzeitig loszuschicken ist selten eine gute Idee. Der Browser drosselt selbst, aber der Server am anderen Ende freut sich trotzdem nicht.

Für solche Fälle arbeitet man in Blöcken, etwa zehn auf einmal. Dafür braucht es keine Bibliothek, nur eine Schleife über Ausschnitte der Liste und je ein Promise.all darin. Für diesen Kurs ist das nicht nötig, aber es ist gut zu wissen, dass „alle auf einmal” nicht immer die Antwort ist.

Zum Mitnehmen

Erst alle anstoßen, dann warten. Wer im await schon startet, macht aus gleichzeitig ein nacheinander.

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.

Was in dieser Lektion steckt

  • Artikel mit 3 Beispielen zum Ausprobieren

    Steht hier, ohne Konto lesbar.

  • Aufgabe im Editor, direkt im Browser geprüft

    Öffnet sich mit dem Basis Konto.