Esegui questo snippet in Node o nella console del browser:
| |
L’output è 1 4 3 2. Un setTimeout con un ritardo di 0ms viene eseguito comunque dopo il callback della promise, ed entrambi partono solo dopo che tutto il codice sincrono, incluso quello scritto più avanti, è terminato. async/await si basa su questo stesso ordine, non introduce un meccanismo nuovo. La maggior parte dei comportamenti sorprendenti nel JavaScript asincrono nasce dal trattare await come se sospendesse l’intero programma, quando in realtà sospende solo la funzione in cui si trova.
Perché setTimeout viene eseguito dopo una promise senza ritardo
JavaScript esegue il tuo codice su un solo thread con un solo call stack. Le funzioni vengono impilate quando vengono chiamate e rimosse quando ritornano, e nulla altro viene eseguito finché lo stack contiene qualcosa. Timer, risposte di rete e risoluzioni di promise sono gestiti dal runtime attorno allo stack (il browser, o libuv in Node), che accoda un callback per dopo.
La coda in cui finisce il callback dipende dal tipo di lavoro:
- La task queue (chiamata anche macrotask queue) contiene i callback di
setTimeoutesetInterval, i completamenti di I/O e gli eventi della UI. L’event loop preleva esattamente un task da questa coda per ogni iterazione. - La microtask queue contiene i callback delle promise (
.then,.catch,.finally) e qualsiasi cosa pianificata conqueueMicrotask. L’event loop vuota completamente questa coda, incluse le microtask aggiunte mentre la sta vuotando, prima di procedere.
Un’iterazione del loop è così:
| |
Nello snippet sopra, console.log("4") chiude l’esecuzione sincrona, la microtask queue si vuota e stampa 3, e solo a quel punto il loop preleva il callback del timer che stampa 2. Se hai già lavorato con una coda di messaggi, il modello ti è familiare: il lavoro aspetta in fila e un solo consumer gestisce un elemento alla volta. La particolarità è che JavaScript ha due file, e quella delle microtask viene sempre servita prima.
Un effetto collaterale da conoscere: dato che la microtask queue deve essere vuota prima che un task possa partire, una catena di promise che continua a pianificare altri callback affama i timer e, nel browser, blocca i repaint.
Dove await sospende davvero l’esecuzione
Una funzione async restituisce sempre una promise, anche senza return e senza await nel corpo. await sospende quella singola funzione: tutto quello che viene dopo diventa una continuazione che viene eseguita come microtask non appena il valore atteso si risolve. È zucchero sintattico sopra .then(), non un meccanismo diverso.
| |
Questo stampa start, a, end, b. example() viene eseguita in modo sincrono fino ad await null, poi restituisce il controllo al chiamante. console.log("end") viene eseguito prima che example riprenda, perché la ripresa viene accodata come microtask e le microtask aspettano che il codice sincrono corrente finisca.
Questo unico fatto, await cede il controllo alla microtask queue e nient’altro, spiega tutti gli errori qui sotto. È anche il motivo per cui async/await ha soppiantato le catene di .then() annidate: una catena che gestisce gli errori a tre livelli diversi è il flusso di controllo aggrovigliato che lo spaghetti code descrive, e una sequenza di await rimette l’ordine delle operazioni sulla pagina, dall’alto verso il basso.
Cosa succede se dimentichi la parola await
Togli un await e ottieni l’oggetto promise invece del suo risultato. Nessun errore, solo dati sbagliati tre righe più sotto:
| |
getUser(4821) ritorna immediatamente una Promise pendente, e una Promise non ha una proprietà name. La correzione è una sola parola: const user = await getUser(id). Quello che rende questo bug costoso è il silenzio. Niente lancia un errore sulla riga incriminata; trovi undefined più avanti nel codice, oppure [object Promise] in una template string.
Meglio await in un loop o Promise.all?
await dentro un ciclo for completa ogni iterazione prima di iniziare la successiva, anche quando le iterazioni non dipendono l’una dall’altra:
| |
Se ogni chiamata a getUser richiede 200ms e ids ha tre elementi, questo codice richiede circa 600ms. La seconda richiesta non parte finché non torna la risposta della prima. Quando le richieste sono indipendenti, avviale tutte insieme e aspetta il gruppo:
| |
Ora tutte e tre le richieste partono insieme e la funzione ritorna in circa 200ms, il tempo della più lenta. Promise.all viene rigettata non appena una singola promise fallisce. Se ti serve ogni risultato indipendentemente dai singoli fallimenti, Promise.allSettled ti dà uno stato per ciascuna invece di interrompersi al primo errore.
L’await sequenziale è la scelta giusta in due casi. Primo: ogni iterazione ha bisogno del risultato precedente, come quando pagini un’API con un cursore preso dall’ultima risposta. Secondo: stai deliberatamente limitando le richieste verso un endpoint con rate limit che rigetterebbe un’ondata di chiamate concorrenti. Con un array ids grande, Promise.all può causare da solo questo secondo problema, quindi limita la concorrenza con un pool piccolo o con una libreria come p-limit.
Perché async forEach non aspetta
forEach chiama il suo callback per ogni elemento e scarta qualsiasi cosa il callback restituisca, promise incluse:
| |
"done" viene stampato prima di qualsiasi nome utente. forEach avvia tutti e tre i callback async e ritorna senza aspettare nessuno di loro, e non puoi fare await su di esso perché ritorna undefined, non una promise. Per lavoro sequenziale, usa for...of con await all’interno. Per lavoro concorrente, usa la stessa correzione del loop qui sopra:
| |
Funziona perché map restituisce un array di promise, esattamente quello che Promise.all si aspetta.
Perché una promise rejection non gestita fa crashare Node
Una funzione async che lancia un errore produce una promise rigettata, non un’eccezione nello stack frame del chiamante:
| |
In una tab del browser questo stampa Uncaught (in promise) nella console e la pagina continua a funzionare. Node è più severo: da Node 15, una rejection non gestita viene trattata come un’eccezione non gestita per impostazione predefinita, e il processo termina. Gestisci le rejection dove accadono, con try/catch attorno all’await o con un .catch() sulla chiamata. Poi, come rete di sicurezza finale, aggiungi un handler globale:
| |
Registrare questo listener sostituisce il crash predefinito di Node, quindi il processo continua a funzionare anche dopo una rejection che nessun altro codice gestisce. Usalo per loggare e poi terminare in modo controllato (chiama process.exit(1) dopo aver scaricato i log), non per aggirare la mancanza di handler.
Perché try/catch non intercetta gli errori nei callback
Un try/catch vede solo le eccezioni lanciate mentre il suo blocco è in esecuzione. Il codice pianificato per dopo, in un altro task o microtask, è fuori portata anche se scritto sulla riga successiva:
| |
setTimeout pianifica il callback e ritorna, quindi il blocco try finisce immediatamente. Quando il callback lancia l’errore 100ms dopo, viene eseguito su un call stack nuovo senza nessun catch sopra.
await è diverso. La funzione async viene sospesa, non terminata: il suo stato locale e il suo blocco try vengono salvati, e quando la promise attesa viene rigettata, la funzione riprende lanciando la rejection alla riga dell’await, dentro il try:
| |
Nota il return await. Con un semplice return getUser(id), la funzione restituisce direttamente la promise senza aspettarla, e una rejection salta completamente questo catch.
Come scovare questi bug prima di andare in produzione
La maggior parte di questi errori è rilevabile staticamente, quindi lascia che sia il linter a trovarli invece dell’event loop. In ESLint, attiva no-await-in-loop per segnalare gli await sequenziali (poi disattivalo riga per riga dove la sequenzialità è voluta) e no-async-promise-executor. Se usi TypeScript, aggiungi @typescript-eslint/no-floating-promises e @typescript-eslint/no-misused-promises: il primo intercetta le promise che non vengono né attese né gestite, il secondo intercetta i callback async passati a funzioni come forEach che ignorano la promise restituita. Quando l’output esce in un ordine diverso da quello che ti aspettavi, scrivi in che coda finisce ogni callback prima di toccare il codice.