Async/Await протык Promises: Чаму на самай справе разлічаецца ўнутранняя прырода
Чым насправды разлічаюцца async/await і Promises у JavaScript: порядак выконання, памяць, трэсы стэка, і калі прыродныя API Promises все ўсё застаюцца кращымі у Node.js.
Крыніцы: Async/Await vs Promises — The Real Difference Nobody Explains Clearly
Досыць фразы «гэта проста синтаксічная кандыранка». Цікава правда пра тое, што на самай справе выкалываецца пад час выканання, працы памяці, стварэння лістоў стеку і роботы канкурэнтна.
Якша запытаць кімнату, наполненую разработчыкамі JavaScript, як Promises адрозніцца ад async/await, большасць з іх перакажуць тую ж фразу, яку вы чулі вяроўна ста разоў:
«Async/await — гэта проста косметычны шар на верху Promises; ён проста дазволяе асінхронны код чытацца так, нібы ён выкананы сінхронна.»
Гэта тэза не ўсё ж такі некальканая, але гэта спрощэнне, яке прыховвае больш, чым адкрывае. Апісанне async/await як „толькі сынтаксічнага сацуру“ падобнае да апісання автаматычнай коробкі передач як „толькі косметычнага панелю, прыбітага да ручной коробкі передач“. Гэта неяснае падходжэнне не адкрывае, як на самай працоўвае асновны механізм, як паследуюць кашалоты ў системе, як выдзеляецца памяць, і чаму выбор аднаго стылю замест другога можа значна змяніць працэю вашага дапытку, калі ён запускаецца у працэйным сераверы.
Якщо гэтыя два падходы справды моглі б быть взаімназаменныя на кожным роўні, разважыце такія пытанні:
- Чаму трэс-лісты кашалота выглядаюць зусім не аднакова, калі падбіяванне выходзіць знутры
awaitі калі — знутры.catch()?
.then() і адпаведным функцыяй async?Promise нават у кодавых базах, якія іншым чынам будуюцца выключна на async/await?Давайце адкінуць кароткія пояснення і розберамся, што на самай працэ выканання асінхроннага коду відбываецца ў сэрвісе JavaScript.
1. Асновная ментальная модэль: адправка vs. пауза
Ёсць канкрэтнае разумець, чаму гэтыя два стылі здаюцца такімі разнымі пад час ўпісу і пазней дэбагавання, трэба зрозумець разныя модэлі выканання, на якіх вони базуюцца.
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) |
+-----------------------+ +-----------------------+
Модэль Promise: Рэгістрацыя калебакаў (адправка)
Promise — гэта, по суты, об’ект, які представляе будучы результат — успех чы неудачу — якой-небудзь асінхронной задачы.
Возьмем типовую ланцюговую структуру .then():
function getUserData(userId) {
return fetchUser(userId)
.then(user => fetchOrders(user.orderId))
.then(orders => calculateTotal(orders))
.catch(err => handleError(err));
}
Нічага тут на самай працэ не зупіняецца. Функцыя getUserData выконваецца сінхронна, з самага пачатку да канцоўкі, за калькольваныя мікрасекунды. На самай працэ яна стварае Promise, прыўязвае функціі-колбэки, якія будуць выкананыя, калі тые Promise будуць завершаныя, а пасля негайна выходзіць з стака вызываў.
Пазней, калі запит да сеті завершыцца, цикл змагання вытягвае зарэўістрованыя функціі-колбэки з очакальнага чыстага стака мікрзадач і пасылае іх у абсалютна новы, раней порожні стак вызываў. Асалодзівая функцыя, якая запускала гэтую ланцюговую структуру, вялікай часткай вже знікла.
Модэль Async/Await: Паўза і продаж чырантайна (Suspension & Resume)
async/await адаптавана на базе гэнератароў ECMAScript і механізмаў корутынаў.
async function getUserData(userId) {
try {
const user = await fetchUser(userId);
const orders = await fetchOrders(user.orderId);
return calculateTotal(orders);
} catch (err) {
handleError(err);
}
}
Калі дварак доходзіць да выразу await, ён не абрыва функцыю — ён проста павяршае выкананне таго конкретнага стак-фрэйма на месцы.
- Контроль вяртаецца да галоўнага потока, ўвольняючы яго для виканання іншых задач — перакрасы інтэрфейсу ў браузеры або обработкі іншых запрасоў у Node.js.
- Усе, што стосуецца локальнага стану функцыі — яе зменны, ўзорак і пазицыя — застаецца недастрыжаным у памяці.
- Калі Promise, якое чакаўся, вырашваецца, дварак вносіць рэзультатныя значэння назад у той самы контекст выканання і продовжвае ўиконанне там, дзе яго застаў.
Гэты адзін разлік — перадача аддыяванага калебэка для выконання пазнейша, протыягоў застой і пазнейшая ажывленне рамкі выканання — адпаведае майже всім практычным разлікам, якія вы зазначыце между гэтымі двума стылямі.
2. Структура стака вызоў: чаму дэбаггін разны
Расшук бягаў у прыменэнай версіі ўжо ёсць адным з найболей складных аспектаў работы з JavaScript, а разлік между ланцюгамі Promise і async/await стае зрозумелым як толькі вы пачнёте чытаць логі.
Як выглядае стэйс трэйс Promise
Возьмім ланцюг Promise, дзе аблука выходзіць на калькі слоёў глыбэй унутрь сецесіі па сеті:
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));
Калі ён зламваецца, стэйс трэйс, які застаёцца, зазвычай выглядае так:
TypeError: Failed to fetch
at stepThree (<anonymous>:10:5)
at processTicksAndRejections (node:internal/process/task_queues:95:5)
Што не прысут? Ні stepOne, ні stepTwo не показуюцца нідзе ў тым логе.
Калі сетавы вызов нарэшце зазнаў неудачы, stepOne і stepTwo ўжо давно вернуліся і былі выведзены з стака. Код для обработкі адказоў запрацаваў у новым цыкле мікрзадання, абсалютна не зв’язаным з там, дзе пачалася ланцюговая вызначальнасць. Якщо stepThree ў вашай базе коду є спяльным інструментам, які запрашваецца з пяцідзiesяти разных месцаў, тым логам немагчыма з’ясаваць, який вызывач спрычыніў гэтую конкретную неудачу.
Як выглядае стак-трэйс для Async/Await
Тепер перапісаце тую ж логіку, выкарыстоўваючы 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);
}
})();
Ось лог, які ён стварае:
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
Паколькі двыжок паверхоўвае рамку замест таго, каб яе знісці, сучасныя двыжкі JavaScript — у тым ліку V8 у Chrome і Node.js — можуць перабудаваць історыю асінхронных вызоваў практычна без жадных витрац у часе выканання. Двыжок фіксуе ход продалейшнай роботы чераз кожную точку паверхоўвання, таму трэйс паказвае весь шлях, які прывёў да неудачнага вызову ўнутрь stepThree.
3. „Return Await“: важны крайній прыклад
Існуе давгачасны спор пра тое, чы рэчь return await promise ёсць зайвая па апэксу, у порываў з простым напісаннем return promise.
За межамі блока try/catch адыяванне Promise безпосередзе дапамагаўляе захаваць адны ўзельцо мікрасправы, таму return promise тэхнічна єсць болей эфектыва. Але ўнутры блока try/catch гэтае слова — await — цалкам зменяе спосаб падхоплення адзінакоў.
Неправильная версія
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();
}
}
Чэрез палову секунды, калі Promise насправды адмоўляеся, блок try/catch вялікі час яўляецца завершаным. Клаза catch ніколі не выконваецца, таму што ён больш не ў складзе актывае галіны выконання. У замен адмоўленне цэлыя выйшае за межы вашага локальнага адзінакоўвання і праказваецца як неконтрольаванае адмоўленне где-небудзь у ланцугу вызваў, там, дзе была выклікана функция 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();
}
}
Адыяванне await вырашвае гэта проблема, таму што ён наказвае двайхэру для паўзы на гэтай лініі і застацца ў чынным блоке try пакуль Promise не будзе вырашаны. Якщо ён адмовіцца, выконанне застаецца ў гэтым контексте на столькі час, сколькі патрэбна блоку catch, каб яго пераспраўціць, замест таго каб функцыя вярнулася раней, чым адбудзецца бяда.
4. Дыяхронія і спакушаная становісць: Адхоўканне ад „гімнастыкі дыяхроніі“
Звязаныя ланцоўкі Promise стаюць ўсё бол незручнымі, калі на пазнейшым кроце патрабуецца даныя, якія былі створены на пачатковым кроце.
Уявіце, што вам трэба запрашаць інформацію пра пользователя, потым яго профіль, а пасля стварыць квітанцюю, якая залежыць ад даных з обох крокаў:
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);
});
});
}
Ёжчы захавацца ад пірамідальнага формата, разработчыкі часта выбіраюць адзначэнне зменных за межамі ланцоўкі, каб пазнейшыя функцыі .then() маглі да яных дасягнуць:
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 абсалютна неабяротна патэнт гэтага способу, таму што кожная заявленая вамя застаецца ў тым жа дыапазоне функцыі пра весь час её выканання:
async function generateReceipt(userId) {
const user = await getUser(userId);
const profile = await getProfile(user.profileId);
return createReceipt(user, profile);
}
Няма неабяротных зменных, якія трэба пазначыць раней, няма вёскаваных структураў калбэка, і няма патрэбы праходзіць па ланцугу, каб з’ясавіць, звядзе ўзялася якая-небудзь значэнне.
5. Выдатнасць і канкурантнасць: Апасцярожнасць сэквенцыйнага выканання
async/await спрыяе лёгкаму чытанню коду, але адкрывае можлівасць для дорогай памылкі: неняхучага сэквенцыйнага выканання операцый, якія моглі б працаваць адночасова.
Разработчыкі, якія пераходзяць з ланцугоў Promise на async/await, часта пачынаюць пісаць код у такім формате, не усвядомляючы выдатнасцевых наследкіў:
// 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
}
Паколькі кожны await блакуе функцыю пакі ўвесь час, пакуль тая вызов не будзе завершаны, fetchPosts нават не пачнёцца, пакуль fetchUser цалкам не завершыцца — незважаючы на тое, што fetchPosts і fetchStats абсалютна не залежаць ад рэзультата fetchUser.
SEQUENTIAL (Slow Waterfall):
[-- fetchUser (200ms) --] -> [-- fetchPosts (200ms) --] -> [-- fetchStats (200ms) --] = 600ms
CONCURRENT (Optimized):
[-- fetchUser (200ms) --]
[-- fetchPosts (200ms) --]
[-- fetchStats (200ms) --] = 200ms (Total)
Рашэнне: Спалучыце async/await зі спосабамі Promise
async/await не ўзаменяе методыў для працы з Promise — яны задуманы для супрацоўкі разам з імі.
// 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. Циклы і ітерацыі: Пастка методаў масэв
Частым выкліканнікам багоў у сучасных кодавых базах JavaScript є спалучэнне асінхронных калебаў з методамі масэв, такімі як .forEach(), .map() або .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!
}
Гэта не працуе, таму што Array.prototype.forEach ў сваій меты являе сабою сінхронную функцію — яна ніколі не пераглядае тое, што вяртае калебэк. Хоць передача асінхронной функцыі ў forEach выклікае стварэнне Promise для кожнага вызову, forEach не мае механізма для чакання на гэтыя Promise; яна проста пераходзіць да наступнай ітерацыі.
Існуюць два правильных альтернатыўныя рашэння, залежна ад таго, канкрэтна вам патрабуецца.
Варыянт А выконвае задачу па адны элемент за раз за дапамою цыклу for...of, што падходзіць, калі важна парада элементаў або калі трэба ухиліцца ад перавантажэння API з обмежаным тыквам:
async function saveUsersSequentially(users) {
for (const user of users) {
await db.users.insert(user);
}
console.log("All users saved safely in order.");
}
Варыянт Б выконвае всё паралельна, супакоўваючы .map() з Promise.all, што падходзіць у тых случаях, калі операцыі є незалежнымі адзінадзе з іншых:
async function saveUsersConcurrently(users) {
await Promise.all(users.map(user => db.users.insert(user)));
console.log("All users saved simultaneously.");
}
7. Калі сырые Promise все ўсё лепшыя
async/await павінен быць вашым главным інструментам для лінейной бізнес-логікі, але існуюць спецыяльныя архітектурныя ситуацыі, калі кращым выборам ёст трыбут працы безпасова з Promises.
Першая з іх — фонавая работа, якую можна проста запусціць і забыць пра яе: ситуацыі, калі вы хочаце запусціць падэйны эфект, не заставляючы таго, хто яе вызваў, чакаць:
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 });
}
Другая — кераванне болей складнымі сцэнарыямі канкурантности, дзе спецыяльныя методы для працы з Promises апраносяць паводле конкрэтных задач:
Promise.race()дазваляе перарваць повольныя сетавыя запыткі за дапамою таймауту.Promise.allSettled()запускае калькі незалежных задач і адмаўляе інфармацыю пра ўсія з іх, як пра успехі, так і пра абыекты.Promise.any()рашаецца як толькі хоць адна з заданых Promises даходзіць да успеху, што ўжыткова для стратэгій альтернатыўных рашэнняў у разных регіонах або джерелах.
// 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)
)
]);
Трэці сценарыя — це створэнне «моста» наваколе старых API, якія базуюцца на калебэках — напрыклад, fs.readFile у Node або старэйшага SDK — для чаго неабяжна явны ўтворэнне об’екта new Promise():
function readConfigFile(path) {
return new Promise((resolve, reject) => {
legacyLib.readFile(path, (err, data) => {
if (err) return reject(err);
resolve(data);
});
});
}
8. Аптутна параболіка
Вывак
async/await — гэта не проста дэкоратыўная сінтаксіс, якая паверху Promises. Хоць ён усё ж апыяўляецца на Promises для пазначэння завершэння задачі, ён пераформатавае асінхронны код з ланцуга рэгістрацый калебэкаў у ўтваруне, бліжыя да корутыны, якаю можна перарваць. Самэ гэта змяненне дазволяе атрымаць значымыя трэсы стеку, звычную обработку адзінакоў на рэвэле мовы за дапамогою try/catch, а таксама простую лексычную інтэрпретацію коду.
Ёжыце з сучасным асінхронным JavaScript заключаецца ў ведамым выявленні таго, калі чытальны, лінейны стыль async/await падходзіць для вашай последовальнай логіки, а калі трэба вжываць базовыя комбінаторы Promise — Promise.all, Promise.allSettled, Promise.race — каб эфектываўна кераваць асінхронным выкананням.