Odpal to w Node albo w konsoli przeglądarki:

1
2
3
4
console.log("1");
setTimeout(() => console.log("2"), 0);
Promise.resolve().then(() => console.log("3"));
console.log("4");

Wynik to 1 4 3 2. Callback promise wygrywa z setTimeout ustawionym na 0ms: wykonuje się przed nim, choć obu i tak przyjdzie zaczekać, aż zakończy się cały synchroniczny kod, który jest jeszcze do wykonania poniżej. async/await jest zbudowane na tej kolejności, nie na czymś nowym. Większość zaskakujących zachowań w asynchronicznym JavaScript wynika z traktowania await jak czegoś, co zatrzymuje cały program, podczas gdy zatrzymuje tylko funkcję, w której się znajduje.

Czemu setTimeout wykonuje się później niż promise bez opóźnienia

JavaScript wykonuje twój kod na jednym wątku i ma tylko jeden call stack. Funkcje trafiają na niego, gdy są wywoływane, i schodzą z niego, gdy zwracają wynik, a nic innego nie działa, dopóki na stosie jest cokolwiek. Timery, odpowiedzi sieciowe i rozstrzygnięcia promise są obsługiwane przez środowisko wykonawcze wokół stosu (przeglądarkę albo libuv w Node), które umieszcza callback w kolejce na później.

To, w jakiej kolejce znajdzie się callback, zależy od tego, jaki to rodzaj pracy:

  • task queue (nazywana też macrotask queue) przechowuje callback dla setTimeout i setInterval, zakończone operacje I/O oraz zdarzenia UI. Event loop bierze z tej kolejki dokładnie jedno zadanie na iterację.
  • microtask queue przechowuje callback promise (.then, .catch, .finally) oraz wszystko, co zaplanujesz przez queueMicrotask. Event loop opróżnia tę kolejkę całkowicie, razem z kolejnymi zadaniami microtask dodanymi w trakcie jej opróżniania, zanim przejdzie dalej.

Jedna iteracja pętli wygląda tak:

1
2
3
4
5
6
wykonaj cały synchroniczny kod, aż call stack będzie pusty
  -> wyczyść całą microtask queue (nowe microtaski wskakują do tej samej kolejki)
    -> wykonaj dokładnie jeden task z task queue
      -> wyczyść microtask queue jeszcze raz
        -> (tylko w przeglądarce) repaint, jeśli potrzebny
          -> powtórz

W powyższym fragmencie console.log("4") kończy przebieg synchroniczny, a kolejka microtask się opróżnia i wypisuje 3. Dopiero wtedy pętla odbiera callback timera, który wypisuje 2. Jeśli pracowałeś już z kolejką wiadomości, ten model jest ci znany: praca czeka w kolejce, a jeden konsument obsługuje po jednym elemencie na raz. Haczyk w tym, że JavaScript ma dwie takie kolejki, a ta dla zadań microtask jest obsługiwana zawsze pierwsza.

Skoro kolejka microtask musi się całkowicie opróżnić, zanim wykona się jakiekolwiek zadanie z task queue, długi łańcuch promise, który wciąż planuje kolejne callbacki, zagłodzi timery, a w przeglądarce zablokuje przemalowania (repaint).

Gdzie dokładnie await zatrzymuje wykonanie kodu

Funkcja async zawsze zwraca promise, nawet bez return i bez await w swoim ciele. await zatrzymuje tylko tę jedną funkcję: wszystko, co jest po nim, staje się kontynuacją, która wykonuje się jako microtask, gdy oczekiwana wartość się rozstrzygnie. To składnia nad .then(), nie inny mechanizm.

1
2
3
4
5
6
7
8
9
async function example() {
  console.log("a");
  await null;
  console.log("b");
}

console.log("start");
example();
console.log("end");

To wypisuje start, a, end, b. example() wykonuje się synchronicznie do await null, a potem oddaje kontrolę z powrotem do funkcji wywołującej. console.log("end") wykonuje się, zanim example zostanie wznowione, ponieważ wznowienie jest umieszczane w kolejce jako microtask, a microtaski czekają, aż zakończy się aktualny kod synchroniczny.

