Co naprawdę gwarantuje async/await i co pozostawia to tobie
Zrozum, co dokładnie wstrzymuje użycie async/await, jak unikać zapytań serializowanych oraz dlaczego błędy, anulowanie, kolejność wykonywania i próby ponowne wymagają rozwiązań wykraczających poza model async/await.
Funkcja pełna wyrażeń await przypomina skrypt uruchamiany wiersz po wierszu, a właśnie to wrażenie jest początkiem wielu błędów asynchronicznych. Powolne panele kontrolne, błędy „pochłonięte”, przestarzałe wyniki wyszukiwania oraz podwójne pobieranie opłat często wynikają z oczekiwania, że async/await zapewni gwarancje, których nigdy nie oferuje. Gdy już poznasz dokładną, dość prostą specyfikację stojącą za tą składnią, od razu będziesz mógł rozróżnić, które zachowania pochodzą od samego języka, a które nadal musisz sam zaprojektować.
Oto funkcja, która wygląda bezdyskusyjnie sekwencyjnie:
async function loadDashboard(userId) {
const user = await fetchUser(userId);
const projects = await fetchProjects(userId);
const notifications = await fetchNotifications(userId);
return {
user,
projects,
notifications,
};
}
Naturalna interpretacja brzmi: pobrać użytkownika, poczekać, pobrać projekty, poczekać, pobrać powiadomienia, a następnie zwrócić wynik. Ta interpretacja nie jest całkowicie błędna, ale ukrywa ten aspekt, który staje się kluczowy, gdy kod musi działać szybko, równolegle, być możliwy do anulowania lub odporny na awarie.
await nie mówi nic o tym, w jaki sposób wykonywane są podstawowe operacje. Jedynie oznacza miejsce, w którym otaczająca go funkcja asynchroniczna musi zostać wstrzymana, dopóki obietnica nie zostanie rozstrzygnięta. Podczas gdy ta funkcja jest wstrzymana, środowisko wykonawcze kontynuuje przetwarzanie innych zadań. Wiele nieoczekiwanych sytuacji wynika z przypisywania zachowań async/await temu, co w rzeczywistości należy do obietnic, środowiska hostującego, API takiego jak fetch, strategii równoległości lub samej aplikacji.
await wstrzymuje tylko jedną funkcję, a nie całe środowisko wykonawcze
Rozważmy ten mały program, który rejestruje działania związane z wywołaniem sieciowym:
async function loadUser() {
console.log("A");
const user = await fetch("/api/user");
console.log("B");
return user;
}
console.log("1");
loadUser();
console.log("2");
Wywołanie loadUser() powoduje natychmiastowe uruchomienie jego ciała, dlatego najpierw jest zapisywane "A", a potem "1". Gdy wykonywanie dociera do await, funkcja nie blokuje wątku podczas przetwarzania żądania – ona sama jest zawieszana, a kontrola zostaje zwrócona do jej wywołującego, stąd "2" pojawia się przed "B". Gdy obietnica fetch zostanie spełniona, reszta funkcji loadUser() jest umieszczana w kolejce do dalszego wykonywania, i dopiero wtedy jest zapisywane "B". Otrzymany porządek to 1, A, 2, B.
Zatem prawidłowy przekład await to nie „zatrzymaj JavaScript tutaj”, lecz raczej „wszystko po tej linii zależy od tej wartości, więc kontynuuj wykonywanie tej funkcji później”.
To dotyczy również wtedy, gdy oczekiwana wartość jest już dostępna. Oczekiwanie na spełnioną obietnicę lub zwykłą wartość inną niż obietnica nadal odkłada dalszą część funkcji na późniejszy mikrozadanie zamiast kontynuować ją natychmiast, jak opisuje MDN. Dlatego umieszczanie pozornie nieszkodliwych dodatkowych wyrażeń await może zmienić planowanie: każde z nich dodaje kolejną granicę mikrozadania.
Praktycznym skutkiem jest to, że nie można zrozumieć programu, traktując każde await jako wywołanie blokujące w funkcji synchronicznej. Trzeba śledzić, co zostało już uruchomione, które funkcje są obecnie wstrzymane oraz co jeszcze może być wykonywane, zanim dana funkcja wznowi działanie.
async nie przenosi pracy z głównej wątku
Słowo kluczowe async prowadzi do kolejnego błędnego przekonania. Spójrz na ten pętlę obciążoną procesorem:
async function calculateTotal(items) {
let total = 0;
for (const item of items) {
total += expensiveCalculation(item);
}
return total;
}
Nic w tym nie jest konkurencyjne. Dodanie słowa async nie przenosi funkcji expensiveCalculation() na inny wątek, nie paralelizuje pętli i nie zapobiega temu, by intensywne operacje synchroniczne blokowały inne zadania chcące zostać wykonywane na tym samym wątku.
To, co zmienia async, to zasady zwrotu wartości przez funkcję. Jej wywołanie zawsze zwraca obietnicę (promise), a zwrócenie zwykłej wartości realizuje tę obietnicę poprzez dostarczenie tej wartości. Specyfikacja ECMAScript opisuje wykonywanie funkcji async w kategoriach możliwości obsługi obietnic, które stają się wynikiem funkcji.
async function getNumber() {
return 42;
}
const result = getNumber();
console.log(result);
// Promise
Logowanie wartości result pokazuje albo obietnicę w stanie oczekiwania, albo już zrealizowaną obietnicę, a nie wartość 42. Liczbę tę uzyskasz dopiero po oczekiwaniu na realizację obietnicy lub po wywołaniu metody .then() na niej.
Zwracanie obietnicy nie czyni jednak ciała funkcji asynchronicznym. Wszystko, co znajduje się przed pierwszym await, jest wykonywane synchronicznie w momencie wezwania. Jeśli umieścisz kosztowne obliczenia wewnątrz funkcji asynchronicznej i oczekujesz, że interfejs pozostanie responsywny, będziesz rozczarowany: funkcja ostatecznie zwróci obietnicę, ale ciężka praca nadal zajmuje w pierwszej kolejności wątek. W przypadku naprawdę intensywnych obliczeń na CPU dostępne są narzędzia takie jak Web Workers w przeglądarce, worker_threads w Node.js lub dzielenie pracy na mniejsze części.
Krótko mówiąc, async/await to sposób pisania łańcuchów obietnic, które są czytane od góry do dołu. Umożliwia ono planowanie kontynuacji wykonywania kodu; nigdy nie sprawia, że kod blokujący jest wykonywany równocześnie.
Miejsce umieszczenia await może seryjalizować niezależne zadania
Z powrotem do ładowacza panelu kontrolnego:
async function loadDashboard(userId) {
const user = await fetchUser(userId);
const projects = await fetchProjects(userId);
const notifications = await fetchNotifications(userId);
return {
user,
projects,
notifications,
};
}
Załóżmy, że fetchProjects() nie potrzebuje parametru user, a fetchNotifications() nie potrzebuje żadnego z wcześniejszych wyników. Funkcja nadal wysyła trzy niezależne żądania jeden po drugim, ponieważ drugie wezwanie następuje dopiero po spełnieniu się pierwszej obietnicy, a trzecie czeka na drugie. Całkowity czas oczekiwania to suma wszystkich trzech:
fetch user
████████
fetch projects
███████████
fetch notifications
███████
To rozwiązanie jest zwykle opisywane jako „użyj Promise.all(), aby wykonać je równolegle”. Dokładniejszy opis brzmi tak, że wszystkie niezależne operacje muszą zostać rozpoczęte, zanim funkcja będzie czekać na którykolwiek z nich. Wezwanie trzech funkcji najpierw tworzy trzy obietnice w trakcie wykonywania, a dopiero potem kod czeka na łączny wynik:
async function loadDashboard(userId) {
const userPromise = fetchUser(userId);
const projectsPromise = fetchProjects(userId);
const notificationsPromise =
fetchNotifications(userId);
const [user, projects, notifications] =
await Promise.all([
userPromise,
projectsPromise,
notificationsPromise,
]);
return {
user,
projects,
notifications,
};
}
Teraz żądania nakładają się na siebie, a całkowity czas oczekiwania jest mniej więcej równy czasowi najwolniejszego z nich:
fetch user
████████
fetch projects
███████████
fetch notifications
███████
all required results available
Promise.all() przyjmuje zbiór obietnic i zwraca jedną obietnicę, która zostanie spełniona, gdy wszystkie wejściowe obietnice się spełnią. Tablica wyników zachowuje kolejność wejść, niezależnie od tego, która operacja zakończyła się pierwsza, dzięki czemu destrukcja na user, projects i notifications pozostaje poprawna.
Zauważ, że tym, co odróżniało kod sekwencyjny od równoległego, nigdy nie była obecność await. Były to zależności. Gdy jeden krok rzeczywiście wymaga wyniku innego, sekwencyjne oczekiwanie jest właściwym wyborem:
const user = await fetchUser(userId);
const permissions = await fetchPermissions(user.role);
Gdy takiej zależności nie ma, czekanie na każdą operację przed rozpoczęciem następnej powoduje opóźnienia i nie zapewnia większej poprawności. Dlatego przydatne pytanie podczas przeglądu kodu to nie „czy powinno się tu użyć Promise.all()?”, lecz „które operacje zależą od wcześniejszych wyników, a które mogłyby już być wykonywane?”
Promise.all() koordynuje wyniki; nie anuluje zadań
Odkrywszy Promise.all(), wielu programistów formuje nowe założenia dotyczące tego, co dzieje się w przypadku błędu. Weźmy tę wersję:
const [user, projects, notifications] =
await Promise.all([
fetchUser(userId),
fetchProjects(userId),
fetchNotifications(userId),
]);
Jeśli fetchProjects() odrzuci zapytanie wcześnie, łączna obietnica odrzuca się natychmiast z tym powodem, zamiast czekać na pozostałe. Może się wydawać, że pozostałe dwa zapytania zostały wtedy przerwane. Tak nie jest. Odrzucenie łącznej obietnicy nie anuluje niczego, co już się rozpoczęło, co jasno stwierdza MDN. Pozostałe operacje nadal się wykonują, a ich ostateczne wyniki są po prostu ignorowane.
Przy operacjach odczytu to głównie marnotrawstwo zasobów. Przy operacjach zapisu może to mieć poważne konsekwencje:
await Promise.all([
updateProfile(),
writeAuditLog(),
sendWebhook(),
]);
Jeśli writeAuditLog() odrzuci zapytanie, updateProfile() mogło już zostać zapisane, a sendWebhook() może być już w drodze. Złapanie tego odrzucenia nie cofa żadnej z tych operacji.
To jest problem projektu systemu, a nie problem składni. Jeśli te kroki muszą się powieść lub nie powieść razem, potrzebujesz prawdziwego mechanizmu atomowości: transakcji bazy danych dla zmian w jednej bazie danych, lub dla systemów zdalnych jakiejś kombinacji działań korygujących, operacji idempotentnych oraz utrwalonego stanu przepływu pracy. Promise.all() obiecuje poinformowanie cię, gdy grupa obietnic zostanie rozstrzygnięta. Nie gwarantuje jednak odwrócenia działań, anulowania ani tego, że skutki uboczne wystąpią jako jedna całość. Te gwarancje muszą pochodzić skąd indziej.
Narzędziem pokrewnym jest Promise.allSettled(), które czeka na każdy wprowadzony element i raportuje każdy wynik osobno. Jest przydatne, gdy musisz dokładnie wiedzieć, które kroki się powiodły, ale ono również nie zapewnia możliwości odwrócenia działań.
try/catch widzi tylko te odrzucenia, które przechodzą przez nie
Jednym z powodów, dla których async/await stał się popularny, jest to, że błędy obietnic mogą być obsługiwane za pomocą znajomego mechanizmu try/catch:
async function loadUser(userId) {
try {
const user = await fetchUser(userId);
return user;
} catch (error) {
console.error("Failed to load user", error);
throw error;
}
}
Gdy oczekiwana obietnica zostanie odrzucona, wyrażenie await wyrzuca powód odrzucenia wewnątrz funkcji, a otaczający je blok catch przechwytuje go tak samo jak wyjątek synchroniczny.
To nie oznacza jednak, że try nadzoruje każdą operację asynchroniczną rozpoczętą w jego nawiasach. Weźmy pod uwagę tworzenie konta:
async function createAccount(input) {
try {
await saveUser(input);
sendWelcomeEmail(input.email);
return { success: true };
} catch (error) {
console.error(error);
return { success: false };
}
}
saveUser() jest oczekiwany, więc jego niepowodzenie trafia do bloku catch. sendWelcomeEmail() nie jest oczekiwany. Jeśli zwraca obietnicę, która później zostanie odrzucona, to odrzucenie nigdy nie trafia do tego przepływu sterowania, ponieważ nic nie oczekuje ani nie zwraca tej obietnicy. createAccount() może już zwrócić { success: true } w momencie, gdy wysyłka e-maila się nie powiedzie, a to niepowodzenie pojawia się oddzielnie, zazwyczaj jako nierozwiązane odrzucenie. W Node.js w aktualnych wersjach nierozwiązane odrzucenia domyślnie zakończą proces, więc nie jest to kwestia wyglądu.
Pominięcie await nie jest automatycznie błędem. Czasami e-mail celowo nie jest uwzględniany w ścieżce żądania. W projektach produkcyjnych jednak tego typu zadania zazwyczaj trafiają do trwałej kolejki, a nie są uruchamiane jako obietnice bez nadzoru. Prawdziwym błędem jest przekonanie, że blok try tworzy granicę błędów wokół przyszłych zadań asynchronicznych tylko dlatego, że wywołanie znajduje się w jego obrębie.
To samo pułapka występuje w miejscu wywołania. Ten wywołujący wydaje się chroniony, ale fragment kodu to zwykły JavaScript bez await:
try {
loadDashboard(userId);
} catch (error) {
console.error("Dashboard failed");
}
loadDashboard() natychmiast zwraca obietnicę. Każda awaria asynchroniczna odrzuca tę obietnicę później, po tym jak blok try już się zakończył, więc blok catch nigdy nie jest wykonywany. Wywołujący musi brać udział w łańcuchu obietnic:
try {
await loadDashboard(userId);
} catch (error) {
console.error("Dashboard failed");
}
Jako alternatywę można dołączyć obsługę odrzuceń za pomocą .catch(). Zasada staje się prosta, gdy przestaniemy postrzegać funkcje asynchroniczne jako zwykłe funkcje zawierające przypadkowo await: ich wywołujący otrzymują obietnice, a błędy przemieszczają się wzdłuż tego kontraktu obietnicy.
Anulowanie to odrębny protokół
Wyobraźmy pole wyszukiwania, w którym użytkownik wpisuje jeden znak po drugim:
r
re
rea
reac
react
Prosta implementacja wysyła żądanie za każdym razem, gdy użytkownik naciska klawisz, nawet jeśli poprzednie żądania są jeszcze w toku:
async function search(query) {
const response = await fetch(
`/api/search?q=${encodeURIComponent(query)}`
);
return response.json();
}
W await nie ma nic, co wskazywałoby, że żądanie dotyczące "r" powinno zostać przerwane, ponieważ teraz ważne jest żądanie dotyczące "react". Poprzednie żądanie jest realizowane do końca, mimo że interfejs już go nie potrzebuje.
Dla fetch anulowanie realizuje się za pomocą AbortController oraz jego AbortSignal. Ta wersja przerywa poprzednią prośbę przed rozpoczęciem nowej:
let controller;
async function search(query) {
controller?.abort();
controller = new AbortController();
const response = await fetch(
`/api/search?q=${encodeURIComponent(query)}`,
{
signal: controller.signal,
}
);
return response.json();
}
Controller udostępnia sygnał, na który słuchają kompatybilne API. Przerwanie tego sygnału poleca fetch, aby przestał działać, co obejmuje zarówno samą prośbę, jak i odczyt treści odpowiedzi. Należy wziąć pod uwagę jeden szczegół: przerwana próba zwraca błąd AbortError, więc osoba wywołująca search() powinna rozpoznać ten błąd i go zignorować, zamiast pokazywać go użytkownikowi.
Wyrażenie „kompatybilne API” jest istotne. Promises nie mają uniwersalnej funkcji anulowania, a await nie ma sposobu na zatrzymanie tego, czego oczekuje. Anulowanie działa tylko wtedy, gdy wywoływana operacja je obsługuje i przekazuje sygnał do elementu, który wykonywać będzie rzeczywistą pracę.
Wasze własne funkcje mogą przyjąć ten sam kontrakt, akceptując sygnał i sprawdzając go pomiędzy poszczególnymi krokami:
async function processFile(file, { signal }) {
for (const chunk of file.chunks) {
signal.throwIfAborted();
await processChunk(chunk);
}
}
signal.throwIfAborted() wyrzuca powód anulowania, jeśli złożono prośbę o przerwanie, dzięki czemu pętla zatrzymuje się przed przetworzeniem kolejnego fragmentu. Anulowanie jest teraz wyraźną częścią interfejsu funkcji, zamiast czymś, na co użytkownicy liczą, że await to za nich zrobi. Dla większej precyzji można również przekazać sygnał do processChunk(), aby długi fragment mógł zostać zatrzymany w połowie.
Czasowe ograniczenia działają według tej samej logiki. Użycie Promise.race() do porównywania operacji z timerem pozwala użytkownikowi przestać czekać, ale podstawowa operacja kontynuuje się, chyba że również otrzyma polecenie zatrzymania. Zatrzymanie obserwacji i zatrzymanie pracy to dwie różne rzeczy.
Porządek lokalny to nie porządek globalny
Sekwencyjne użycie await rzeczywiście gwarantuje określoną kolejność w obrębie jednej funkcji:
await saveOrder(order);
await sendConfirmation(order);
sendConfirmation() jest wywoływane dopiero po zakończeniu działania saveOrder(). Ta lokalna gwarancja jednak niewiele mówi o reszcie systemu. Wyobraźmy sobie dwa żądania aktualizujące ten sam profil. Jedno z nich wysyła:
await updateProfile({
name: "Umar",
});
Kilka milisekund później drugie wysyła:
await updateProfile({
name: "Umar Dev",
});
Każdy z wywołujących poprawnie czeka na swoją aktualizację. Nic z tego nie określa, które z zapisów dotrze do bazy danych jako ostatnie, jak przebiegają próby ponownych wysyłek po obu stronach, czy dwa żądania są obsługiwane przez różne serwery, ani też czy stare dane mogą nadpisać nowsze.
Wersją tego problemu w przeglądarce jest klasyczny wyścig wyszukiwawczy. Żądanie A rozpoczyna się przed żądaniem B, ale kończy po nim, a kod renderujący otrzymane dane pokazuje przestarzałe wyniki na ekranie:
const results = await search(query);
render(results);
Żaden dodatkowy await nie może to naprawić. Aplikacja potrzebuje zasady dotyczącej relewności lub kolejności: anulowanie starszych zapytań, oznaczanie żądań wersją oraz usuwanie przestarzałych odpowiedzi, porównywanie identyfikatorów przed wyświetleniem lub wprowadzanie kontroli wersji tam, gdzie przechowywane są dane. Należy pamiętać, że await reguluje przepływ sterowania w obrębie jednej funkcji; nie tworzy globalnego porządku pomiędzy niezależnymi operacjami asynchronicznymi.
Ponawiania prób ujawniają to, czego await nigdy nie obiecywał
Ponawiania prób to sytuacja, gdy niekompletny model mentalny staje się kosztowny. Zacznijmy od wywołania służącego do płatności:
async function submitPayment(payment) {
const response = await fetch("/api/payments", {
method: "POST",
body: JSON.stringify(payment),
});
return response.json();
}
Załóżmy, że żądanie wygaśnie i programista doda naiwne ponawienie próby:
try {
return await submitPayment(payment);
} catch {
return await submitPayment(payment);
}
Przypuszcza to, że nieudana próba nic nie spowodowała. Czas wygaśnięcia oznacza jedynie, że klient nie otrzymał odpowiedzi na czas. Serwer mógł zaksięgować płatność kartą i stracić odpowiedź, albo połączenie mogło zostać przerwane już po dokonaniu efektu ubocznego. Ślepe ponawianie prób może skutkować podwójnym obciążeniem klienta.
await nie może podpowiedzieć, czy ponowna próba jest bezpieczna. Obietnica (promise) informuje o tym, czy dana próba przyniosła sukces lub odmowę, które mogą być zaobserwowane przez wywołującego. Nie informuje natomiast o tym, czy system zdalny wykonał nieodwracalne działania przed uzyskaniem tego wyniku.
Dla operacji wywołujących efekty uboczne bezpieczeństwo ponawiania prób musi być zaprojektowane w samym procesie operacji, zwykle poprzez idempotencję. Klient generuje stabilny klucz raz na każdą logiczną próbę płatności i wysyła go przy każdej ponownej próbie:
await fetch("/api/payments", {
method: "POST",
headers: {
"Idempotency-Key": paymentAttemptId,
},
body: JSON.stringify(payment),
});
Klucz jest przydatny tylko wtedy, gdy serwer go uwzględnia: musi on rejestrować klucz razem z wynikiem i, gdy ten sam klucz pojawi się ponownie, zwracać pierwotny wynik zamiast pobierać opłatę ponownie. Klucz musi również pozostać niezmieniony przy powtórnych próbach jednej operacji; generowanie nowego klucza za każdym razem podczas żądania unieważnia jego cel. Implementacja różni się w zależności od systemów, ale zasada pozostaje ta sama: semantyka powtórnych prób należy do samej operacji oraz systemów realizujących jej skutki uboczne, a nie do async/await. Obsługa po stronie serwera jest omówiona w rozumieniu kluczy idempotentnych w punktach końcowych POST w Node.js.
Cennym nawykiem w programowaniu JavaScript jest to, że gdy jakieś oczekiwane wywołanie zawodzi, należy trzymać oddzielnie dwie stwierdzenia: „Nie otrzymałem wyniku pomyślnego” oraz „Operacja z pewnością nie odbyła się”. To nie są to same twierdzenia.
Mniejszy, bardziej precyzyjny model mentalny
Aby dobrze rozumieć mechanizm async/await, nie musisz zapamiętywać specyfikacji ECMAScript. Wystarczy wiedzieć o prostym zasadniczym schemacie: funkcja asynchronna zwraca obietnicę (promise); jej ciało działa normalnie aż do momentu dotarcia do instrukcji await; ta instrukcja wstrzymuje dalsze wykonywanie funkcji do chwili uzyskania wartości, na którą się czeka, a następnie kontynuuje jej działanie.
Wszystko inne to odrębne pytania z własnymi odpowiedziami:
- Kilka operacji: kiedy zaczyna się każda z nich i czy zależy od innej? To pozwala określić, czy sekwencjonowanie jest celowe, czy przypadkowe.
try/catch faktycznie chroni to, co uważamy za chronione.await nie mogą tego zrobić?Główne wnioski
async/await jest celowo prosty. Ułatwia czytelność niesynchronicznego przepływu sterowania, co ma ogromną wartość, ale ta sama czytelność sprawia, że kod wygląda bardziej synchronicznie, niż w rzeczywistości jest system pod spodem. Gdy napotkasz await, unikaj interpretacji jako „program czeka tutaj”. Traktuj to jako sygnał: ta funkcja jest wstrzymana do chwili otrzymania wartości, więc jakie operacje są już w trakcie wykonywania, co może dziać się w międzyczasie i jakie gwarancje musi zapewnić twój własny projekt? To pytanie odpowiada temu, co faktycznie robi składnia, i prowadzi cię prosto do decyzji projektowych, które zapobiegają błędom w produkcji.
Literatura pokrewna
- Async/Await vs Promises: Co tak naprawdę się różni w tle — Wyjaśnia rzeczywiste różnice w wykonywaniu, zużyciu pamięci oraz śladach stosu między async/await a Promises, a także kiedy warto korzystać z surowych API Promise.
- Powszechne błędne wyobrażenia na temat async/await, które powodują błędy w produkcji — Opisuje dziewięć subtelnych nieporozumień związanych z async/await – od warunków konkurencyjnych po nierozpatrzone odrzucenia – które potajemnie psują aplikacje JavaScript w rzeczywistych warunkach.