Startseite / Artikel / Die Wahl zwischen Promise.all, Promise.race und sequentiellen Awaits

Die Wahl zwischen Promise.all, Promise.race und sequentiellen Awaits

Erfahren Sie, wann Promise.all() Node.js-APIs beschleunigt, warum es bei jeder Ablehnung schnell versagt, sowie ein Entscheidungsframework zur Auswahl des richtigen asynchronen Musters.

2267 Wörter

Das Ausführen asynchroner Aufgaben in Node.js scheint zunächst wie ein einfacher Vorteil: Mehrere Operationen werden gleichzeitig gestartet anstatt nacheinander abgewartet, wodurch die API schneller wird. In der Praxis kann es jedoch dazu führen, dass ein Geschwindigkeitsvorteil durch die routinemäßige Verwendung von Promise.all() anstelle einer bewussten Entscheidung zu einem Zuverlässigkeitsproblem wird. Es ist genauso wichtig zu wissen, wann Operationen sicher parallelisiert werden können, was passieren sollte, wenn eine davon fehlschlägt, und wann ein anderes Werkzeug besser geeignet ist, wie die Syntax selbst zu kennen.

1. Der sequenzielle Ansatz

Stellen Sie sich eine API vor, die drei Dinge zusammenholen muss:

  • Benutzerinformationen
  • Bestellungen
  • Zahlungen

Ein naiver erster Ansatz könnte so aussehen:

const user = await getUser(userId);
const orders = await getOrders(userId);
const payments = await getPayments(userId);
return {
  user,
  orders,
  payments
};

Dieser Code funktioniert einwandfrei. Achten Sie jedoch genau auf die Reihenfolge der Ausführung:

getUser()
   ↓
getOrders()
   ↓
getPayments()

Jeder Schritt wartet darauf, dass der vorherige abgeschlossen ist. Der Abruf der Bestellungen kann erst beginnen, nachdem der Abruf des Benutzers abgeschlossen ist, und der Abruf der Zahlungen kann erst beginnen, nachdem der Abruf der Bestellungen abgeschlossen ist. Wenn jeder Aufruf ungefähr die gleiche Zeit in Anspruch nimmt:

getUser()      = 200ms
getOrders()    = 200ms
getPayments()  = 200ms

dann addiert sich die Gesamtanfristzeit auf etwa:

200 + 200 + 200 = 600ms

Das ist Zeitverschwendung, wenn diese drei Aufrufe nichts miteinander zu tun haben.

2. Promise.all() verwenden

Wenn die Operationen nicht voneinander abhängig sind, können Sie sie stattdessen gleichzeitig ausführen:

const [user, orders, payments] = await Promise.all([
  getUser(userId),
  getOrders(userId),
  getPayments(userId)
]);
return {
  user,
  orders,
  payments
};

Der Ausführungsfloß sieht jetzt eher so aus:

getUser()       ────────┐
                        │
getOrders()     ────────┤
                        ├──→ Promise.all()
getPayments()   ────────┘

Anstatt den Aufwand für:

A + B + C

ist Ihre Gesamtwartezeit ungefähr:

max(A, B, C)

Falls jeder der drei Aufrufe weiterhin etwa 200 ms dauert:

Sequential:  ~600ms
Parallel:    ~200ms

Das ist ein bedeutender Vorteil. Doch es gibt eine Nuance, die man im Gedächtnis behalten sollte:

Promise.all() beschleunigt keine einzelne Operation.

Es ermöglicht lediglich, unabhängige Operationen gleichzeitig statt nacheinander auszuführen.

3. Ein Beispiel für eine reale API

Betrachten wir einen Dashboard-Bildschirm, der folgendes anzeigen muss:

Profile
Orders
Wishlist
Notifications

Diese könnten auf vier separate Datenbankabfragen oder Serviceaufrufe hindeuten. Wenn sie sequenziell geschrieben werden, könnte das so aussehen:

const profile = await getUserProfile(userId);
const orders = await getUserOrders(userId);const wishlist = await getUserWishlist(userId);const notifications = await getUserNotifications(userId);return {
  profile,
  orders,
  wishlist,
  notifications
};

Vergleichen wir das nun mit der parallelen Version:

