Async/Await kontra Promises: Co tak naprawdę różni się w tle
W czym dokładnie różnią się async/await od Promises w JavaScript: kolejność wykonywania, pamięć, ślady stosu oraz sytuacje, gdy surowe API Promises nadal są lepsze w Node.js.
Źródła: Async/Await vs Promises — The Real Difference Nobody Explains Clearly
Dość stwierdzeń typu „to tylko syntaktyczny dodatek”. Oto, co naprawdę dzieje się pod maską podczas wykonywania kodu, zarządzania pamięcią, tworzenia ścieżek stosu oraz realizacji zadań równolegle.
Zapytaj grupę programistów JavaScript, aby wyjaśnili różnice między Promises a async/await – większość powtórzy to samo zdanie, które słyszałeś setki razy:
„Async/await to nic więcej niż warstwa estetyczna nałożona na Promises – pozwala ona kodowi asynchronicznemu wyglądać tak, jakby był wykonywany synchronicznie.”
To stwierdzenie nie jest dokładnie błędne, ale to uproszczenie, które ukrywa więcej niż ujawnia. Określanie async/await jako „tylko cukru składniowego” jest trochę jak opisywanie automatycznej skrzyni biegów jako „tylko panelu dekoracyjnego przymocowanego do manualnej skrzyni biegów”. Przez to pomija się sposób, w jaki faktycznie funkcjonuje podstawowy mechanizm, jak błędy rozprzestrzeniają się w systemie, jak przydzielana jest pamięć oraz dlaczego wybór jednego stylu zamiast drugiego może znacząco zmienić zachowanie aplikacji podczas jej działania w środowisku produkcyjnym.
Jeśli obie metody byłyby rzeczywiście wzajemnie zamienne na każdym poziomie, rozważmy następujące pytania:
- Dlaczego ścieżki błędów wyglądają zupełnie inaczej, gdy awaria występuje wewnątrz
awaitw porównaniu z wewnątrz.catch()?
.then() a odpowiednią funkcją async?Promise, nawet w kodach zbudowanych wyłącznie wokół async/await?Odrzućmy uproszczone wyjaśnienia i przyjrzyjmy się temu, co tak naprawdę dzieje się w środowisku wykonawczym JavaScript podczas uruchamiania kodu asynchronicznego.
1. Podstawowy model myślowy: Push vs. Pause
Aby zrozumieć, dlaczego te dwa style wydają się tak różne podczas pisania i późniejszego debugowania, musisz znać odrębne modele wykonywania, od których każdy z nich zależy.
RAW PROMISE (.then CHAIN) ASYNC / AWAIT (COROUTINE)
=========================== =========================
+-----------------------+ +-----------------------+
| Initiate Task | | Initiate Task |
+-----------+-----------+ +-----------+-----------+
| |
+-----------v-----------+ +-----------v-----------+
| Register Callback | | Yield & Pause Frame |
| (Handler pushed away) | | (Thread freed up) |
+-----------+-----------+ +-----------+-----------+
| |
+-----------v-----------+ +-----------v-----------+
| Main Stack Clears | | Resume on Resolution |
| (Context lost) | | (Context restored) |
+-----------------------+ +-----------------------+
Model Promise: Rejestracja callbacki (Push)
Promise to w istocie obiekt reprezentujący przyszły wynik — sukces lub niepowodzenie — jakiejś asynchronicznej operacji.
Weźmy typową łańcuchową strukturę .then():
function getUserData(userId) {
return fetchUser(userId)
.then(user => fetchOrders(user.orderId))
.then(orders => calculateTotal(orders))
.catch(err => handleError(err));
}
W rzeczywistości nic tutaj nie jest wstrzymywane. Funkcja getUserData wykonywana jest synchronicznie, od początku do końca, w ciągu zaledwie mikrosekund. W rzeczywistości tworzy ona Promise, dołącza funkcje callback, które zostaną uruchomione po ustaleniu się tych Promise’ów, a następnie natychmiast opuszcza stos wywołań.
Później, gdy żądanie sieciowe zostanie zakończone, pętla wydarzeń pobiera zarejestrowaną funkcję callback z kolejki mikrozadań i umieszcza ją na zupełnie nowym, wcześniej pustym stosie wywołań. Oryginalna funkcja, która rozpoczęła tę łańcuchową strukturę, już całkowicie zniknęła.
Model Async/Await: Wstrzymanie korutyny (Pauza i kontynuacja)
async/await jest implementowany na bazie generatorów ECMAScript oraz mechanizmów korutyn.
async function getUserData(userId) {
try {
const user = await fetchUser(userId);
const orders = await fetchOrders(user.orderId);
return calculateTotal(orders);
} catch (err) {
handleError(err);
}
}
Gdy silnik osiąga wyrażenie await, nie usuwa funkcji — zamiast tego zawiesza ten konkretny kontekst stosu na miejscu.
- Kontrola wraca do głównej wątku, umożliwiając mu wykonywanie innych zadań — np. ponowne rysowanie interfejsu w przeglądarce lub obsługę innych przychodzących żądań w Node.js.
- Wszystko związane ze stanem lokalnym funkcji — jej zmienne, zakres i pozycja — pozostaje nienaruszone w pamięci.
- Gdy oczekiwany Promise zostanie rozwiązany, silnik wprowadza uzyskaną wartość z powrotem do tego samego kontekstu wykonywania i kontynuuje działanie dokładnie od miejsca przerwy.
To jedno rozróżnienie – przekazanie oddzielonego callbacka do wykonania później w porównaniu z zamrożeniem i późniejszym przywróceniem ramy wykonywania – tłumaczy niemal każdą praktyczną różnicę, jaką zauważysz pomiędzy tymi dwoma stylem.
2. Anatomia stosu wywołań: dlaczego debugowanie się różni
Pogoń za błędami w środowisku produkcyjnym jest już jedną z najtrudniejszych części pracy z JavaScript, a różnica między łańcuchami Promise a async/await staje się natychmiast widoczna po rozpoczęciu analizy logów.
Jak wygląda ślad błędu Promise
Weźmy łańcuch Promise, w którym błąd występuje na kilku poziomach głęboko wewnątrz wywołania sieciowego:
function stepOne() {
return stepTwo();
}
function stepTwo() {
return stepThree();
}
function stepThree() {
return fetch('https://invalid-url-domain-does-not-exist.xyz')
.then(res => res.json());
}
stepOne().catch(err => console.error(err.stack));
Gdy dojdzie do awarii, powstały ślad zazwyczaj wygląda w ten sposób:
TypeError: Failed to fetch
at stepThree (<anonymous>:10:5)
at processTicksAndRejections (node:internal/process/task_queues:95:5)
Cóż jest nieobecne? Ani stepOne, ani stepTwo nie pojawiają się nigdzie w tym śladzie.
Zanim wywołanie sieciowe faktycznie zawiodło, zarówno stepOne, jak i stepTwo już dawno zakończyły swoją pracę i zostały usunięte ze stosu. Kod obsługi błędów uruchomił się przy kolejnym cyklu mikrozadania, całkowicie oderwany od miejsca, gdzie powstała łańcuchowa sekwencja wywołań. Jeśli stepThree to funkcja wspólna używana z pięćdziesięciu różnych miejsc w kodzie, ślad nie daje możliwości określenia, który wywołujący element spowodował ten konkretny błąd.
Jak wygląda ślad stosu w Async/Await
A teraz przepisz tę samą logikę przy użyciu async/await:
async function stepOne() {
return await stepTwo();
}
async function stepTwo() {
return await stepThree();
}
async function stepThree() {
const res = await fetch('https://invalid-url-domain-does-not-exist.xyz');
return await res.json();
}
(async () => {
try {
await stepOne();
} catch (err) {
console.error(err.stack);
}
})();
Oto ślad, który to generuje:
TypeError: Failed to fetch
at stepThree (<anonymous>:10:21)
at async stepTwo (<anonymous>:6:10)
at async stepOne (<anonymous>:2:10)
at async <anonymous>:15:5
Ponieważ silnik wstrzymuje wykonywanie kodu zamiast go przerywać, nowoczesne silniki JavaScript — takie jak V8 w Chrome i Node.js — mogą odbudować historię wywołań asynchronicznych praktycznie bez żadnych kosztów w czasie wykonywania. Silnik śledzi ciągłość wykonywania kodu przez każdy punkt wstrzymania, dzięki czemu ślad wykonywania pokazuje całą ścieżkę, która doprowadziła do błędnego wywołania wewnątrz stepThree.
3. Pułapka „Return Await”: kluczowy przypadek graniczny
Istnieje długotrwała kontrowersja co do tego, czy return await promise jest zbędne w porównaniu z prostym zapisem return promise.
Poza blokiem try/catch bezpośrednie zwrócenie Promise oszczędza jeden dodatkowy cykl mikrozadania, więc return promise jest technicznie bardziej efektywne. Jednak wewnątrz try/catch to pojedyncze słowo — await — całkowicie zmienia sposób łapania błędów.
Zepsuta wersja
async function fetchUserData(userId) {
try {
// BUG: Returning the promise directly without awaiting it
return api.getUser(userId);
} catch (error) {
console.error("Failed to fetch user, applying fallback...", error);
return getFallbackUser();
}
}
Pół sekundy później, gdy Promise faktycznie odrzuca wartość, blok try/catch już zakończył swoją eksploatację. Klauzula catch nigdy nie zostaje uruchomiona, ponieważ nie jest już częścią aktywnej ścieżki wykonywania. Zamiast tego odrzucenie wymyka się całkowicie z lokalnego obsługiwania błędów i pojawia się jako nierozwiązane odrzucenie gdzieś w łańcuchu wywołań, tam gdzie zostało wywołane fetchUserData.
async function fetchUserData(userId) {
try {
// CORRECT: Await forces resolution BEFORE exiting the try block
return await api.getUser(userId);
} catch (error) {
console.error("Failed to fetch user, applying fallback...", error);
return getFallbackUser();
}
}
Dodanie await rozwiązuje ten problem, ponieważ poleca silnikowi zatrzymanie się w tym wierszu i pozostanie w bloku try aż Promise zostanie rozstrzygnięty. Jeśli odrzuci się wartość, wykonywanie kontynuuje się w tym kontekście na tyle długo, by blok catch mógł to przechwycić, zamiast pozwolić funkcji na powrót jeszcze przed wystąpieniem błędu.
4. Zasięg i wspólny stan: unikanie „gimnastyki zasięgu”
Łańcuchy Promise’ów ułożone warstwowo stają się szczególnie kłopotliwe, gdy późniejszy etap wymaga danych wygenerowanych przez wcześniejszy.
Załóżmy, że musimy pobrać użytkownika, następnie jego profil, a na końcu wygenerować paragon, który zależy od danych z obu tych kroków:
function generateReceipt(userId) {
return getUser(userId)
.then(user => {
return getProfile(user.profileId)
.then(profile => {
// You are forced to nest here just to access `user` and `profile` together
return createReceipt(user, profile);
});
});
}
Aby uniknąć kształtu piramidy, programiści często decydują się na deklarowanie zmiennych poza łańcuchem, aby późniejsze funkcje .then() mogły do nich uzyskać dostęp:
function generateReceipt(userId) {
let tempUser; // Leaking scope variable
return getUser(userId)
.then(user => {
tempUser = user;
return getProfile(user.profileId);
})
.then(profile => {
return createReceipt(tempUser, profile);
});
}
async/await eliminuje potrzebę tego rozwiązania workaround, ponieważ każda zadeklarowana zmiana pozostaje w tym samym zakresie na poziomie funkcji przez cały czas trwania operacji:
async function generateReceipt(userId) {
const user = await getUser(userId);
const profile = await getProfile(user.profileId);
return createReceipt(user, profile);
}
Nie ma żadnej zmiennych zewnętrznej do uprzedniego deklarowania, żadnej zagnieżdżonej struktury callbacków ani konieczności śledzenia łańcucha, aby ustalić, skąd pochodzi dana wartość.
5. Wydajność i równoczesność: Pułapka sekwencyjna
async/await ułatwia czytanie kodu, ale otwiera drogę do kosztownego błędu: nieumyślnego sekwencjonowania operacji, które mogłyby być wykonywane jednocześnie.
Rozwijający przechodzący od łańcuchów Promise do async/await często kończą na pisaniu czegoś w tym stylu, nie zdając sobie sprawy z kosztów wydajnościowych:
// SLOW: 3 sequential network requests (Waterfall)
async function loadDashboardData(userId) {
const user = await fetchUser(userId); // Takes 200ms
const posts = await fetchPosts(userId); // Takes 200ms (waits for user)
const stats = await fetchStats(userId); // Takes 200ms (waits for posts)
return { user, posts, stats }; // Total time: 600ms
}
Ponieważ każde await blokuje funkcję do chwili zakończenia tej wywołania, fetchPosts nie zostanie nawet uruchomiony, dopóki fetchUser całkowicie się nie zakończy — mimo że fetchPosts i fetchStats w ogóle nie zależą od wyniku fetchUser.
SEQUENTIAL (Slow Waterfall):
[-- fetchUser (200ms) --] -> [-- fetchPosts (200ms) --] -> [-- fetchStats (200ms) --] = 600ms
CONCURRENT (Optimized):
[-- fetchUser (200ms) --]
[-- fetchPosts (200ms) --]
[-- fetchStats (200ms) --] = 200ms (Total)
Rozwiązanie: Połączenie async/await z kombinatorami Promise
async/await nie jest zamiennikiem metod pomocniczych Promise — ma służyć do ich współdzielenia.
// FAST: All 3 requests start immediately in parallel
async function loadDashboardData(userId) {
const [user, posts, stats] = await Promise.all([
fetchUser(userId),
fetchPosts(userId),
fetchStats(userId)
]);
return { user, posts, stats }; // Total time: ~200ms
}
6. Pętle i iteracja: Pułapka metod tablicy
Częstym źródłem błędów w obecnych kodach JavaScript są połączenia asynchronicznych callbacki z metodami tablicy takimi jak .forEach(), .map() lub .filter().
async function saveUsers(users) {
users.forEach(async (user) => {
await db.users.insert(user);
});
console.log("All users saved!"); // LIES: This logs BEFORE the database calls finish!
}
To nie działa, ponieważ Array.prototype.forEach jest z natury synchroniczny — nigdy nie sprawdza, co zwraca funkcja callback. Chociaż przekazanie funkcji asynchronicznej do forEach powoduje, że każde wezwanie tworzy Promise, forEach nie ma mechanizmu do czekania na te Promises; po prostu przechodzi do następnej iteracji.
Istnieją dwie poprawne alternatywy, w zależności od tego, czego faktycznie potrzebujesz.
Opcja A wykonywać zadania po jednym elemencie za pomocą pętli for...of, co jest odpowiednie, gdy liczy się kolejność lub musisz uniknąć przeciążenia API z ograniczoną szybkością odpowiedzi:
async function saveUsersSequentially(users) {
for (const user of users) {
await db.users.insert(user);
}
console.log("All users saved safely in order.");
}
Opcja B wykonywa wszystko równocześnie, łącząc .map() z Promise.all, co nadaje się w przypadkach, gdy operacje są od siebie niezależne:
async function saveUsersConcurrently(users) {
await Promise.all(users.map(user => db.users.insert(user)));
console.log("All users saved simultaneously.");
}
7. Kiedy surowe Promises wciąż są lepsze
async/await powinien być Twoim głównym narzędziem do realizacji liniowej logiki biznesowej, ale istnieją konkretne sytuacje architektoniczne, w których lepszym wyborem jest praca bezpośrednio z Promises.
Pierwszą z nich jest wykonywanie w tle zadań, które można po prostu uruchomić i zapomnieć o nich — sytuacje, gdy chcesz wywołać efekt uboczny bez zmuszania osoby, która je uruchomiła, do oczekiwania:
async function handleUserSignup(req, res) {
const user = await db.createUser(req.body);
// Non-blocking: Fire and handle errors without awaiting
sendWelcomeEmail(user.email).catch(err => {
logger.error("Failed to send welcome email in background", err);
});
// Respond immediately without waiting for the email service
return res.json({ status: "success", user });
}
Druga to zarządzanie bardziej złożonymi scenariuszami równoczesności, gdzie dedykowane metody kombinatoryczne Promises oferują specjalnie zaprojektowane zachowania:
Promise.race()umożliwia przerwanie wolnych połączeń sieciowych za pomocą czasu wygaśnięcia.Promise.allSettled()uruchamia kilka niezależnych zadań i raportuje o wszystkich z nich, zarówno o tych udanych, jak i nieudanych.Promise.any()rozwiązuje się, gdy tylko któryś z podanych Promises odniesie sukces, co jest przydatne w strategiach awaryjnych dla różnych regionów lub źródeł danych.
// Timeout a network request after 5 seconds
const data = await Promise.race([
fetch('https://api.example.com/data').then(r => r.json()),
new Promise((_, reject) =>
setTimeout(() => reject(new Error('Request timed out')), 5000)
)
]);
Trzecim scenariuszem jest budowanie mostu wokół starszych API opartych na funkcjach zwrotnych — na przykład fs.readFile w Node lub starszego SDK — które wymagają wyraźnego utworzenia obiektu new Promise():
function readConfigFile(path) {
return new Promise((resolve, reject) => {
legacyLib.readFile(path, (err, data) => {
if (err) return reject(err);
resolve(data);
});
});
}
8. Podsumowanie porównania
Wynik
async/await to nie tylko estetyczna składnia nakładana na Promises.
Mimo że nadal opiera się na Promises w tle do reprezentowania zakończenia operacji, przekształca kod asynchroniczny z łańcucha rejestracji funkcji zwrotnych w coś bardziej przypominającego przerwalną korekturę. To właśnie ta zmiana umożliwia uzyskanie sensownych ścieżek stosu, standardowe obsługi błędów na poziomie języka za pomocą try/catch oraz proste rozszerzanie zasięgu dostępu do zmiennych.
Opanowanie nowoczesnego asynchronicznego JavaScript polega ostatecznie na rozpoznaniu, kiedy czytelny, liniowy styl async/await pasuje do twojej sekwencyjnej logiki, a kiedy należy skorzystać z surowych kombinatorów Promise — Promise.all, Promise.allSettled, Promise.race — aby skutecznie zarządzać równoczesnością.