Häufige Missverständnisse zu async/await, die Fehler in der Produktion verursachen
Erklärt neun subtile Missverständnisse bezüglich async/await – von Rennbedingungen bis hin zu unverarbeiteten Ablehnungen –, die stille Schäden an echten JavaScript-Anwendungen verursachen.
Über einen langen Zeitraum hinweg gehen viele Entwickler davon aus, ein solides Verständnis von async/await zu haben, nur weil sie es fließend verwenden können. Zu wissen, dass eine async-Funktion immer ein Promise zurückgibt, zu wissen, dass await vor einem Netzwerkaufruf verwendet werden muss, und zu wissen, dass try/catch das Handhaben asynchroner Fehler erleichtert, scheint auszureichen. Im Vergleich zu codeintensivem Callback-Code wirkt dieser Stil ordentlich und liest sich fast wie gewöhnliches synchrones JavaScript: Der Benutzer wird abgerufen, sein Konto geladen, die UI aktualisiert – und alle Fehler, die unterwegs auftreten, werden gefangen. Die Syntax ist so intuitiv, dass es leichtfällt, nicht innezuhalten und das dahinterstehende Denkmuster genauer zu betrachten.
Das eigentliche Problem ist, dass diese oberflächliche Fließigkeit lediglich die Struktur von async/await abdeckt, aber nicht die zugrundeliegenden Regeln der asynchronen Verarbeitung. Es ist üblich, await so zu betrachten, als würde es das gesamte Programm einfrieren, anzunehmen, dass der Aufruf einer asynchronen Funktion automatisch deren Ergebnis mit dem Aufrufer verknüpft, und zu glauben, dass Code, der im linearen, von oben nach unten verlaufenden Stil geschrieben ist, nicht gegen sich selbst konkurrieren könnte. Solche Annahmen verursachen in kleinen Demos selten sichtbaren Schaden, da nur eine Operation gleichzeitig ausgeführt wird, Antworten schnell zurückkommen und gemockte Daten in einer vorhersehbaren Reihenfolge verfügbar werden. Reale Produktionsysteme sind dabei weitaus weniger nachsichtig.
Die Fehler, die letztendlich auftauchen, sehen nicht wie typische Asynchronitätsfehler aus. Eine Funktion gibt zurück, bevor ein bestimmter Nebeneffekt tatsächlich abgeschlossen ist. Eine veraltete Anfrage überschreibt den von einer neueren Anfrage gesetzten Zustand. Ein try/catch-Block lässt eine Abweichung ungehindert durchschlüpfen. Ein Batch-Job startet mehr gleichzeitige Aufgaben, als das System tatsächlich bewältigen kann. Jede Promise für sich betrachtet scheint völlig in Ordnung zu sein – doch der gesamte Workflow verhält sich keineswegs so, wie ursprünglich beabsichtigt.
Es kann lange dauern, die Lektion vollständig zu verinnerlichen: async/await beseitigt die Parallelität in JavaScript nicht. Es gibt lediglich einer async-Funktion eine leichter verständliche Möglichkeit, anzuhalten und wieder fortzusetzen. Alles Weitere hängt weiterhin davon ab, welche Promises zurückgegeben werden, welche gewartet werden, welche still ignoriert werden, welche storniert oder erneut versucht werden und welche im Laufe des Prozesses den gemeinsamen Zustand verändern dürfen.
Ich dachte, await pausiert mehr als eine Funktion
Ein häufiger erster Irrtum ist ganz einfach zu verstehen. Immer wenn await auftaucht, ist es verlockend, es mental so zu betrachten, als würde es alles darum herum pausieren. JavaScript blockiert weder das Browser-Fenster noch den Node.js-Prozess, doch man neigt trotzdem dazu anzunehmen, dass die umliegende Logik irgendwie auf ihre Reihe warten wird.
So funktioniert das nicht. await pausiert nur die spezifische asynchrone Funktion, in der es sich befindet. Der Kontrollfluss kehrt zum Laufzeitumfeld zurück, solange die gewartete Promise noch aussteht, und JavaScript kümmert sich weiter um alles andere, was ausgeführt werden kann. Ereignislistener können ausgelöst werden, Timer können ablaufen, unverwandte Anfragen können abgeschlossen werden, und sogar ein weiterer Aufruf derselben Funktion kann in der Zwischenzeit starten.
async function loadProfile() {
console.log("Loading profile");
const profile = await fetchProfile();
console.log("Profile loaded");
return profile;
}
console.log("Before");
loadProfile();
console.log("After");
Der äußere Code wartet nicht einfach deshalb, weil loadProfile zufällig eine await-Anweisung enthält. Der Aufruf der Funktion setzt sie in Gang und gibt sofort eine Promise zurück. Es sei denn, wer auch immer sie aufgerufen hat, entscheidet sich dafür, auf diese Promise zu warten oder sie zurückzugeben, setzt die Ausführung einfach weiter fort, ohne anzuhalten.
Das mag wie eine kleine technische Nuance klingen, doch die Auswirkungen reichen weit über fehlerhaft angeordnete Konsoleinträge hinaus. Ein Anfragenverarbeiter könnte seine Antwort senden, während eine Schreiboperation in das Prüfprotokoll noch im Gange ist. Ein CLI-Tool könnte beenden, während eine Dateischreiboperation noch aussteht. Ein Test könnte Erfolg melden, bevor die darin enthaltenen Assertionen innerhalb eines asynchronen Callbacks überhaupt ausgeführt werden. Die asynchrone Funktion verhält sich genau wie vorgesehen – das eigentliche Problem ist, dass der Aufrufer seine eigene Abschlussbehandlung niemals mit der des asynchronen Aufrufs verknüpft hat.
Daher lautet die richtige Frage nicht, ob eine Funktion intern await verwendet, sondern ob die Promise, die die gesamte Operation repräsentiert, tatsächlich mit dem Code verbunden ist, der wissen muss, wann sie abgeschlossen ist.
Ich habe asynchrone Funktionen aufgerufen, ohne zu entscheiden, wer für ihren Abschluss verantwortlich ist
Ein Fehler, der ständig auftritt, ist das Ausführen einer asynchronen Funktion, ohne darauf zu warten oder ihr Ergebnis zurückzugeben. Manchmal handelt es sich dabei um einen einfachen Übersehfehler. Andere Male erscheint die Aufgabe so unbedeutend, dass man sie einfach im Hintergrund laufen lässt.
Der eigentliche Mangel liegt nicht unbedingt darin, dass die Aufgabe unabhängig ausgeführt wird – sondern darin, dass niemand entschieden hat, wer für das Ergebnis verantwortlich ist.
Stellen Sie sich vor, ein Auftrag wird gespeichert und anschließend eine Benachrichtigung versandt. Wenn diese Benachrichtigung unbedingt erfolgreich sein muss, bevor die gesamte Operation als abgeschlossen gilt, muss der Aufrufer darauf warten. Wenn sie tatsächlich optional ist, mag es in Ordnung sein, sie eigenständig laufen zu lassen – doch ein Ablehnungsfall benötigt trotzdem einen Abwicklungsweg. Das Einfachwegwerfen der zurückgegebenen Promise verwandelt den Aufruf nicht wie durch Magie in eine zuverlässige Hintergrundaufgabe – es beraubt den Aufrufer lediglich jeder Möglichkeit, später zu erfahren, was passiert ist.
Dies wird im Backend-Code besonders riskant. Eine Funktion könnte eine erfolgreiche Antwort zurücksenden, obwohl ein späterer asynchroner Nebeneffekt fehlgeschlagen ist. Der Benutzer geht davon aus, dass die Aktion erfolgreich war, während das System keine dauerhafte Aufzeichnung davon hat, dass noch unvollendete Arbeiten bestehen. Wenn der Prozess zu diesem Zeitpunkt zufällig neu gestartet wird, verschwinden diese ausstehenden Arbeiten einfach.
Es hilft, jede unerwartete Promise als architektonische Entscheidung und nicht als Nachgedanken zu betrachten. Entweder ist die aktuelle Operation für das Ergebnis verantwortlich und muss darauf warten, oder eine andere Schicht übernimmt dies und benötigt einen geeigneten Mechanismus zur Überwachung der Fertigstellung, Wiederholungsversuche und Fehler. Es ist nichts falsch an wirklich beabsichtigter Hintergrundarbeit – aber sie sollte nicht einfach entstehen, nur weil jemand vergessen hat await einzutippen.
Ein Versprechen, das niemand beobachtet, ist nicht per Design eine Hintergrundaufgabe. Es handelt sich einfach um Arbeit ohne zuständigen Eigentümer.
forEach so behandeln, als würde es Versprechen verstehen
Eines der ersten asynchronen Muster, die das übliche Denkmuster heimlich untergraben, ist die Kombination von forEach mit einem asynchronen Callback. Die Syntax erscheint völlig vernünftig, wodurch es leicht passiert, dass man übersehen wird, warum die umschließende Funktion bereits beendet ist, bevor die eigentliche Arbeit überhaupt stattgefunden hat.
async function saveUsers(users) {
users.forEach(async (user) => {
await saveUser(user);
});
console.log("All users saved");
}
Eine Abschlussmeldung kann bereits auftauchen, bevor ein einzelnes Benutzerdatensatz gespeichert wurde. Der Grund dafür ist, dass forEach die Callback-Funktion aufruft, aber das zurückgegebene Ergebnis ignoriert. Ein asynchroner Callback gibt immer ein Promise zurück, wodurch bei jedem Aufruf stumm ein Promise erzeugt wird, zu dem niemand eine Referenz behält. Da es nichts mehr gibt, worauf gewartet werden muss, wird die übergeordnete Funktion unverzüglich abgeschlossen – unabhängig davon, was ihre internen Aufrufe noch tun.
Das ist eigentlich kein spezifischer Fehler von forEach. Er entsteht durch die Annahme, dass das Übergeben einer asynchronen Funktion an eine beliebige Array-Methode automatisch deren Funktionskonzept so erweitert, dass sie Promises verarbeiten kann. Das ist jedoch nicht der Fall. Ein synchroner Iterationshelfer bleibt unabhängig davon, welche Art von Funktion ihm übergeben wird, synchron – es sei denn, dieser Helfer wurde eigens zur Koordination asynchroner Aufgaben entwickelt.
Das gleiche Problem tritt bei map, filter und reduce auf. Die Verwendung von map mit einem asynchronen Callback liefert ein Array voller ausstehender Promises statt eines Arrays mit den endgültigen Werten. filter wartet niemals auf ein asynchrones Prädikat, weshalb es die Promis-Objekte selbst auswertet – die stets als wahr gelten – anstatt der Ergebnisse, die sie letztendlich liefern. Es ist möglich, eine asynchrone Version von reduce zu erstellen, die sich korrekt verhält, doch sie ist in der Regel schwer nachvollziehbar, da der Akkumulator selbst ein Promise ist, das bei jeder Iteration sorgfältig entpackt werden muss.
Sobald das klar ist, geht es bei der Erstellung korrekten Codes nicht mehr darum, sich zu merken, welche Array-Methode „erlaubt“ ist, sondern darum, das Ausführungsmodell zu wählen, das die Aufgabe tatsächlich erfordert. Wenn jeder Schritt wirklich von der Fertigstellung des vorherigen abhängt, drückt eine for...of-Schleife mit await darin diesen Zweck direkt aus. Wenn die Schritte unabhängig sind und gleichzeitig ausgeführt werden können, ist es oft der richtige Ansatz, sie in eine Liste von Promises umzuwandeln und diese mit Promise.all gemeinsam abzuwarten. Und wenn die Konkurrenz eingeschränkt werden muss, erledigen weder eine einfache Schleife noch ein unbegrenztes Promise.all die Aufgabe.
Die Syntax muss aus dem operativen Bedarf abgeleitet werden – und nicht umgekehrt. Es ist verlockend, zunächst das Muster zu wählen, das am idiomatischsten erscheint, und zu hoffen, dass sich daraus das gewünschte Verhalten ergibt – doch diese Reihenfolge ist falsch.
Die Annahme, dass Promise.all die Dinge risikofrei schneller macht
Nachdem man festgestellt hat, dass forEach niemals wartet, ist der natürliche nächste Schritt die Verwendung von Promise.all, welches in der Regel das richtige Werkzeug ist: Die Elemente werden in asynchrone Operationen umgewandelt, die entstehenden Promises an Promise.all übergeben und man wartet darauf, dass alle zusammen abgeschlossen werden.
Dieses Muster funktioniert hervorragend, wenn die einzelnen Operationen voneinander unabhängig sind, die Gruppengröße überschaubar bleibt und ein einziger Fehler dazu führen soll, dass das gesamte Ergebnis ungültig wird. Probleme entstehen, wenn es außerhalb dieser Grenzen eingesetzt wird.
Promise.all drosselt nichts von selbst. Die meisten zugrundeliegenden Aufgaben beginnen bereits im Moment der Erstellung der Promises, sodass das Durchlaufen von einigen tausend Datensätzen fast gleichzeitig mehrere tausend Datenbankabfragen oder Ausgangsanfragen auslösen kann. Solcher Code mag bei kleinen lokalen Datensätzen funktionieren, führt aber dazu, dass Verbindungs-pools überlastet werden, Rate-Limits überschritten werden oder der Speicher erschöpft wird, sobald mit Eingaben in Produktionsumfang gearbeitet wird.
Auch die Fehlerbehandlung kann überraschend sein. Wenn eine Promise in der Gruppe abgelehnt wird, werden die anderen nicht automatisch gestoppt. Sie laufen weiter, was bedeutet, dass sie weiterhin Datensätze schreiben, Nachrichten senden oder den externen Zustand verändern können, selbst nachdem der aufrufende Code bereits in den Catch-Block übergegangen ist. Zu behaupten, „die Batch-Verarbeitung ist fehlgeschlagen“, ist technisch korrekt, bedeutet aber nicht, dass es keine Nebeneffekte gab.
Durch das erneute Ausführen der gesamten Batch-Verarbeitung kann die bereits beim ersten Versuch erfolgreich abgeschlossene Arbeit erneut durchgeführt werden. Zu diesem Zeitpunkt geht es nicht mehr um die Mechanik von Promises – sondern um Idempotenz und die Handhabung unvollständiger Abschlüsse.
Bevor man auf Promise.all zurückgreift, ist es hilfreich, andere Fragen zu stellen: Wie viele Operationen lassen sich tatsächlich gleichzeitig sicher ausführen? Sind sie wirklich voneinander unabhängig? Was bedeutet ein Versagen für den Rest des Batches? Gibt es eine Möglichkeit, die bereits begonnene Arbeit zu stoppen? Könnten bei einem Neversuch Elemente, die bereits abgeschlossen sind, dupliziert werden? Braucht der Aufrufer wirklich jedes einzelne Ergebnis – oder wäre ein ehrliches Teil-Erfolg ausreichend?
Mannchmal ist Promise.all immer noch die richtige Lösung. Manchmal eignet sich Promise.allSettled besser. In anderen Fällen erfordert die Situation eine sequentielle Schleife, einen Konkurrenzbeschränker, eine Warteschlange oder eine Transaktion, die die gesamte Operation umschließt. Welche Methode richtig ist, hängt von den Garantien ab, die die Operation erbringen muss, und nicht davon, wie schnell der Code auf den ersten Blick erscheint.
Ausgangspunkt: Der Glaube, Code, der von oben nach unten gelesen wird, könne keine Konkurrenzsituationen aufweisen
Vielleicht ist das teuerste Missverständnis überhaupt der Glaube, await schütze Code vor Konkurrenzsituationen. Innerhalb einer einzigen Funktion werden die Anweisungen nach einem await tatsächlich in der richtigen Reihenfolge fortgesetzt, wodurch die Funktion wie eine kontinuierliche Abfolge wirkt. Doch bei mehreren gleichzeitigen Aufrufen derselben Funktion können mehrere Ausführungen leicht übereinanderliegen.
Stellen Sie sich zwei Anfragen vor, die jeweils einen Saldo lesen, einen neuen Saldo berechnen und diesen speichern. Beide Anfragen warten auf den Lesevorgang sowie auf den Schreibvorgang. Jede Zeile innerhalb jeder Anfrage wird in der erwarteten Reihenfolge ausgeführt. Dennoch tritt die Wettbedingung auf, weil beide Anfragen denselben Ausgangssaldo lesen können, bevor eine von ihnen ihre Aktualisierung wieder schreibt.
Dieselbe Art von Fehler tritt auch im Frontend auf. Zuerst wird eine Suche nach einer älteren Anfrage gestartet, anschließend eine zweite Suche nach einer neueren. Die neuere Anfrage wird zufällig zuerst abgeschlossen und aktualisiert die Benutzeroberfläche korrekt. Die ältere Anfrage wird danach abgeschlossen und überschreibt diesen korrekten Zustand mit einem veralteten Ergebnis. Jede Anfrage hat genau wie vorgesehen auf ihren eigenen Abruf gewartet – was fehlt, ist eine Regel, die festlegt, welche Anfrage weiterhin das Recht hat, die Benutzeroberfläche zu aktualisieren.
Dies sollte man bei jedem await berücksichtigen, der zwischen dem Lesen eines Zustands und der darauf ausgeführten Aktion liegt. Die Pause ist kein harmloser Zeitraum; sie stellt ein Fenster dar, in dem andere Aufgaben ausgeführt werden können und die Annahmen, die die Funktion unmittelbar vor der Pause getroffen hat, ungültig machen können.
Prüfungen auf Anwendungsseite überstehen dieses Fenster nicht automatisch. Die Überprüfung, ob ein Benutzernamen frei ist, bevor er eingegeben wird, bietet keinen Schutz vor zwei Anfragen, die fast zur gleichen Zeit dieselbe Prüfung durchführen. Die Überprüfung, ob ein Eintrag noch ausstehend ist, bevor er genehmigt wird, garantiert nicht, dass in der Zwischenzeit kein anderer Prozess seinen Zustand bereits geändert hat.
Eine echte Absicherung muss in der Regel von einer untergeordneten Ebene stammen: eine Eindeutigkeitsbeschränkung auf Datenbankebene, ein bedingter Update-Vorgang, optimistisches Verriegeln, eine Idempotenzschlüssel, eine Transaktion oder explizite Eigentumsregeln, die in der Benutzeroberfläche durchgesetzt werden. await kümmert sich nur um die Erfüllung einer bestimmten Promise. Es tut nichts, um den gemeinsam genutzten Zustand zu sichern oder sicherzustellen, dass ein früher gelesener Wert zum Zeitpunkt der Verarbeitung noch gültig ist.
Die Erwartung, dass try/catch Aufgaben abfängt, die nie mit await abgewartet wurden
Ein weiterer häufiger Fehler erscheint bei der Code-Überprüfung völlig unbedenklich. Ein asynchroner Aufruf befindet sich innerhalb eines try/catch-Blocks, und es scheint vernünftig, anzunehmen, dass alle Fehler dort behandelt werden.
try {
sendAnalyticsEvent(event);
} catch (error) {
logError(error);
}
Wenn sendAnalyticsEvent eine asynchrone Funktion ist, die nachdem sie bereits ihre Promise zurückgegeben hat, abgelehnt wird, sieht der umgebende catch-Block diese Fehlermeldung niemals. Der synchrone Teil des Aufrufs war bereits erfolgreich, sobald er eine Promise zurückgab. Die Ablehnung erfolgt danach, außerhalb des Stack-Frames, den der try-Block überwacht.
Der catch-Block funktioniert nur, wenn die Promise von innen darin abgewartet wird:
try {
await sendAnalyticsEvent(event);
} catch (error) {
logError(error);
}
Um es einfach auszudrücken: Der Unterschied scheint offensichtlich zu sein, doch die frühere Version wirkt sicher, gerade weil der asynchrone Aufruf scheinbar innerhalb eines Fehlerhandlers liegt. Die Anordnung suggeriert eine Beziehung, die der Code in Wirklichkeit nie herstellt.
Derselbe Problembereich tritt auch in Callbacks und Event-Handlern auf. Ein asynchrer Callback kann intern abgelehnt werden, doch wenn der aufrufende Code niemals auf die von ihm zurückgegebene Promise wartet oder sie überprüft, hat diese Ablehnung keinen Ort, wohin sie gehen kann. Das Einhüllen des Aufruforts in try/catch hilft nicht, denn die Ablehnung gehört zu einer Promise, die innerhalb des Scope von nichts überwacht wird.
Die zugrundeliegende Regel ist, dass Fehler in asynchronem Code zusammen mit den Promises übertragen werden, nicht durch Einrückungen. Um eine Ablehnung abzufangen, muss man direkt auf die Promise warten, sie in eine Kette einfügen, die bereits einen Handler besitzt, oder einen expliziten Ablehnungs-Callback hinzufügen. Wird die Promise verworfen, wird auch ihr Fehlerweg zusammen mit ihr verworfen.
Auch für Catch-Blöcke, die zu viel aufnehmen, ist das wichtig. Jeden Fehler in einen Fallback-Wert null umzuwandeln, erzeugt ein eigenes Problem: Die Aufrufer können keinen echten Fehler mehr von einem legitimen, leeren Ergebnis unterscheiden. Es geht nicht darum, den Fehler einfach nur abzufangen. Der Code muss die Bedeutung dieses Fehlers für denjenigen bewahren, der ihn anschließend verarbeitet.
Absage mit Umkehr verwechseln
Wenn ein Team erstmals AbortController verwendet, ist es verlockend anzunehmen, dass das Abbrechen einer Anfrage bedeutet, die zugrunde liegende Arbeit sei tatsächlich gestoppt worden. Bei aus dem Browser gesendeten Anfragen stoppt das Abbrechen tatsächlich, dass der Client weiter wartet, und verhindert oft unnötige weitere Verarbeitungen auf der Seite selbst. Das ist wirklich nützlich für Dinge wie Suchfunktionen in Echtzeit, das Verlassen einer Seite oder das Ignorieren von Daten, die nicht mehr relevant sind.
Es wird nicht garantiert, dass Arbeiten, die bereits an den Server übergeben wurden, rückgängig gemacht werden.
Falls eine Schreibanfrage nachdem der Server sie bereits erhalten hat abgebrochen wird, kann die Backend-Systeme den Datenbank-Update oder den externen Aufruf dennoch zu Ende führen. Alles, was der Client tatsächlich weiß, ist, dass er auf die Antwort nicht mehr wartet. Ein erneuter Versuch dieser Anfrage ohne some Form von Idempotenzschutz birgt das Risiko, eine bereits erfolgreich abgeschlossene Operation erneut auszuführen.
Dieser Unterschied besteht, weil die Stornierung auf der Client-Seite und der Zustand auf der Server-Seite sich auf entgegengesetzten Seiten einer Netzwerkgrenze befinden. Der Client kann sein Interesse an einem Ergebnis zurückziehen, hat aber keine Möglichkeit, diese Grenze zu überwinden und Effekte rückgängig zu machen, die bereits eingetreten sind.
Die Stornierung sollte am besten als Signal bezüglich der Relevanz und nicht als Garantie für den Zustand betrachtet werden. Sie teilt dem Rest der Anwendung mit, dass der Aufrufer sich nicht mehr für ein bestimmtes Ergebnis interessiert, sodass dieses Ergebnis den aktuellen Zustand nicht überschreiben darf. Bei Lesevorgängen ist das Abbrechen der Anfrage eine sinnvolle Optimierung. Bei Schreibvorgängen ist weiterhin ein separates Mechanismus erforderlich, um festzustellen, ob die Operation abgeschlossen ist, noch im Gange ist oder ohne Wiederholung ihrer Wirkung erneut versucht werden kann.
Durch die Stornierung wird geklärt, ob ein Ergebnis noch gewünscht ist. Sie beantwortet jedoch nicht die Frage, ob die zugrunde liegende Aktion weiterhin konsistent ist.
Schreiben der Asynchron-Syntax vor der Definition des Workflows
Der gemeinsame Nenner all dieser Fehler ist die Behandlung des asynchronen Designs, als handelte es sich dabei ausschließlich um eine Syntaxfrage – man entscheidet zunächst, ob await, Promise.all oder ein asynchrer Callback verwendet werden soll, anstatt herauszufinden, welche Garantien die Operation tatsächlich bieten muss.
Die nützlicheren Fragen stellen sich bereits früher. Muss der Aufrufer warten, bis diese Operation abgeschlossen ist, bevor er fortfährt? Können mehrere Aufgaben gleichzeitig sicher ausgeführt werden? Spielt ihre relative Reihenfolge eine Rolle? Wie reagiert man richtig, wenn einige Aufgaben erfolgreich sind und andere nicht? Ist es sicher, diese Operation erneut zu versuchen? Wer ist für die Fehlerbehandlung verantwortlich? Könnte ein veralteter Ergebniswert den gemeinsam genutzten Zustand überschreiben? Und wenn etwas ausläuft, bedeutet das, dass die Operation fehlgeschlagen ist, oder nur, dass dieser spezielle Aufrufer aufgehört hat zu warten?
Sobald diese Fragen beantwortet sind, fügt sich der eigentliche JavaScript-Code in der Regel ohne große Schwierigkeiten ein.
In einer Abfolge, in der die Reihenfolge tatsächlich wichtig ist, kann eine Schleife verwendet werden, die nacheinander jeden Schritt abwartet. Unabhängige Aufgaben mit einer bekannten, begrenzten Anzahl können gleichzeitig ausgeführt werden. Größere Datensätze können mit einer Konkurrenzbeschränkung verarbeitet werden. Arbeiten, die den Aufrufer nicht blockieren müssen, können an eine dauerhafte Warteschlange weitergeleitet werden. Aktualisierungen des gemeinsamen Zustands können durch explizite Eigentumsprüfungen kontrolliert werden. Datenbankschreibvorgänge können ihre Invarianten auf der Speicherschicht atomar durchsetzen. Bei Operationen, bei denen Doppelungen kostspielig wären, können Idempotenzschlüssel verwendet werden, damit ein erneuter Versuch nicht eine zweite Kopie desselben Effekts erstellt.
Der schwierige Teil besteht darin, die Platzierung von await stets richtig zu gestalten. Es geht darum herauszufinden, was „erledigt“ tatsächlich bedeutet, an jeder Grenze, die die Operation überschreitet.
Das Schlüsselwort kennt man, aber nicht den Vertrag
Jahrelang falsch mit async/await umzugehen hat weniger damit zu tun, dass man vergisst, wie die Syntax funktioniert, sondern vielmehr damit, dass sie asynchronen Code weitaus sequentieller, lokaler und vorhersehbarer erscheinen lässt, als er tatsächlich ist.
Das Sehen eines await lässt vermuten, dass auch alles darum herum pausiert wird. Es ist leicht übersehen zu können, eine asynchrone Funktion aufzurufen, ohne festzulegen, wer für ihre endgültige Abschlussverantwortung trägt. Das Ausführen synchroner Array-Methoden mit asynchronen Callbacks behandelt sie so, als würden sich die beiden gegenseitig verstehen, was jedoch nicht der Fall ist. Das Verwenden von Promise.all, ohne über die Last oder das Szenario nachzudenken, in dem nur einige Promises erfolgreich sind, ist eine natürliche, aber riskante Gewohnheit. Das Vertrauen in Code, der von oben nach unten gelesen wird – selbst dann, wenn mehrere Aufrufe tatsächlich zeitlich übereinanderliegen können – verbirgt Konkurrenzsituationen direkt vor den Augen. Die Erwartung, dass try/catch Ablehnungen von Promises fängt, die nie mit await abgewartet wurden, sowie das Falschverstehen eines stornierten Anfrages als Beweis dafür, dass auf dem Server nichts passiert ist, entstehen beide aus derselben Blindstelle.
Jeder dieser Fehler hat seinen Ursprung im selben Mangel: dem Unterschied zwischen dem Aussehen des Codes und dem, was er tatsächlich verspricht.
Gute Auseinandersetzung mit asynchronem Code bedeutet, die Promises über Funktionsgrenzen hinweg nachzuvollziehen, anstatt einfach von oben nach unten zu lesen. Es bedeutet, herauszufinden, wer sie erstellt, wer auf sie wartet, wer für die Handhabung von Ablehnungen verantwortlich ist und welcher Zustand weiterhin Einfluss auf das Ergebnis hat, sobald alles abgeschlossen ist. Es bedeutet außerdem, jeder Stelle große Aufmerksamkeit zu schenken, an der await eine Pause verursacht, denn genau in dieser Pause kann andere Arbeit die Annahmen ändern, auf denen die Funktion beruht.
Der Code kann auf der Seite weiterhin sequenziell aussehen. Dieses Erscheinungsbild bedeutet jedoch nicht mehr, dass das System sich entsprechend verhält.
Diese Veränderung verwandelt asynchrones JavaScript von einer Reihe von Schlüsselwörtern in ein Modell, das auf Eigentumsverhältnissen, Zeitplanung und Fehlern basiert. Sie erklärt außerdem, warum Code, der jahrelang korrekt erschien, trotz Bestehen aller Tests in der Produktion weiterhin falsch funktionieren kann.
Zu lernen, wie man auf eine Promise wartet, ist der einfachere Teil.
Zu verstehen, was der Rest des Programms während dieser Wartezeit tut, dauert erheblich länger.
Verwandte Artikel
- Fünf täuschende Node.js-Bugs, die unbemerkt die Code-Review überstehen – Es werden fünf reale Node.js-Bugs behandelt, die forEach, „schwimmende“ Promises und flache Kopien betreffen, um zu zeigen, warum Code, der einwandfrei läuft, in der Produktion dennoch fehlschlagen kann.