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.
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
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:
- Einen Benutzer anzulegen
- Die ID dieses Benutzers abzurufen
- 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
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
- Neun Promise-Muster für zuverlässiges asynchrones JavaScript in der Produktion — Lernen Sie praktische Promise-Muster wie parallele Anfragen, Zeitlimits, Wiederholungen, Konkurrenzbeschränkungen und Stornierungen, um widerstandsfähiges, produktionstaugliches asynchrones JavaScript zu entwickeln.