Што насправді гарантуе async/await і шта воно залишае на вашу відпаведальнасць.
З'ясаваце, што насправді призупінюе await, як уникнуць серіалізованых запитоў, і чаму для вираслів, скасавання, аранжавання та повторных спроб неабходны рашэнні, якія выходзяць за межы async/await.
Функцыя, наполненая выразамі await, нагадвае скрыпт, які выконваецца лінія за лініяй, і самэ гэта уражанне якраз там, дзе часта пачынаюцца багі асінхронных працэй. Медленныя панелі керування, затрымленыя адзінкі бягов, застарэлыя рэзультаты пошуку і подвойныя платежы часта вынікаюць з адказу на тое, што async/await гарантуе тое, чаго ніколи не гарантаваў. Калі вы знаеце точны, дасыць малякі прынцып, які стоіць за гэтай синтаксамай, вы можете з першага погляду разбіраць, якія дзеянні є частью самага языка, а якія вам ўсё ж трэба спроектаваць.
Ось функцыя, якая выглядае абоўсумова последовной:
async function loadDashboard(userId) {
const user = await fetchUser(userId);
const projects = await fetchProjects(userId);
const notifications = await fetchNotifications(userId);
return {
user,
projects,
notifications,
};
}
Прыродны спосаб прачытання: запрашаць інфармацыю пра пользователя, чакаць, запрашаць інфармацыю пра проекты, чакаць, запрашаць інфармацыю пра паведамленняя, а потым вернуць рэзультат. Гэты спосаб прачытання не ўсё такі вялікі бяг, але ён маскіруе тую частку, якая мае значэнне, калі код патрэбуе быць швырым, канкурантным, можна ўскасаваць або стойкім да падзеяў, якія можу прывести да бягов.
await не паведамляе нічога пра тое, як адварцованая робота выконваецца. Ён толькі пазначае месца, дзе асінхронная функцыя, якая яго абгортае, павінна націснуцца, пакуль обявленне не будзе вырашана. Калі гэта функцыя націснутая, сістэма продовжае обрабоцваць іншыя заведамення. Багато неспакойнасцей вынікае калі поведанне async/await асоціюецца з обявленнямі, сераўысным сераўам, API такім як fetch, стратэгіяй канкурантнасці чыста з самай аплікацыяй.
await націснуе толькі адную функцыю, а не весь сістэмны час
Разглянем гэты маленькі праграма, який логуе інформацыю праз сетевы вызов:
async function loadUser() {
console.log("A");
const user = await fetch("/api/user");
console.log("B");
return user;
}
console.log("1");
loadUser();
console.log("2");
Выклік loadUser() пачаткае выконанне яго тэлу адразу, таму "A" фіксуецца першы пасля "1". Калі выконанне доходзіць да await, функцыя не блакуе нитку пад час обработкі запита. Яна призупіляе сябе і вертае кантроль зворачыцелю, таму "2" показваецца раней за "B". Калі обяцанне fetch выконваецца, рэштка loadUser() ставіцца у чергу для продажы, і толькі тады фіксуецца "B". Рэзультатны порядак — 1, A, 2, B.
Таму правильны пераклад await — не "зупініць JavaScript тут". Ён ближэй да "усё, што лежыць пасля гэтай лініі, залежыць ад гэтага значэння, таму продовжыце выконанне функцыі пазней">.
Гэта правіла дыяюць нават тады, калі значэнне, якое чакаюць, вже ў наявнасці. Чаканне на выпалення праграмы або простае значэнне, якое не ў формате праграмы, як і описвае MDN, все равно переносіць рэшту функцыі на пазнейшы мікрозаданне замест таго, каб яна продавалася далей у тым жа рядку. Самэй гэтага размішчання, здавалася бы, безнэпакойных дадатковых выразаў await можа змяніць плануванне: кожны з іх дадае ўтэпку для новага мікрозадання.
Практычны наследкі гэтага ў тым, што нельга разумець програму, спрыяючы кожны await як блакуецкі вызов у сінхронной функцыі. Патрэбна ўвага да таго, што вже запускалася, якія функцыі нараз чакаюць своей развязкі, і што ўсё ще можа выконвацца пры перакінуцці функцыі далей.
async не пераносіць работу з галоўнага потока
Ключавая слова async вызывае ўтарошчанне ўразумеласці. Паглядзіце на гэты цыкл, залежны ад працы CPU:
async function calculateTotal(items) {
let total = 0;
for (const item of items) {
total += expensiveCalculation(item);
}
return total;
}
Нічага з гэтаго не ўздзейснюецца паралельна. Дадаўшы async, не перакладаецца expensiveCalculation() на іншы потак, не паралелізуецца цикл, і не запобiegаецца таму, што важкая сінхронная робота блакуе ўсё іншае, што хочаць запрацаваць у тым жа потаку.
Тое, што зменяе async, — это контракт вярнення функцыі. Выкліканне яе завжды дае прабаму, а вярненне звычайной значэння выпалняе гэтую прабаму цім значэннем. Спецыфікацыя ECMAScript описвае адліквідацыю async-функцыі через можлівасць прабамы, якая стае рэзультатам функцыі.
async function getNumber() {
return 42;
}
const result = getNumber();
console.log(result);
// Promise
Логаванне result паказвае чакаючую або выпаленую Promise, а не 42. Чысла можна паведаміць толькі пасля чакання на прабаму або выклікання .then() на яй.
Адзін толькі вяртанне праграмы не робіць яе асінхронной. Усё, што знаходзится даўжэй за першым await, выконваецца сінхронна ў момент вызову. Якщо вы пасадзіце трохі трацяглыя вычысленні ў асінхронную функцыю і спадзяваецеся, што інтэрфейс застанется чутлівым, вы будете разчароўаны: функцыя зрэшты вярнуе праграму, але важкая робота все равно спачатку займае поток. Для справжняя роботы, якая сильна навантажвае CPU, існуюць інструменты — Web Workers у браузеры, worker_threads у Node.js, або раздзелэнне роботы на часткі.
Корача кажучы, async/await — это спосаб напісання ланцоў праграм, якія чытаюцца зверху вярху. Ён плануе продажы роботы; ён ніколі не дазволяе блакуючаму коду выконвацца паралельна.
Там, дзе вы размішчаеце await, можна серыяваць незалежную роботу
Вярнімся да загрузча панелі керування:
async function loadDashboard(userId) {
const user = await fetchUser(userId);
const projects = await fetchProjects(userId);
const notifications = await fetchNotifications(userId);
return {
user,
projects,
notifications,
};
}
Працюймо з гадкай, што fetchProjects() не патрэбуе параметра user, а fetchNotifications() не патрэбуе ніякога ранейшага рэзультата. Функцыя заўсёды выконвае тры незалежныя запиты адна за іншай, таму што другі вызов выконваецца толькі пасля таго, як выпало перша обяцанне, а трэці чакае на другое. Аб’ёмна затрымка доравняеся суме ўсіх трох:
fetch user
████████
fetch projects
███████████
fetch notifications
███████
Этот способ решения зазвычай описываецца як «выкарыстоўваць Promise.all(), ўбачыба запускаць іх паралельна». Болей тачныя апісанні — што всі незалежныя операцыі павінны быць запусканыя раней, чым функцыя будзе чакаць на якую-небудзь з іх. Вызов трох функцый спачатку стварае тры обяцанні, якія выпалююць, і толькі пасля чаго код чакае на сумарны рэзультат:
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,
};
}
Запиты тепер перакрываюцца, і аб’ёмна затрымка прыблізна доравняецца затрымцы самага повольнага з іх:
fetch user
████████
fetch projects
███████████
fetch notifications
███████
all required results available
Promise.all() прыёмляе колькість прамісаў і вяртае аднаго праміса, які выпаняецца, калі всі вхідныя прамісы выпаняюцца. Маса рэзультата застаецца ў тым жа порядку, як і вхідныя даны, незалежна ад таго, якае запрацоўкі завершылася першай, таму дэструктурызація ў user, projects і notifications застаецца правильной.
Заўважыце, што тое, што адзіляла последовальны код ад канкурэнтнага, ніколі не было наявнасцю await. Цэй былі залежнасці. Калі адна стадзія практычна патрэбуе рэзультата іншай, последовальнае чаканне ўсё ж яўляецца правым выборам:
const user = await fetchUser(userId);
const permissions = await fetchPermissions(user.role);
Калі такой залежнасці няма, чаканне кожнай операцыі пры пачатку наступной дадзе затрымкі і не прыносіць ніякай правильнасці. Таму корыстны вопыт па перагляду коду — не "чы гэта трэба робіць за дапамою Promise.all()?", а "якія операцыі залежаць ад ранейшых рэзультатаў, і якія вядома можаць ўжо выкананыцца?"
Promise.all() координуе рэзультаты; ён не анулюе выкананне задач
Адкрыв для сябе Promise.all(), багато разработчыкаў ствараюць новую дактрыну пра тое, што выходзіць пад час абыяковасці. Возьмем гэты варыянт:
const [user, projects, notifications] =
await Promise.all([
fetchUser(userId),
fetchProjects(userId),
fetchNotifications(userId),
]);
Якщо fetchProjects() адзюрмуе рана, то сумарная праміс адзюрмуе немеджліва з тыма прычынай, у замяне на чаканне за рэшт. Людзі часта думаюць, што іншыя два запыткі тады зупінююцца. Аднак гэта не так. Адзюрмуе сумарной прамісы не анулюе нічога, што вялікі ўжо запусцана, як чыста зазначае MDN. Іншыя операцыі продовжуюць выконвацца, а ўсе іх рэзультаты проста ігнаруюцца.
Пры чытанні гэта пераважна марнуюць ресурсы. Пры запісі гэта можа быць серйозным:
await Promise.all([
updateProfile(),
writeAuditLog(),
sendWebhook(),
]);
Якщо writeAuditLog() адзюрмуе, updateProfile() можа ўжо быць выкананы, а sendWebhook() можа ўжо крытыцца па сецыі. Зловэнне адзюрмуе не скасоўвае ніткага з гэтага.
Это проблема проектавання системы, а не синтаксічная проблема. Якщо этыя крокі мусяць выйсці ўспехам або зазнаць нявыходу разам, вам патрэбны справжні механізм атомарнасці: транзакцыя базы дадзеных для змян у адной базе дадзеных, або для даляжных систем — якаясь комбінацыя компенсацыйных дзеянняў, ідэмпатентных операцый і стану рабочага прайсупутку, які зберагаецца. Promise.all() обяцвае паведаміць вас, калі група обявлень вырашыцца. Ён не обяцвае атрыбуцыю назад, анулювання чы то тое, што пабочныя эфекты будуць выкананы як адна ейка. Гэтыя гарантіі павінны выходзіць з іншых джэроў.
Связаным інструментам ёсць Promise.allSettled(), які чакае кожны вхід і дапытвае пра кожны рэзультат окрема. Ён корыстны, калі вам патрэбна точная інфармацыя пра тое, якія крокі выйшлі ўспехам, але ён таксама не забезпечвае атрыбуцыю назад.
try/catch бачыць толькі адмовы, якія праходзяць чераз яго
Адзін з прычынаў, чаму async/await стаў популярным, — гэта тое, што бяды з promise-аў можна адмахнуцца за дапамогай звычайных 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;
}
}
Калі promise, які чакаецца, адмовляецца, выраз await выбрасвае прычыну адмовы ўнутры функціі, а суседзяючы catch прымае яе так сама, як і сінхронную абяку.
Гэта не значыць, што try стежыць за кожной асінхронной операцыяй, якая запускаецца ўнутры його складакоў. Разглядзім стварэнне абліку:
async function createAccount(input) {
try {
await saveUser(input);
sendWelcomeEmail(input.email);
return { success: true };
} catch (error) {
console.error(error);
return { success: false };
}
}
saveUser() чакаецца, таму яго няудача падае ў блок catch. sendWelcomeEmail() — нячакаецца. Якщо ён вяртае обяву, якая пазней будзе адхілена, гэта адхіленне ніколі не прабяжыць у гэты поток керування, таму што ніхто не чакае і не вяртае гэтую обяву. createAccount() можа ўжо быць вярнуў { success: true } да таго часу, калі падае робота з электронным листам, і няудача прыкметна окалічна, зазвычай як неконтрольавана адхіленне. У Node.js у чынных версіях неконтрольаваныя адхіленні за замовчанням завершаюць процес, таму гэта не ўсё толькі візуальная проблема.
Адзінакоўка await не ўсега являе сабою памылку. Інодзе электранаўленне навмесна не прадважваецца ў шлях запыту. Аднак у праўзрачным дизайне такая самастоятельная робота зазвычай падае ў стойкіяй канэ, а не запускаецца як неконтролюемая праміса. Праўяй памылкай є перакананне, што блок try стварае межу для адхілення ад будучых асінхронных задач толькі таму, што вызов знаходзіцца ўнутры яго візуальна.
Тая ж пастка з’яўляецца ў месцы вызову. Цей вызывальнік выглядае захіщаным, але фрагмент — цэльва JavaScript без await:
try {
loadDashboard(userId);
} catch (error) {
console.error("Dashboard failed");
}
loadDashboard() негайна вяртае прамісу. Будзь-якая асінхронная памылка адхіляе тую прамісу пазней, пасля таго як блок try вяршыўся, таму блок catch ніколі не выканаецца. Вызывальнік должен узяць участь у ланцугу праміс:
try {
await loadDashboard(userId);
} catch (error) {
console.error("Dashboard failed");
}
Альтэрнатыўна, можна прыкрепіць функцыю обработкі адхілення за дапамою .catch(). Асновная прынцыпа ўсё простая, як толькі перестанем супакоўвацца з асінхроннымі функцыямі як з звычнымі функцыямі, якія проста мають await: іх вызывачы отрымаюць прамісы, а адказы на бяды передаюцца чераз гэты контракт праміса.
Анулюванне — это адзіны протакол
Уявіце поле пошуку, дзе корыстнік вводзіць по аднаму симвалу:
r
re
rea
reac
react
Простая рэалізацыя выклікае запит за кожны натиск клавішы, нават калі пакульшы запиты ўсё яшчэ чакаюць на обработку:
async function search(query) {
const response = await fetch(
`/api/search?q=${encodeURIComponent(query)}`
);
return response.json();
}
У await няма нічога, што бы сказало, што запит на "r" павінен быць зупінены, таму што тепер важлівы запит на "react". Пакульшы запит продовжае выконваліцца да завершэння, нават якщо інтэрфейс больш не пакулькае яго.
Для fetch анулюванне выражаецца за дапамою AbortController і яго AbortSignal. Штая версія анулюе паказваны запит пры пачатку новага:
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();
}
Контролер адкрывае сігнал, які слухаюць сумэжныя API. Анулюванне гэтага сігналу прыказвае fetch зупніцца, што стосуецца як самага запиту, так і чытання тэлу адпаведзі.
Важлівым є выраз "сумэжныя API". Обяцанні не маюць універсальнай функцыі анулювання, а await не мае можлівасці зупніць тое, на што ён чакае. Анулюванне можа працаваць толькі тады, калі операцыя, яку вы вызываеце, яе падтрымлівае і перадае сігнал таму, хто выпрацоўвае рэальную работу.
Своія функцыі можаць выкорыстоўваць той жа контракт, прыймаючы сігнал і пераканальваючы яго между крокамі:
async function processFile(file, { signal }) {
for (const chunk of file.chunks) {
signal.throwIfAborted();
await processChunk(chunk);
}
}
signal.throwIfAborted() выкідае прычыну абаранавання, якщо была запатрэбована ануляцыя, тады цикл зупіняецца перш чым обрабоцаваць наступны фрагмент. Ануляцыя тепер ўскладнівае інтэрфейс функцыі, а не ўсё тое, на што спадзяюцца вызывачы, што await зробіць за іх. Для большай дзеярганізацыі можна таксама перадаць сігнал у processChunk(), ўжо тады дзейны фрагмент можа зупініцца на паўпацёку.
Таймауты працуюць за той жа логікай. Вываранне аперацыі проты таймера за дапамою Promise.race() дазволяе вызывачу перастаць чакаць, але сама аперацыя продовжваецца, якщо толькі ёй не будзе сказана зупініцца. Зупіненне абсалютвання і зупіненне работы — гэта два разныя падыя.
Локальная архітектура не ўсё тое ж, што і глобальная архітектура
Парадыгма await гарантуе парадак у межах адной функцыі:
await saveOrder(order);
await sendConfirmation(order);
sendConfirmation() нават не выклікаецца пакуль saveOrder() не завершыцца. Аднак гэта локальная гарантія мало што сказвае пра рэшты системы. Уявіце два запиты, якія адчынюють змены ў той самы профіль. Адны з яных выклікае:
await updateProfile({
name: "Umar",
});
Чырвоны мілісэкунды пазней другі выклікае:
await updateProfile({
name: "Umar Dev",
});
Кожны запит правільна чакае на свою адчынку змэн. Нічога з гэтаго не вялікае пра тое, який запіс дася да базы дадзеных апошні, як перапрыткі з боку кожнага запиту будуць выконваны, чы розныя серверы обрабоцуюць гэтыя запиты, чы старыя даныя заменяюць новэйшыя.
Версія гэтай проблемы ў браузере — это класычная гонка за пошук. Запит А пачынаецца раней за запіт Б, але завершаецца пасля яго, і код, який відобразае тое, што прыходзіць у адпаведнасці, паказвае застарелыя рэзультаты на экране:
const results = await search(query);
render(results);
Ніякі дадзеные await не можу выправіць гэта. Аплікацыя патрабуе правіла, якія б кантролювалі релевантнасць чыста парадок: анулювацыю старэйшых пошукоў, пазначэнне запытак версіяй і адклейканне застарэлых адпаведзенняў, пораўнанне ідэнтыфікатораў пры атрыманні рэзультаатаў, чыста прыменэнне пераканання версій там, дзе зберагаюцца даны. Неабходна памятаць, што await кантролюе парадак выконання ўнутар адной функцыі; яны не ствараюць глобальнага парадку між незалежнымі асінхроннымі операцыямі.
Праказанні падтрымаюць тое, чаго await ніколі не обяцаў
Праказанні ў тым, што непূраная ментальная модель стае даскладной. Пачніце з вызову для платежа:
async function submitPayment(payment) {
const response = await fetch("/api/payments", {
method: "POST",
body: JSON.stringify(payment),
});
return response.json();
}
Падазроўваю, што запыт выйшаў за час і разработчык дадае простае праказанне:
try {
return await submitPayment(payment);
} catch {
return await submitPayment(payment);
}
Гэта прыпускае, што неудачная спроба нічога не змусіла. Тайма-аут означае толькі, што кліент не павінен быў атрымаць адпаведзь у час. Сервер могаў зняць грошы са карткі і загубіць адпаведзь, або з’єднанне могла быць перервана пасля таго, як побачны эфект вже быў выкараны. Абавязковая пракатка можа змусіць кліента заплатіць два разы.
await не можа сказаць, чыя пракатка ў безпеці. Праміс адмаўляе, чыя спроба прывела да успеху чы ў адмове, якую можа пабачыць вызывальнік. Ён не адмаўляе, чыя даўняя система выконала незворачныя дзеянні прычынай гэтага результата.
Для операцый з побачнымі эфектамі безпека пракаткі трэба прымусова включыць у саму операцыю, зазвычай чераз ідэмпотентнасць. Кліент генеруе стабільны ключ аднажды на кожную логічную спробу платежа і адправляе яго з кожной пракаткай:
await fetch("/api/payments", {
method: "POST",
headers: {
"Idempotency-Key": paymentAttemptId,
},
body: JSON.stringify(payment),
});
Ключ паводлівае толькі тады, калі сервер яго прыймае: ён должен фіксаваць ключ разам з рэзультатам, а калі той самы ключ прыйдзе знову, вяртаць первісны рэзультат уместо практыкавання новых зусіб. Ключ таксама павинен застацца тым самым пад перапрыбуткамі адной спробы; стварэнне новага ключа за кожны запит паспелвае мэту. Рэалізацыя разлічыцца ў розных системах, але прынцып застаецца тым самым: семантіка перапрыбуткам належыць да самай операцыі і систем, якія выканаюць ўсеяе яе наследкі, а не да async/await. Работа на стороне сервера аднароджваецца ў разумеўцы ключоў ідэмпотентнасці ў Node.js POST-канцах.
У працэвыканні JavaScript выкалываецца важны прыём, які вынікае з гэтага. Калі запусканыя аперацыі збываюцца неудачна, трэба роздзеляць два тверджэння: «Я не паказваў успех» і «Аперацыя абавесна не выканалася». Це не тое ж сама тэза.
Меншы, але болей точны ментальны модэль
Ёсць не патрэба запамятаваць спефікацыю ECMAScript, каб правяльна разумець async/await. Дапатрыбны толькі простыя правіла: асінхронная функцыя вяртае праграму; яе тэла працюе звычайна, пакуль не досягнеться await; await призупіняе выкананне функцыі, пакуль не будзе атрымана значэнне, якое чакалася, а пасля запланавае ўзначальнэнне выконання.
Усе іншае — цэлая адна запытка з сабой адпаведным адказам:
- Калькі аперацыі: калі пачынаецца кожная з іх і чы робіцься гэта залежна ад іншай? Гэта пакажае, чы ўпорядкаванне є намеровым чы випадковым.
try/catch дзейсна захоўвае тое, што вы спадзяваліся захаваць.await гэтага не можуць?Ключовыя выводы
async/await ўмышлена простая. Яна робіць асінхронны праймап контролю чытабельным, што ўжо сама па сабе мае вялікую цэннасць, але сама гэта чытабельнасць прыводзіць да таго, што код выглядае болей сінхронным, чым ёсць на самай працэ. Калі вы зустроўляеце await, утримайцеся ад тлумачэння яго як «програма чакае тут». Чытайце яго як сігнал: гэтая функцыя зупіняецца пакуль не прыйдзе значэнне, таму якія операцыі вже выпалююць, што можа запрацаваць за гэты час, і якія гарантіі павінен даць ваш сэльф-дизайн? Гэты вопыт адпавядае таму, што на самай працэ робіць сынтаксіс, і ён ведае вас прымусова да дзеянняў па дизайну, якія запобегаюць багам у прыменні.
Спадні матэрыялы
- Async/Await vs Promises: Чаму насправды розлічаюцца ўнутранняя прырода — Пасвятавана рэальным разлікам у аспектах выконання, выкарыстоўвання памяці та стэк-трэйсу межы async/await і Promises, а також калі варта выкарыстоўваць чыстыя API Promises.
- Пашыраныя хыбныя уявленні пра async/await, якія спрычынаюць багі ў продакшн-сервісах — Апавяшвае дзев’ять тонкіх неправильных усвядомленняў пра async/await — ад ситуацый канкурэнціі да неконтрольваных адхіленняў — якія таямніча ламаюць рэальныя JavaScript-застосункі.