const [
  profile,
  orders,
  wishlist,
  notifications
] = await Promise.all([
  getUserProfile(userId),
  getUserOrders(userId),
  getUserWishlist(userId),
  getUserNotifications(userId)
]);
return {
  profile,
  orders,
  wishlist,
  notifications
}

Vorausgesetzt, diese vier Aufrufe hängen tatsächlich nicht voneinander ab, kann diese Umformulierung die Gesamtlatenz der API erheblich verringern. Es handelt sich dabei um einen der einfachsten Vorteile bei der Optimierung der Leistung von Node.js.

Aber das ist noch nicht das Ende der Geschichte – es gibt eine Einschränkung, die Sie verstehen müssen, bevor Sie dieses Muster überall anwenden.

4. Promise.all() scheitert schnell

Das ist der Punkt, der viele Menschen in Verwirrung bringt. Nehmen wir folgendes Beispiel:

const results = await Promise.all([
  getUser(),
  getOrders(),
  getPayments()
]);

Was passiert, wenn getPayments() eine Ablehnung auslöst?

Der gesamte Aufruf von Promise.all() wird abgelehnt, unabhängig davon, wie die anderen Aufrufe verlaufen sind:

getUser()       → SUCCESS
getOrders()     → SUCCESS
getPayments()   → ERROR
                ↓          Promise.all()
                ↓
             REJECT

Es erhalten Sie kein teilweises Ergebnis mit den beiden erfolgreichen Aufrufen sowie keinen Marker für den fehlgeschlagenen. Stattdessen erhalten Sie eine Ausnahme, und keine der Daten wird übermittelt:

try {
  const [user, orders, payments] = await Promise.all([
    getUser(userId),
    getOrders(userId),
    getPayments(userId)
  ]);
} catch (error) {
  console.error(error);
}

Dieses Alles-oder-Nichts-Verhalten macht Sinn, wenn jede Operation in der Gruppe erforderlich ist, damit die Antwort gültig ist. Doch es eignet sich nicht immer als geeignete Lösung.

5. Wann Promise.all() die falsche Wahl ist

