Przejdź do treści

async/await w JavaScript - jak funkcja może czekać bez blokowania wątku

Profile photo of Adrian Zawadzki

Adrian Zawadzki

10 min czytania

async/await wygląda jak magia: piszesz await fetch(...), dostajesz wynik w kolejnej linijce, a wątek nie blokuje się ani na moment. Pod spodem nie ma magii - jest stary wzorzec generator + Promise + auto-runner, ukryty pod nowymi słowami async i await.

Piszesz to:

async function loadUser() {
  const response = await fetch("/api/user/1");
  const user = await response.json();
  console.log(user.name);
}

Funkcja robi pauzę na await, czeka kilkaset milisekund na sieć, dostaje odpowiedź, idzie dalej. Wątek w międzyczasie obsługuje renderowanie, klikanie, scroll. Wszystko płynnie.

To brzmi jak magia. JavaScript jest jednowątkowy - jeśli funkcja czeka, wątek jest zajęty. Normalna funkcja, gdy ją uruchomisz, leci od góry do dołu i nie umie się zatrzymać w środku. A tu robi pauzę, puszcza wątek wolny i wraca do siebie później.

Pod spodem to nie magia, tylko trzy stare klocki ukryte pod nowymi słowami async i await: Promise, generator, który umie się zatrzymać, i mechanizm łączący jedno z drugim. Ten post jest o tym, czym jest ten mechanizm, dlaczego potrzebował generatorów, i co engine robi naprawdę, gdy zobaczy await.

Jeśli nie wiesz dokładnie, czym jest Promise, zacznij od posta o Promises - zakładam, że wiesz, co znaczy pending, fulfilled, rejected i microtask queue. Drugi klocek to generator - jest osobny post o generatorach z interaktywnym demo; tu rozwijam temat tylko o tyle, ile trzeba do złożenia auto-runnera.

#Co właściwie robi await

W jednym zdaniu:

await przed Promise pauzuje wykonanie tej funkcji do momentu, aż Promise się rozstrzygnie, a potem podstawia jego wartość w miejsce wyrażenia.

(Operandem await nie musi być Promise - jeśli nim nie jest, engine i tak opakuje go w Promise.resolve(x). Czyli await 42 też oddaje wątek na microtask, choć nie ma na co czekać. Mała pułapka warta zapamiętania.)

Czyli te dwa fragmenty są równoważne:

const response = await fetch("/api/user/1");
console.log(response.status);

fetch("/api/user/1").then((response) => {
  console.log(response.status);
});

W obu przypadkach kontynuacja (console.log) odpala się dopiero, gdy Promise z fetch jest gotowy. Ale wizualnie pierwsza wersja jest synchroniczna - linia po linii. Druga - z callbackiem, wcięciem, nową funkcją.

Pytanie brzmi: jak to się dzieje, że pierwsza wersja wygląda synchronicznie, a działa asynchronicznie?

#Co kryje się pod async/await: Promise, generator, auto-runner

Mental model:

  1. Promise - obiekt-pośrednik na wartość, której jeszcze nie ma. Wprowadzony w ES2015, opisany w poście o Promises.
  2. Generator - funkcja, która umie zatrzymać własne wykonanie w środku i wrócić do niego później. Też ES2015, dużo mniej popularna - rozwinięcie w poście o generatorach.
  3. Auto-runner - mechanizm, który bierze generator, wywołuje go krok po kroku, a w miejsca, gdzie generator zwrócił Promise, wsadza wartość, gdy Promise będzie gotowy.

Pierwsze dwa klocki - Promise i generator - dostaliśmy w ES2015. Auto-runner do języka nie wszedł, więc ludzie pisali go ręcznie albo używali biblioteki - najpopularniejsza nazywała się co. Dopiero ES2017 wziął te trzy klocki i schował za jednym słówkiem async.

#Co z generatora bierzemy do async/await

Reszta - iterator protocol i lazy evaluation - jest w poście o generatorach. Tu wystarczą dwie rzeczy.

Pierwsza - yield jako pauza. Funkcja oznaczona function* na każdym yield zatrzymuje swoje wykonanie i oddaje sterowanie z powrotem na zewnątrz. Kolejne g.next() wznawia ją dokładnie z tego samego miejsca - z zachowanymi zmiennymi lokalnymi i pozycją w kodzie.

Druga - g.next(value) jako wejście. Argument przekazany do next() ląduje w miejscu, w którym generator stoi, jako wynik wyrażenia yield. Czyli z generatorem da się rozmawiać dwustronnie: generator oddaje Promise przez yield i mówi “czekam na wynik”, ktoś z zewnątrz czeka, aż Promise się spełni, i podstawia wartość przez g.next(wartość). Generator dostaje ją po prawej stronie yield i leci dalej, jakby nigdy nie było pauzy.

#Generator + Promise = ręczny async/await z 2015

Tak ludzie pisali kod asynchroniczny w latach 2015-2017, zanim język dostał async/await:

function* loadUser() {
  const data = yield fetch("/api/user/1");
  const user = yield data.json();
  return user.name;
}

function run(gen) {
  const it = gen();
  function step(value) {
    const result = it.next(value);
    if (result.done) return Promise.resolve(result.value);
    return Promise.resolve(result.value).then(step);
  }
  return step();
}

run(loadUser).then((name) => console.log(name));

Czytasz to i widzisz dwie rzeczy. Po pierwsze - loadUser wygląda synchronicznie, choć w środku robi dwa requesty. Po drugie - cała “magia” leży w run. Runner robi pętlę: woła next(), dostaje Promise z yield, czeka aż się spełni, podstawia wartość przez next(value), kręci dalej. Gdy generator skończy (done: true), runner zwraca finalną wartość zapakowaną w Promise.

