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

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:
awaitprzed 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:
- Promise - obiekt-pośrednik na wartość, której jeszcze nie ma. Wprowadzony w ES2015, opisany w poście o Promises.
- 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.
- 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 + runner | async/await |
|---|---|
function* foo() | async function foo() |
yield somePromise | await somePromise |
Ręczny runner (co, własny run) | Wbudowany w engine |
| Wynikiem jest Promise | Wynikiem 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”:
- Funkcja
asynczawsze zwraca Promise. Bo runner pakuje wynik w Promise (jak w naszymrun). Nawetasync function foo() { return 42 }zwracaPromise<42>, nie42. awaitdziała wewnątrzasyncfunkcji - albo na top-level w module ESM (od ES2022, gdzie cały moduł zachowuje się jak ciało async). Boawaitto fakt, że funkcja może być zatrzymana - a tylkoasync/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:
loadUser()startuje synchronicznie. Wchodzisz w funkcję, wykonujesz wszystko aż doawait.fetch("/api/user/1")startuje request - przeglądarka wysyła zapytanie HTTP.fetchod razu zwraca Promise w staniepending(bofetchto dwustronna fasada - startuje pracę w przeglądarce i od razu daje JS-owi obiekt na wartość, której jeszcze nie ma).- 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.thenna tym Promise, - zwraca z funkcji - oddaje wątek z powrotem call stackowi.
- zapamiętuje, w którym miejscu funkcji jesteśmy (zmienne lokalne, pozycja w kodzie - dokładnie jak generator po
console.log("po wywołaniu")leci - zauważ, żeloadUser()już wyszedł z call stacka, choć “wewnątrz” niego jeszcze nie skończyliśmy. Wątek jest wolny dla innych rzeczy.- Po jakimś czasie przeglądarka kończy request. Promise z
fetchprzechodzi wfulfilledz odpowiedzią. Zarejestrowana kontynuacja trafia do microtask queue. - Event loop bierze microtask. Wskakujemy z powrotem w
loadUserw punkt poawait, podstawiamy odpowiedź jakoresponse, 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:
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:
async/awaitto 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.awaitnie blokuje wątku. Wychodzi z funkcji, rejestruje kontynuację jako microtask, oddaje wątek event loopowi. Wraca dopiero, gdy Promise jest gotowy.- Dla niezależnych requestów
Promise.allzamiastawaitw pętli. Sekwencja zamienia 200 ms na 2 sekundy. ZPromise.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…