Führen Sie das in Node oder einer Browser-Konsole aus:
| |
Die Ausgabe ist 1 4 3 2. Ein setTimeout mit 0ms Verzögerung läuft trotzdem nach dem Promise-Callback, und beide warten auf jede Zeile synchronen Codes darunter. async/await baut auf dieser Reihenfolge auf, nicht auf einem neuen Mechanismus. Die meisten überraschenden Verhaltensweisen in asynchronem JavaScript entstehen, weil man await so schreibt, als würde es das gesamte Programm anhalten, obwohl es nur die Funktion anhält, in der es steht.
Warum setTimeout nach einem Promise ohne Verzögerung läuft
JavaScript führt Ihren Code auf einem einzigen Thread mit einem einzigen Call-Stack aus. Funktionen werden beim Aufruf auf den Stack gelegt und beim Return wieder entfernt, und solange der Stack nicht leer ist, läuft nichts anderes. Timer, Netzwerkantworten und Promise-Auflösungen übernimmt die Laufzeitumgebung rund um den Stack (der Browser oder libuv in Node), die dafür einen Callback für später einplant.
In welcher Queue der Callback landet, hängt davon ab, um welche Art von Arbeit es sich handelt:
- Die Task-Queue (auch Macrotask-Queue genannt) enthält
setTimeout- undsetInterval-Callbacks, abgeschlossene I/O-Operationen und UI-Events. Die Event-Loop nimmt pro Durchlauf genau einen Task aus dieser Queue. - Die Microtask-Queue enthält Promise-Callbacks (
.then,.catch,.finally) und alles, was mitqueueMicrotaskeingeplant wurde. Die Event-Loop leert diese Queue vollständig, einschließlich Microtasks, die während des Leerens hinzukommen, bevor sie weitermacht.
Ein Durchlauf der Loop sieht so aus:
| |
Im Snippet oben beendet console.log("4") den synchronen Durchlauf, die Microtask-Queue leert sich und gibt 3 aus, und erst danach holt sich die Loop den Timer-Callback, der 2 ausgibt. Wer schon mit einer Message Queue gearbeitet hat, kennt das Modell: Arbeit wartet in einer Schlange, und ein einzelner Consumer verarbeitet ein Element nach dem anderen. Der Unterschied ist, dass JavaScript zwei solcher Schlangen hat und die Microtask-Queue immer zuerst bedient wird.
Ein Nebeneffekt, den man kennen sollte: Weil die Microtask-Queue leer sein muss, bevor ein Task laufen kann, hungert eine Promise-Chain, die immer weitere Promise-Callbacks einplant, Timer aus und blockiert im Browser das Repaint.
Wo await die Ausführung wirklich anhält
Eine async-Funktion gibt immer ein Promise zurück, auch ohne return und ohne await im Funktionskörper. await hält genau diese eine Funktion an: Alles danach wird zu einer Fortsetzung, die als Microtask läuft, sobald der erwartete Wert feststeht. Es ist syntaktischer Zucker für .then(), kein eigener Mechanismus.
| |
Das gibt start, a, end, b aus. example() läuft synchron bis zu await null und gibt die Kontrolle dann an den Aufrufer zurück. console.log("end") läuft, bevor example fortgesetzt wird, weil die Fortsetzung als Microtask eingeplant wird und Microtasks warten, bis der aktuelle synchrone Code fertig ist.
Eine einzige Tatsache erklärt jeden der folgenden Fehler: await gibt die Kontrolle an die Microtask-Queue ab, sonst nichts. Sie erklärt auch, warum async/await verschachtelte .then()-Ketten verdrängt hat: Eine Chain, die Fehler auf drei verschiedenen Ebenen behandelt, ist genau der verworrene Kontrollfluss, den Spaghetti-Code beschreibt, und aufeinanderfolgende await-Anweisungen bringen die Reihenfolge der Operationen zurück auf die Seite, von oben nach unten.
Was passiert, wenn man das await-Keyword vergisst
Fehlt ein await, bekommt man das Promise-Objekt statt dessen Ergebnis. Kein Fehler, nur falsche Daten drei Zeilen später:
| |
getUser(4821) gibt sofort ein ausstehendes Promise zurück, und ein Promise hat keine name-Property. Der Fix ist ein einziges Keyword: const user = await getUser(id). Teuer macht diesen Bug die Stille: An der fehlerhaften Zeile wirft nichts, man findet undefined irgendwo weiter unten im Code oder [object Promise] in einem Template-String.
await in der Schleife oder Promise.all: Was ist richtig?
await in einer for-Schleife schließt jede Iteration ab, bevor die nächste beginnt, selbst wenn die Iterationen nicht voneinander abhängen:
| |
Dauert jeder getUser-Aufruf 200ms und hat ids drei Einträge, dauert das Ganze etwa 600ms. Die zweite Anfrage startet erst, wenn die Antwort der ersten zurück ist. Sind die Anfragen unabhängig, startet man sie alle zusammen und awaitet den ganzen Batch:
| |
Jetzt feuern alle drei Anfragen gleichzeitig, und die Funktion kehrt nach etwa 200ms zurück, der Zeit der langsamsten. Promise.all rejectet, sobald auch nur ein einzelnes Promise rejectet. Braucht man jedes Ergebnis unabhängig von einzelnen Fehlschlägen, liefert Promise.allSettled für jedes einen Status, statt beim ersten Fehler abzubrechen.
Sequenzielles await ist in zwei Fällen die richtige Wahl. Erstens: Jede Iteration braucht das Ergebnis der vorherigen, etwa beim Paginieren einer API mit einem Cursor aus der letzten Antwort. Zweitens: Man drosselt bewusst gegen einen ratenbegrenzten Endpoint, der einen Schwall gleichzeitiger Aufrufe ablehnen würde. Bei einem großen ids-Array kann Promise.all genau in dieses zweite Problem laufen, also begrenzt man die Nebenläufigkeit mit einem kleinen Pool oder einer Library wie p-limit.
Warum async forEach nicht wartet
forEach ruft seinen Callback für jedes Element auf und verwirft dessen Rückgabewert, Promise inklusive:
| |
"done" wird ausgegeben, bevor irgendein Nutzername erscheint. forEach startet alle drei async-Callbacks und kehrt zurück, ohne auf einen davon zu warten, und man kann es nicht awaiten, weil es undefined zurückgibt, kein Promise. Für sequenzielle Arbeit nutzt man for...of mit await darin. Für nebenläufige Arbeit gilt derselbe Fix wie bei der Schleife oben:
| |
Das funktioniert, weil map ein Array von Promises zurückgibt, genau das, was Promise.all erwartet.
Warum eine Unhandled Promise Rejection Node zum Absturz bringt
Eine async-Funktion, die einen Fehler wirft, erzeugt ein rejectetes Promise, keine Exception im Stack-Frame des Aufrufers:
| |
In einem Browser-Tab landet dafür Uncaught (in promise) in der Konsole, und die Seite läuft weiter. Node ist strenger: Seit Node 15 wird eine unhandled Rejection standardmäßig wie eine uncaught Exception behandelt, und der Prozess beendet sich. Rejections behandelt man dort, wo sie entstehen, mit try/catch um das await oder einem .catch() auf dem Aufruf. Als letztes Auffangnetz kommt dann noch ein globaler Handler dazu:
| |
Diesen Listener zu registrieren ersetzt Nodes Standardabsturz, sodass der Prozess nach einer Rejection, von der er nichts weiß, weiterläuft. Nutzen Sie ihn, um zu loggen und dann sauber herunterzufahren (process.exit(1) aufrufen, nachdem die Logs geschrieben sind), nicht, um fehlende Handler zu übertünchen.
Warum try/catch Fehler in Callbacks nicht abfängt
Ein try/catch sieht nur Exceptions, die geworfen werden, während sein Block läuft. Code, der für später eingeplant ist, in einem anderen Task oder einer anderen Microtask, ist außer Reichweite, selbst wenn er in der nächsten Zeile steht:
| |
setTimeout plant den Callback ein und kehrt zurück, sodass der try-Block sofort fertig ist. Wenn der Callback 100ms später einen Fehler wirft, läuft er auf einem frischen Call-Stack, ohne catch darüber.
Bei await ist das anders. Die async-Funktion wird angehalten, nicht beendet: Ihr lokaler Zustand und ihr try-Block bleiben erhalten, und wenn das erwartete Promise rejectet, setzt die Funktion fort, indem sie die Rejection an der await-Zeile wirft, innerhalb des try:
| |
Beachten Sie das return await. Mit einem bloßen return getUser(id) gibt die Funktion das Promise direkt zurück, ohne darauf zu warten, und eine Rejection überspringt dieses catch komplett.
Wie man diese Bugs findet, bevor sie live gehen
Die meisten dieser Fehler lassen sich statisch erkennen, also soll der Linter sie finden, nicht die Event-Loop. Aktivieren Sie in ESLint no-await-in-loop, um sequenzielle Awaits zu markieren (und deaktivieren Sie die Regel inline dort, wo die Reihenfolge Absicht ist), sowie no-async-promise-executor. Wer TypeScript nutzt, ergänzt @typescript-eslint/no-floating-promises und @typescript-eslint/no-misused-promises: Die erste Regel erfasst Promises ohne await und ohne Fehlerbehandlung, die zweite erfasst async-Callbacks, die an Stellen wie forEach übergeben werden und das zurückgegebene Promise ignorieren. Kommt die Ausgabe trotzdem in einer unerwarteten Reihenfolge, schreiben Sie erst auf, in welcher Queue jeder Callback landet, bevor Sie den Code anfassen.