Nehmen wir an, ein Dashboard muss anzeigen:

  • Profil
  • Empfehlungen
  • Benachrichtigungen
  • Falls der Empfehlungsdienst zufällig vorübergehend nicht verfügbar ist, muss dann die gesamte Konsole nicht laden? Fast sicherlich nicht. Ein besseres Ergebnis wäre etwas wie:

    Profile          → Available
    Notifications    → Available
    Recommendations  → Unavailable
    

    Genau für diese Situation wurde Promise.allSettled() entwickelt:

    const results = await Promise.allSettled([
      getUserProfile(userId),
      getRecommendations(userId),
      getNotifications(userId)
    ]);
    

    Anstatt bei der ersten Ablehnung aufzuhören, wartet es darauf, dass jede Promise abgeschlossen ist, und berichtet über alle davon:

    [
      {
        status: "fulfilled",
        value: profile
      },
      {
        status: "rejected",
        reason: error
      },
      {
        status: "fulfilled",
        value: notifications
      }
    ]
    

    Daraufhin kann die Anwendungslogik entscheiden, wie mit jedem einzelnen Ergebnis umgegangen werden soll. Der Unterschied besteht darin:

    Promise.all()
    
    One fails
       ↓
    Everything rejects
    

    im Vergleich zu:

    Promise.allSettled()
    
    One fails
       ↓
    You still receive every result
    

    Keiner der Ansätze ist von Natur aus überlegen – sie sind dazu konzipiert, unterschiedliche Probleme zu lösen, und die Wahl des richtigen hängt davon ab, ob ein einziger Fehler als fatal für die gesamte Gruppe betrachtet werden soll.

    6. Kette von Operationen sollten nicht zwangsläufig parallel ausgeführt werden

    Es gibt noch eine weitere Falle, auf die hingewiesen werden sollte.

    Nehmen wir an, Ihr Workflow erfordert von Ihnen:

    1. Einen Benutzer anzulegen
    2. Die ID dieses Benutzers abzurufen
    3. Eine Bestellung, die mit diesem Benutzer verknüpft ist, anzulegen

    Diese Schritte sind voneinander abhängig.

    Man kann physisch keine Bestellung anlegen, bevor das Benutzerkonto existiert.

    Das bedeutet, dass etwas wie folgt falsch ist:

    await Promise.all([
      createUser(),
      createOrder()
    ]);
    

    Der Schritt zur Anlage der Bestellung benötigt vermutlich die ID des Benutzers als Eingabe.

    Der richtige Ansatz besteht darin, diese Schritte nacheinander auszuführen:

    const user = await createUser();
    
    const order = await createOrder(user.id);
    

    Das Leitprinzip hier ist einfach:

    Operationen, die keine Beziehung zueinander haben, eignen sich für parallele Ausführung.

    Operationen, die von den Ergebnissen anderer abhängen, müssen nacheinander ausgeführt werden.

    Greifen Sie nicht einfach auf Parallelismus zurück, nur weil die Sprache es ermöglicht.

    7. Unbegrenzter Parallelismus kann Ihr System überlasten

    Es gibt ein subtileres Problem, das leicht übersehen wird.

    Betrachten Sie diesen Codeausschnitt:

    await Promise.all(
      users.map(user => sendEmail(user.email))
    );
    

    Bei 10 Benutzern ist es unwahrscheinlich, dass Probleme entstehen.

    Bei 10.000 Benutzern werden nun Tausende von gleichzeitigen Operationen gestartet.

    Mehr Konkurrenz bedeutet nicht automatisch bessere Leistung.

    Mögliche Probleme sind:

    • Beschränkungen bei Datenbankverbindungen
    • API-Geschwindigkeitsbegrenzungen
    • Arbeitsspeicherbelastung
    • Netzwerküberlastung
  • Einschränkungen durch Dienste Dritter
  • Anstieg der Fehlerraten
  • Statt einer unbegrenzten parallelen Ausführung benötigt man oft begrenzte Konkurrenz.

    Eine Möglichkeit, dies zu erreichen, ist die Verwendung einer Bibliothek zur Begrenzung der Konkurrenz:

    import pLimit from "p-limit";
    
    const limit = pLimit(5);const results = await Promise.all(
      users.map(user =>
        limit(() => sendEmail(user.email))
      )
    );
    

    Mit dieser Einstellung werden gleichzeitig nicht mehr als fünf Operationen ausgeführt.

    Visuell sieht der Unterschied so aus:

    1000 tasks
    
         ↓Concurrency limit = 5     ↓5 tasks
    5 tasks
    5 tasks
    5 tasks
    ...
    

    Das ist langsamer als das gleichzeitige Starten aller 1.000 Aufgaben.

    Aber es bietet weitaus mehr Kontrolle.

    In realen Bedingungen liefert eine kontrollierte Ausführung oft insgesamt bessere Ergebnisse, da sie verhindert, dass die Ressourcen, von denen die Operationen abhängen, überlastet werden.

    8. Promise.race() löst ein anderes Problem

    Es gibt noch eine Methode, die mit Promise.all() verwechselt wird:

    Promise.race()
    

    Promise.race() entscheidet – entweder durch Erfüllung oder Ablehnung – in dem Moment, in dem die erste Promise in der Gruppe entschieden hat.

    Zum Beispiel:

    const result = await Promise.race([
      serverA(),
      serverB()
    ]);
    

    Bildlich dargestellt:

    Server A ───────────────→ 500ms
    
    Server B ───────→ 200ms                    ↓
                   Promise.race()
                        ↓
                     Result
    

    Dieses Muster hat legitime Anwendungsfälle, wie zum Beispiel das gegeneinander ausspielen redundanter Anfragen oder die Implementierung eines Zeitlimits.

    Aber bedenken Sie Folgendes:

    Promise.race() stopppt die Operationen, die das Rennen verlieren, nicht automatisch.

    Falls Sie die verlierenden Operationen abbrechen müssen, müssen Sie dies selbst umsetzen, typischerweise mit etwas wie AbortController.

    9. Vergessen Sie das Wiederholungsverhalten nicht

    Nehmen wir an, Sie rufen einen externen Dienst auf:

    const result = await paymentService();
    

    Der Aufruf fehlschlägt aufgrund eines vorübergehenden Netzwerkproblems.

    Falls Sie alles in eine große Promise.all() einpacken und bei Fehlern blind erneut versuchen, kann das ein neues Problem verursachen.

    Sie laufen Gefahr, einen Wiederholungssturm auszulösen.

    Vor dem erneuten Versuch sollten Sie Folgendes berücksichtigen:

    • Welche Operationen sind tatsächlich sicher zum Wiederholen?
    • Wie viele Wiederholungsversuche sollten erlaubt sein?
    • Wie lange sollte man zwischen den Versuchen warten?
    • Ist die Operation idempotent?
    • Was passiert, wenn der externe Dienst bereits unter Last leidet?

    Ein gängiges Muster für vorübergehende Fehler ist der exponentielle Backoff.

    Konzeptionell:

    Attempt 1 → fail
         ↓
       wait
         ↓
    Attempt 2 → fail
         ↓
      wait longer
         ↓
    Attempt 3 → success
    

    Schnelligkeit bedeutet wenig, wenn sie die Zuverlässigkeit beeinträchtigt.

    10. Gleiches Denkmuster auf Datenbankabfragen anwenden

    Es ist verlockend zu glauben, dass JavaScript da, wo es konkurrierende Promises unterstützt, Datenbankabfragen immer parallel ausführen sollte.

    Das ist jedoch nicht immer der Fall.

    Nehmen wir dieses Beispiel:

    await Promise.all([
      database.users.findMany(),
      database.orders.findMany(),
      database.products.findMany(),
      database.payments.findMany(),
      database.notifications.findMany()
    ]);
    

    Dies startet ungefähr fünf Datenbankoperationen gleichzeitig.

    Das könnte völlig in Ordnung sein.

    Oder es kann dazu führen, dass Ihre Datenbank bei hohem Traffic ihre Grenzen überschreitet.

    Faktoren, die berücksichtigt werden sollten, sind:

    • Wie komplex jede Abfrage ist
    • Die Größe Ihres Datenbankverbindungs-Pools
    • Wie viele API-Instanzen laufen
    • Die Gesamtverkehrsmenge
    • Ob es angemessene Indizes gibt
    • Wie lange jede Abfrage zur Ausführung benötigt
    • CPU- und Speicherreserven auf dem Datenbankserver

    Die Leistungsoptimierung kann nicht im Vakuum erfolgen.

    Ihre API ist nur ein Teil eines viel größeren Systems.

    11. Ein Framework zur Entscheidung, wann Promise.all() verwendet werden soll

    Vor der Anwendung von Promise.all() hilft es, drei Fragen durchzugehen.

    Frage 1: Sind die Operationen unabhängig?

    Falls ja, kann es sinnvoll sein, sie parallel auszuführen.

    Falls nein, muss die Abfolge beibehalten werden, von der sie abhängen.

    Frage 2: Was soll passieren, wenn eine Operation fehlschlägt?

    Falls ein einziger Fehler die gesamte Batch-Verarbeitung ungültig machen sollte:

    Promise.all()
    

    ist wahrscheinlich das richtige Werkzeug.

    Falls Sie lieber die Ergebnisse der erfolgreichen Operationen sammeln möchten:

    Promise.allSettled()
    

    ist in der Regel besser geeignet.

    Frage 3: Wie viele Operationen werden gleichzeitig gestartet?

    Drei parallele Aufrufe?

    Das ist im Allgemeinen handhabbar.

    Zehntausend?

    Das ist eine völlig andere Herausforderung.

    Möglicherweise müssen Sie folgende Maßnahmen einführen:

    • Concurrentitätsbegrenzungen
    • Batchverarbeitung
    • Warteschlangenverwaltung
    • Paginierung
    • Rate Limiting

    12. Kurzer Überblick zur Auswahl eines Ansatzes

    Situation Besseres Vorgehen
    Unabhängige Operationen, alle müssen erfolgreich sein Promise.all()
    Unabhängige Operationen, ein teilweiser Erfolg reicht aus Promise.allSettled()
    Die Operationen sind voneinander abhängig Sequenzielles await
    Man benötigt nur das Ergebnis der ersten abgeschlossenen Operation Promise.race()
    Viele Aufgaben, die eine begrenzte Parallelität erfordern p-limit oder Batching
    Schleppende, nicht dringende Hintergrundarbeiten Warteschlange oder Worker-Prozess
    Aufrufe an externe Systeme, die zu vorübergehenden Fehlern neigen Wiederholungslogik mit Backoff

    Wichtig ist es nicht, diese Tabelle auswendig zu lernen.

    Es geht darum, die Gründe hinter jeder Entscheidung zu verstehen.

    13. Die wichtigste Erkenntnis

    Wenn man zum ersten Mal auf Promise.all() stößt, ist es verständlich, anzunehmen:

    "Die parallele Ausführung ist immer schneller."

    Diese Annahme hält jedoch nicht stand.

    Eine genauere Sichtweise lautet:

    Parallelisierte Ausführung lohnt sich nur dann, wenn das zugrunde liegende System tatsächlich die gleichzeitige Last bewältigen kann.

    Falls es drei unabhängige Operationen gibt, die jeweils 100 ms dauern:

    Sequential → ~300ms
    Parallel   → ~100ms
    

    Die Verbesserung ist offensichtlich.

    Aber wenn man diese Anzahl auf 10.000 Operationen erhöht, wobei die Datenbank nur 100 gleichzeitige Verbindungen zulässt, kann unkontrollierter Parallelismus die Leistung sogar verschlechtern anstatt zu verbessern.

    Eine bessere Frage, die man sich stellen sollte, lautet:

    "Can I run these in parallel?"Ask:"Should I run these in parallel?"
    

    Diese Denkweise unterscheidet zwischen dem Kennen der Syntax von JavaScript und dem tatsächlichen Verständnis dafür, wie Backend-Systeme unter Last funktionieren.

    Fazit

    Promise.all() bleibt eines der wertvollsten Werkzeuge in Node.js zur gleichzeitigen Ausführung unabhängiger asynchroner Aufgaben.

    Allerdings garantiert es keinen Geschwindigkeitszuwachs.

    wenden Sie es an, wenn:

    • Die Operationen voneinander unabhängig sind
    • Ihnen tatsächlich alle Ergebnisse benötigt werden
    • Ihr System die zusätzliche Parallelisierung bewältigen kann

    wenden Sie Promise.allSettled() an, wenn es in Ordnung ist, wenn einige Operationen fehlschlagen, ohne den Rest zu beeinträchtigen.

    wenden Sie sequenzielle await-Aufrufe an, wenn die Operationen von den Ergebnissen der vorherigen abhängen.

    wenden Sie Konkurrenzbeschränkungen an, wenn Sie gleichzeitig eine große Anzahl von Aufgaben verarbeiten.

    Schalten Sie Warteschlangen ein, wenn die Arbeit nicht innerhalb der Lebensdauer der HTTP-Anfrage abgeschlossen werden muss.

    Ziel ist es nicht nur, Millisekunden aus Ihrem Code herauszuholen.

    Ziel ist es, Ihr System schneller zu machen, ohne es anfällig zu machen.

    Verwandte Artikel

  • Behebung von Fehlern bei der Fehlerbehandlung mit Async/Await in Node.js-Produktionscode — Erfahren Sie fünf häufige Fehler bei der Fehlerbehandlung mit async/await in JavaScript und Node.js, die zu stillen Fehlern und Rennbedingungen führen, sowie konkrete Lösungen.
  • Erläuterung der Konkurrenz in Node.js: libuv, der Event Loop und der Thread-Pool — Erfahren Sie, wie Node.js die OS-Primitiven von libuv sowie den Worker-Thread-Pool nutzt, um asynchrone I/O-Aufgaben zu verarbeiten, sowie gängige Probleme beim Thread-Pool und Tipps zur Optimierung.
  • Wählen zwischen EC2, ECS und EKS für Node.js-Arbeitslasten — Vergleicht, wie EC2, ECS mit Fargate und EKS die Betriebstechnik von Node.js-Apps handhaben, um Ihnen dabei zu helfen, den richtigen AWS-Rechendienst für die Größe und Fähigkeiten Ihres Teams auszuwählen.
  • Logs über asynchrone Anfragen mit AsyncLocalStorage korrelieren — Erfahren Sie, wie Node.js AsyncLocalStorage den Kontext pro Anfrage wie requestId über await-Grenzen hinweg verfolgt, ohne ihn manuell in jeder Funktion durchzugeben.