To uproszczona wersja - prawdziwy co ma jeszcze ścieżkę błędu: gdy Promise z yield przejdzie w stan rejected, runner woła it.throw(err) zamiast it.next(val), co wstrzykuje wyjątek do generatora w miejsce po yield. Dzięki temu try/catch wewnątrz generatora łapie błąd tak samo, jak try/catch wokół await.

Czytelnik tego kodu z 2015 zobaczył, że ten wzorzec jest na tyle uniwersalny, że szkoda go nie wbudować w język. Brian Terlson zgłosił proposal do TC39 - wziął ten wzorzec i schował za dwoma słowami: async zamiast function*, await zamiast yield. Plus auto-runner wbudowany w engine.

#async/await to ten sam wzorzec, tylko nowymi słowami

Mapowanie 1:1:

Wzorzec generator + runnerasync/await
function* foo()async function foo()
yield somePromiseawait somePromise
Ręczny runner (co, własny run)Wbudowany w engine
Wynikiem jest PromiseWynikiem jest Promise

To znaczy, że konceptualnie każda async funkcja to generator z auto-runnerem. Spec ECMA-262 ma na to własne wewnętrzne kroki (AsyncFunctionStart, Await, Resume), a nowoczesne silniki to zoptymalizowały - ale model myślowy jest dokładnie taki: engine widzi async, traktuje funkcję jak coś, co umie się zatrzymać, a każde await to punkt zatrzymania, w którym engine sam pyta o Promise i sam podstawia wartość.

Stąd dwie rzeczy “narzucone”:

  1. Funkcja async zawsze zwraca Promise. Bo runner pakuje wynik w Promise (jak w naszym run). Nawet async function foo() { return 42 } zwraca Promise<42>, nie 42.
  2. await działa wewnątrz async funkcji - albo na top-level w module ESM (od ES2022, gdzie cały moduł zachowuje się jak ciało async). Bo await to fakt, że funkcja może być zatrzymana - a tylko async/generator funkcje (i moduł ESM) to potrafią.

#Co engine naprawdę robi przy const response = await fetch(...)

Zejdźmy o poziom niżej. Masz:

async function loadUser() {
  const response = await fetch("/api/user/1");
  console.log(response.status);
}

loadUser();
console.log("po wywołaniu");

Krok po kroku:

  1. loadUser() startuje synchronicznie. Wchodzisz w funkcję, wykonujesz wszystko aż do await.
  2. fetch("/api/user/1") startuje request - przeglądarka wysyła zapytanie HTTP. fetch od razu zwraca Promise w stanie pending (bo fetch to dwustronna fasada - startuje pracę w przeglądarce i od razu daje JS-owi obiekt na wartość, której jeszcze nie ma).
  3. Engine widzi await. Robi trzy rzeczy:
    • zapamiętuje, w którym miejscu funkcji jesteśmy (zmienne lokalne, pozycja w kodzie - dokładnie jak generator po yield),
    • rejestruje kontynuację (cały kod po await) jako callback do .then na tym Promise,
    • zwraca z funkcji - oddaje wątek z powrotem call stackowi.
  4. console.log("po wywołaniu") leci - zauważ, że loadUser() już wyszedł z call stacka, choć “wewnątrz” niego jeszcze nie skończyliśmy. Wątek jest wolny dla innych rzeczy.
  5. Po jakimś czasie przeglądarka kończy request. Promise z fetch przechodzi w fulfilled z odpowiedzią. Zarejestrowana kontynuacja trafia do microtask queue.
  6. Event loop bierze microtask. Wskakujemy z powrotem w loadUser w punkt po await, podstawiamy odpowiedź jako response, lecimy dalej. console.log(response.status) wypisuje.

Klikaj Dalej → żeby przeklikać ten przepływ krok po kroku, albo puść Uruchom i obserwuj, jak loadUser znika ze stacka, kontynuacja siedzi w microtask queue, a po odpowiedzi przeglądarki wraca jako drugi odcinek tej samej funkcji:

1async function loadUser() {
2 const data = await fetch("/api/user/1");
3 console.log(data.status);
4}
5
6loadUser();
7console.log("po wywołaniu");
Call stack0
puste
Browser / Web API
puste
Microtask queue0
puste
Konsola

Klucz: await to nie blokowanie wątku. To wyjście z funkcji + zarejestrowanie kontynuacji jako microtaska. Funkcja “wraca” do siebie dopiero, gdy event loop wezwie ją z powrotem. Z perspektywy kodu wygląda jak pauza - z perspektywy wątku to dwa osobne odcinki wykonania, sklejone razem przez engine.

To dlatego nie blokujesz scrollowania, animacji, klików - wątek między jednym a drugim odcinkiem loadUser jest dostępny dla wszystkich innych zadań.

#Puenta

Trzy rzeczy do zapamiętania:

  1. async/await to nie nowy mechanizm async. To wbudowany w język wzorzec generator + Promise + auto-runner, który ludzie pisali ręcznie w latach 2015-2017 z biblioteką co.
  2. await nie blokuje wątku. Wychodzi z funkcji, rejestruje kontynuację jako microtask, oddaje wątek event loopowi. Wraca dopiero, gdy Promise jest gotowy.
  3. Dla niezależnych requestów Promise.all zamiast await w pętli. Sekwencja zamienia 200 ms na 2 sekundy. Z Promise.allSettled, jeśli pojedyncza porażka nie ma kasować reszty, i z limitem równoległości, jeśli requestów są setki.

Reszta składni - try/catch wokół await, Promise.all zamiast await w pętli, top-level await - to wariacje wokół tej samej decyzji: zamienić ręczny generator + runner na dwa słowa kluczowe.

Komentarze

Wczytywanie komentarzy…

Dodaj komentarz