Ten jeden fakt — await oddaje kontrolę do kolejki microtask i niczego więcej — wyjaśnia każdy z poniższych błędów. To też powód, dla którego async/await wyparło zagnieżdżone łańcuchy .then(). Łańcuch, który obsługuje błędy na trzech różnych poziomach zagnieżdżenia, to właśnie ten rodzaj poplątanego przepływu wykonania, który opisuje artykuł o spaghetti code. Kolejne instrukcje await przywracają porządek kodu, od góry do dołu, tak jak go czytasz.

Co się dzieje, gdy zapomnisz słowa await

Pomiń await, a dostaniesz obiekt promise zamiast jego wyniku. Żadnego błędu, tylko złe dane trzy linie dalej:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
async function getUser(id) {
  const res = await fetch(`/api/users/${id}`);
  return res.json();
}

async function printUser(id) {
  const user = getUser(id); // missing await
  console.log(user.name);
}

printUser(4821); // "undefined" — user is a Promise, not the resolved object

getUser(4821) zwraca natychmiast pending Promise, a Promise nie ma właściwości name. Naprawa to jedno słowo: const user = await getUser(id). Ten błąd jest kosztowny właśnie przez swoją ciszę. Nic nie wyrzuca wyjątku w wadliwej linii; undefined znajdujesz gdzieś dalej w kodzie, albo [object Promise] w template string.

Kiedy używać await w pętli, a kiedy Promise.all

await wewnątrz pętli for kończy każdą iterację, zanim zacznie się następna, nawet gdy iteracje nie są od siebie zależne:

1
2
3
4
5
6
7
async function loadUsers(ids) {
  const users = [];
  for (const id of ids) {
    users.push(await getUser(id)); // one request at a time
  }
  return users;
}

Jeśli każde wywołanie getUser trwa 200ms, a ids ma trzy elementy, całość zajmuje około 600ms. Drugie zapytanie nie zaczyna się, dopóki nie wróci odpowiedź na pierwsze. Gdy zapytania są od siebie niezależne, wystartuj je wszystkie naraz i zaczekaj na całą partię:

1
2
3
async function loadUsers(ids) {
  return Promise.all(ids.map((id) => getUser(id)));
}

Teraz wszystkie trzy zapytania startują razem, a funkcja zwraca wynik po około 200ms, czyli po czasie, jakiego potrzebuje najwolniejsze z nich. Promise.all odrzuca się, jak tylko jeden promise zostanie odrzucony. Jeśli potrzebujesz każdego wyniku niezależnie od pojedynczych niepowodzeń, Promise.allSettled daje ci status dla każdego z nich, zamiast przerywać działanie przy pierwszym błędzie.

Sekwencyjny await jest właściwym wyborem w dwóch przypadkach. Pierwszy: każda iteracja potrzebuje poprzedniego wyniku, jak przy paginacji API za pomocą kursora z ostatniej odpowiedzi. Drugi: świadomie ograniczasz tempo wobec endpointu z rate limitem, który odrzuciłby serię równoczesnych wywołań. Przy dużej tablicy ids Promise.all może sam wpaść w ten drugi problem, więc ogranicz współbieżność małym poolem albo biblioteką jak p-limit.

Czemu async forEach nie czeka na wynik

forEach wywołuje swój callback dla każdego elementu i odrzuca to, co callback zwróci, włącznie z promise:

1
2
3
4
5
6
7
function printAll(ids) {
  ids.forEach(async (id) => {
    const user = await getUser(id);
    console.log(user.name);
  });
  console.log("done");
}

"done" wypisuje się, zanim wypisze się jakiekolwiek imię użytkownika. forEach startuje wszystkie trzy funkcje callback i zwraca wynik bez czekania na żadną z nich, a nie możesz na niego zastosować await, bo zwraca undefined, nie promise. Do pracy sekwencyjnej użyj for...of z await w środku. Do pracy współbieżnej użyj tej samej poprawki co w pętli powyżej:

1
2
3
4
async function printAll(ids) {
  const users = await Promise.all(ids.map((id) => getUser(id)));
  users.forEach((user) => console.log(user.name));
}

Działa to, bo map zwraca tablicę obiektów promise, a to jest dokładnie to, co przyjmuje Promise.all.

Czemu unhandled promise rejection wysadza Node

Funkcja async, która wyrzuca wyjątek, tworzy odrzucony promise, a nie wyjątek w stack frame funkcji wywołującej:

1
2
3
4
5
async function risky() {
  throw new Error("boom");
}

risky(); // called with no .catch and no surrounding try/catch

W karcie przeglądarki to wypisuje w konsoli Uncaught (in promise), a strona działa dalej. Node jest bardziej rygorystyczny: od Node 15 unhandled rejection jest domyślnie traktowany jak nieprzechwycony wyjątek, a proces się kończy. Obsługuj odrzucenia tam, gdzie powstają, za pomocą try/catch wokół await albo .catch() na wywołaniu. Potem, jako siatkę bezpieczeństwa ostatniej szansy, dodaj globalny handler:

1
2
3
process.on("unhandledRejection", (reason) => {
  console.error("unhandled rejection:", reason);
});

Zarejestrowanie tego listenera zastępuje domyślne zatrzymanie Node, więc proces teraz działa dalej nawet po odrzuceniu, o którym nic nie wie. Użyj go, żeby zalogować błąd i potem zamknąć proces w kontrolowany sposób (wywołaj process.exit(1) po zrzuceniu logów), a nie żeby zamaskować brakujące handlery.

Czemu try/catch nie przechwytuje błędów w funkcjach callback

try/catch widzi tylko wyjątki wyrzucone w trakcie działania swojego bloku. Kod zaplanowany na później, w innym zadaniu albo w kolejce microtask, jest poza jego zasięgiem, nawet jeśli jest zapisany w następnej linii:

1
2
3
4
5
6
7
try {
  setTimeout(() => {
    throw new Error("late");
  }, 100);
} catch (err) {
  console.log("caught:", err.message); // never runs
}

setTimeout planuje callback i zwraca wynik, więc blok try kończy się natychmiast. Gdy callback wyrzuca wyjątek 100ms później, działa to na świeżym call stack, bez żadnego catch powyżej.

await działa inaczej. Funkcja async jest zawieszona, nie zakończona: jej lokalny stan i jej blok try są zachowane, a gdy oczekiwany promise zostanie odrzucony, funkcja wraca do działania, wyrzucając odrzucenie w linii z await, wewnątrz try:

1
2
3
4
5
6
7
8
async function loadUser(id) {
  try {
    return await getUser(id);
  } catch (err) {
    console.error(`failed to load user ${id}:`, err.message);
    throw err;
  }
}

Zwróć uwagę na return await. Przy gołym return getUser(id) funkcja oddaje promise od razu, bez czekania na niego, a odrzucenie całkowicie omija ten catch.

Jak wychwycić te błędy zanim trafią na produkcję

Większość tych błędów da się wykryć statycznie, więc niech znajdzie je linter, a nie event loop. W ESLint włącz no-await-in-loop, żeby wychwytywać sekwencyjne await (a potem wyłącz je inline tam, gdzie sekwencyjność jest zamierzona) oraz no-async-promise-executor. Jeśli używasz TypeScript, dodaj @typescript-eslint/no-floating-promises i @typescript-eslint/no-misused-promises: pierwsza reguła wychwytuje promise, na które nikt nie czeka i których nikt nie obsługuje, druga wychwytuje funkcje callback przekazywane w miejsca jak forEach, które ignorują zwrócony promise. Gdy wynik i tak wychodzi w kolejności, której się nie spodziewałeś, zapisz sobie, w jakiej kolejce znajdzie się każdy callback, zanim zaczniesz grzebać w kodzie.

Powiązane artykuły