Await бачить лише одну обіцянку: створення API з Async/Await, які самостійно виконують свою роботу
Що насправді спостерігає JavaScript async/await, чому відокремлені завдання, калебеки, стріми та Promise.all проходять повз нього, і як проектувати Promises, які означають завершення роботи.
Джерела: 90% of Developers Don’t Understand What JavaScript `await` Actually Waits For
Легко інтерпретувати await як „чекати, поки ця функція завершиться“, проте система виконання дає значно вужчі гарантії: вона обчислює один вираз, розглядає результат як значення, схоже на Promise, та призупиняє поточну async функцію, поки це єдине значення не буде визначене. Усе інше залежить від того, як було створено це значення. Саме ця різниця пояснює, чому код із ретельно розміщеними ключовими словами await все одно може повертатися занадто рано, працювати повільніше, ніж потрібно, ігнорувати помилки чи використовувати застарілі дані. Після розгляду наведених нижче випадків ви зможете дивитися на будь-який await та ставити єдине важливе запитання: чи справді цей Promise представляє ту роботу, яка мене цікавить?
Проблема: код, який, здається, завершився, але насправді ні
Проблеми в цій області мають спільну закономірність. Розробник чекає на виконання функції та припускає, що все, що вона розпочала, вже завершено. Він чекає на виконання методу масиву та вважає, що всі асинхронні калебеки вже виконані. Він чекає на налаштування таймера, підписку на подію чи запис у потік та вірить, що основна операція вже завершена. Код виглядає правильно, тому що там є await.
Але await не має уявлення про „завдання“, „запит“ чи „усе, що розпочала ця функція“. Якщо вираз, який знаходиться праворуч від нього, не охоплює повний життєвий цикл роботи, то жодна частина цього ключового слова не може це зробити. Враховуючи це, змінюється підхід до проектування API, циклів, фонових завдань, коду, заснованого на подіях, та обробки помилок.
Виклик відбувається раніше, ніж очікування
Візьмемо найзвичайніший рядок у асинхронному JavaScript.
const user = await loadUser();
Середовище виконання не бачить спочатку await, а потім ввічливо просить loadUser виконатися асинхронно. Воно оцінює loadUser() точно так само, як будь-який інший виклик функції. Цей виклик генерує значення, яке може бути нативним Promise, власним об’єктом типу thenable, звичайним об’єктом, рядком, undefined або чимось іншим. Лише після цього await починає працювати з отриманим результатом.
Наслідком є те, що фактична операція зазвичай починається під час виклику, ще до початку очікування. Якщо ви викликаєте дві асинхронні функції та зберігаєте їхні Promises, не чекаючи на результат жодної з них, обидві операції починаються майже одночасно:
const userPromise = loadUser();
const settingsPromise = loadSettings();
const user = await userPromise;
const settings = await settingsPromise;
Тут loadUser() розпочинає свою роботу та повертає Promise, після чого loadSettings() розпочинає свою. Дві рядки await просто збирають результати, як тільки вони стають доступними.
Порівняйте це з версією, яку більшість людей пише за замовчуванням:
const user = await loadUser();
const settings = await loadSettings();
Тепер loadSettings() навіть не викликається, поки перше обіцяння не буде виконане та функція не продовжить роботу. Дві запити виконуються один за одним.
Зверніть увагу, що сам await не приймає рішень щодо паралелізму. Рішення щодо порядку виконання приймається завдяки положенню викликів функцій. Короткий спосіб запам’ятати це: виклик асинхронної функції починає операцію; await визначає момент, коли поточна функція продовжує роботу. Одна застереження щодо першого підходу: якщо settingsPromise відхиляється, поки ви все ще чекаєте на userPromise, це відхилення на короткий час залишається без обробки, що деякі середовища виконання розцінюють як помилку. Promise.all уникає цієї проблеми, спостерігаючи за обома Promise одночасно. Для більш детального порівняння цих підходів дивіться вибір між Promise.all, Promise.race та послідовними await.
Вирішення одного Promise, а не кожного побічного ефекту
Розгляньмо функцію створення облікового запису, яка зберігає запис, надсилає листа з привітанням без очікування чи повернення відповіді, а потім повертає обліковий запис.
async function createAccount(input) {
const account = await accountRepository.save(input);
sendWelcomeEmail(account.email);
return account;
}
Потім абонент пише очевидний рядок:
const account = await createAccount(input);
Цей виклик продовжується, коли Promise, повернений функцією createAccount, виконується, що відбувається після завершення збереження даних та повернення функцією. Він нічого не повідомляє про електронну пошту. Функція sendWelcomeEmail створила окрему асинхронну операцію, чий Promise так і не був пов’язаний із тим, що повертає createAccount, тому з точки зору викликаючого коду ця робота не існує.
Ось у чому суть непорозуміння. Оператор await слідує лише за ланцюгом Promises, доступним з значення, яке йому було передано. Він не сканує все, що запустила функція, і точно не чекає, поки процес стане неактивним.
Практичні наслідки є трьома:
- Якщо Promise з електронною поштою відхиляється, блок
try/catchу викликаючому коді ніколи цього не побачить. - Якщо процес завершується, електронний лист може просто так і не бути надісланим.
Усе це не означає, що кожен побічний ефект мусить завершитися в межах запиту. Багато завдань справді належать до виконання у фоновому режимі. Справжньою проблемою є нечітке визначення власника цих завдань. Важливі завдання слід повертати та очікувати їх завершення, чітко позначати як необов’язкові та обробляти відповідно, або передавати їх у стійку чергу чи систему обробки завдань. Створення об’єкта Promise та залишення його без уваги — це не фонова обробка; це лише надія.
Асинхронна функція може завершитися, поки її підфункції все ще виконуються
Ще одне поширене припущення полягає у тому, що ключове слово async змушує JavaScript відстежувати кожну асинхронну операцію, яка розпочинається всередині функції. Це не так. Функція async створює рівно один Promise, і цей Promise пов’язаний лише з тим, що функція явно повертає або чекає у межах власного контрольного потоку. Усе інше може продовжувати виконуватися після того, як цей Promise буде вирішений.
Класичним прикладом є використання forEach:
async function processUsers(users) {
users.forEach(async (user) => {
await updateUser(user);
});
}
Кожне викликання асинхронного калебеку повертає об’єкт Promise, але forEach відкидає ці значення. Він не збирає їх, не чекає на них та не відображає їх; замість цього повертає undefined. Тож processUsers майже миттєво досягає своєї кінцевої дужки, і оскільки асинхронна функція, яка виконується після неї, завершується з значенням undefined, викликаючий код продовжує роботу задовго до того, як будь-яке оновлення завершиться:
await processUsers(users);
console.log("All users processed");
Цей твердження просто неправдиве. Викликаючий процес чекав, поки processUsers завершить планування обробників подій, а не на виконання самої роботи цих обробників.
Щоб це виправити, дочірні Promises потрібно під’єднати до батьківської операції. Залежно від ситуації це може означати використання циклу for...of із await всередині для суворої послідовності, Promise.all(users.map(updateUser)) для повної паралельності або обмежувач паралелізму, коли кількість елементів може бути настільки великою, що це завантажить базу даних чи API. Це загальне правило поширюється далеко за межі forEach: батьківський Promise чекає лише на роботу дочірніх об’єктів, які є частиною його ланцюга. Інструменти, такі як правила no-floating-promises та no-misused-promises у typescript-eslint, автоматично виявляють багато з цих помилок.
Обробники подій невидимі для await
Велика частина екосистеми JavaScript існувала ще до Promises та повідомляла про завершення роботи за допомогою калебеків. Це призводить до коду на кшталт цього:
await setTimeout(() => {
console.log("Timer completed");
}, 1000);
Він не зупиняється навіть на секунду. setTimeout реєструє функцію-колбек та негайно повертає ідентифікатор таймера (число в браузерах, об’єкт Timeout у Node.js). Цей ідентифікатор не є Promise, який би вказував на момент виконання таймера. await отримує звичайне значення, сприймає його як вже доступне та продовжує виконання функції на наступному мікрозавданні, задовго до того, як виконається колбек.
Та сама проблема виникає з прослуховувачами подій, старішими API файлової системи, драйверами баз даних, потоками та саморозробленими SDK. Якщо функція повертає підписку, ідентифікатор запиту, булеве значення чи емітер подій, очікування цього значення не перетворює можливий колбек на завершення Promise.
Щоб очікувати поведінки, заснованої на колбеках, вам потрібен Promise, який буде розв’язуватися або відхилятися саме тоді, коли колбек повідомить про результат:
function delay(milliseconds) {
return new Promise((resolve) => {
setTimeout(resolve, milliseconds);
});
}
await delay(1000);
Тепер очікуване значення справді відповідає події, яка вас цікавить: Promise залишається у стані очікування, поки таймер не викличе resolve. Сучасні платформи часто вже постачають такі обгортки; Node.js, наприклад, пропонує Promise-орієнтований setTimeout у модулі node:timers/promises та util.promisify для функцій у стилі callback.
Це підкреслює концептуальне питання, яке криється за кожним await: чи відображає повернене значення завершення процесу, чи лише його реєстрацію, запуск або посилання на роботу, яка ще триває? Якщо це один із останніх варіантів, очікування цього значення не призведе до отримання результату, який ви уявляєте.
Thenables, звичайні значення та межа мікрозавдань
await не вимагає справжньої інстанції Promise. Якщо значення є об’єктом, який підтримує метод .then, await використовує механізм виконання цього об’єкта. Саме це дозволяє бібліотекам, сумісним із Promise, та власним асинхронним об’єктам безпосередньо використовуватися в функціях async, а також означає, що об’єкт може поводитися асинхронно, не використовуючи new Promise.
Для значень, які не є об’єктами, що підтримують .then, await все одно додає асинхронний крок:
async function example() {
console.log("before");
await 42;
console.log("after");
}
console.log("start");
example();
console.log("end");
Це виводить start, before, end, а лише потім after, хоча 42 був доступний відразу. Функція зупиняється на await, синхронний код після виклику продовжує роботу, а решта коду example виконується пізніше як мікрозавдання.
Отже, очікування звичайного значення не є синхронним. Воно може змінити порядок виконання вашого коду відносно навколишніх синхронних операторів та інших чергових мікрозавдань, і в сценаріях, чутливих до порядку виконання, зайве await створює додаткову межу планування. Затримка зазвичай є незначною з точки зору продуктивності, але концептуальна ідея залишається незмінною: код після await ніколи не продовжує виконуватися на тій самій неперервній стеку викликів.
Відхилення є частиною процесу обробки
Люди часто кажуть, що await чекає, поки Promise буде вирішений. Точніше буде сказати, що він чекає, поки Promise буде у стані завершення, а цей стан включає й відхилення. Коли Promise, на який чекають, відхиляється, await кидає причину відхилення всередині поточної async функції, і саме це дозволяє звичайним конструкціям try/catch працювати з асинхронними помилками:
try {
const order = await loadOrder(orderId);
return order;
} catch (error) {
logger.error(error);
throw error;
}
Цей захист діє лише у разі, якщо Promise, який відхиляється, дійсно очікується всередині блоку try. Promise, створений та залишений без обробки, повністю уникає цього захисту:
try {
loadOrder(orderId);
} catch (error) {
console.error("This does not catch the Promise rejection");
}
Тут сам заклик вдалося виконати: він повернув Promise без виникнення помилки. Відхилення надходить пізніше, через цей Promise, після того, як блок try вже давно завершив свою роботу.
Це особливо небезпечно, коли await навмисно видаляється, щоб зробити щось „неблокуючим“, тоді як розробник все ще вважає, що навколишні блоки try/catch захищають цей код. Це не так. Кожна асинхронна операція потребує чіткого власника у разі її невдачі: того, хто її викликає та очікує на результат або повертає його, явного обробника .catch або системи, яка бере на себе відповідальність. Promise без власника — це не швидша архітектура; це шлях виникнення помилки, відокремлений від коду, який розуміє, що сталося. Стаття на адресі що насправді гарантує async/await розглядає саму ідею власності з точки зору обробки помилок.
Promise.all координує результати, а не кінцеві наслідки
Promise.all зазвичай описується як «очікування кількох Promises одночасно». Це підходить як первинна апроксимація, але важливі деталі. Він повертає новий Promise, який виконується, коли всі вхідні об’єкти виконуються, та відхиляється відразу, як тільки хоча б один з них відхиляється.
const results = await Promise.all(tasks);
Навіть тут await має єдину мету: агреговане Promise, створене за допомогою Promise.all.
Коли якась з завдань завершується з помилкою, весь агрегат також завершується з цією помилкою, і ваша функція продовжує виконуватися по шляху обробки помилки. Решта завдань не скасовуються; вони продовжують виконуватися та можуть все ще створювати побічні ефекти навіть після того, як викликаючий код вже визнав пакет невдалим. Уявіть собі пакет, який виконує кілька зовнішніх оновлень: одне з них негайно зазнає невдачі, викликаючий код повідомляє про „невдачу пакета“, тоді як інші оновлення тихо успішно виконуються, залишаючи систему напівзміненою.
Promise.all забезпечує координацію результатів, але не транзакції чи можливість скасування. Виберіть інструмент, який відповідає необхідній семантиці:
- Якщо частковий успіх є прийнятним, зберігайте кожен результат (наприклад, за допомогою
Promise.allSettled) та перевіряйте кожен з них. - Якщо вам потрібна поведінка „все або нічого“, базові операції мають справжній механізм скасування або компенсації.
- Якщо робота має зупинитися після першої помилки, кожна операція повинна підтримувати скасування, зазвичай через спільний
AbortSignal.
Об’єднаний Promise не може скасувати роботу, яка вже розпочалася. Його виконання означає лише те, що дозволяє його структура.
Стріми та ресурси з довгим часом виконання
Стріми є ще одним поширеним джерелом плутанини. Метод write стріму може повертати булеве значення, яке вказує, чи може внутрішній буфер прийняти ще дані. Виклик .pipe() повертає стрім-призначення. Парсер, заснований на подіях, може повернутися відразу після підключення своїх слухачів. Очікування будь-якого з цих значень не означає очікування завершення стріму, його очищення, закриття чи запису кожного байту.
Завершення зазвичай сигналізується за допомогою подій таких як finish, end, close або error, і щоб надійно його очікувати, ці події мають бути перетворені на Promise. Багато бібліотек, а також сам Node.js через pipeline у модулі node:stream/promises, надають інструменти, які створюють єдиний Promise, чий стан відображає ситуацію в усій конвеєрній системі, включаючи можливі помилки.
Цей принцип застосовується до будь-якого ресурсу з довгим терміном існування:
- Початок завантаження — це не його завершення.
- Відкриття з’єднання — це не завершення запиту.
- Планування завдання — це не його виконання.
- Додавання повідомлення до черги — це не його обробка.
Значення, яке повертає API, визначає, що може спостерігати await. Перш ніж довіряти await, з’ясуйте, яку межу життєвого циклу насправді позначає його Promise.
Завершення обробки — це не релевантність
Уявіть поле пошуку, де користувач вводить „JavaScript“, а потім швидко змінює його на „TypeScript“. Відправляються два запити. Запит на TypeScript повертається першим, і на екрані відображаються правильні результати; потім надходить повідомлення від JavaScript, яке є повільнішим, і воно стирає попередні результати.
Кожна функція в цьому потоці правильно використовувала await. Проблема виникає тому, що очікування та релевантність — це окремі аспекти. await продовжує роботу, коли Promise досягає свого стану; він не перевіряє, чи хтось все ще потребує результату, чи не замінив його новіший запит, чи не відображає компонент тепер іншу інформацію. Код, який здається послідовним, лише впорядковує кроки в межах одного виклику, тоді як кілька викликів однієї й тієї самої функції все одно можуть відбуватися одночасно.
Щоб вирішити цю проблему, необхідно чітко визначити, яка операція є поточною. Ви можете скасувати застарілі запити, порівнювати кожну відповідь із ідентифікатором останнього запиту, використовувати перевірки версій або покладатися на шар отримання даних, який прив’язує відповіді до параметрів, що їх створили. await не може визначити, який результат належить до поточного стану. Для пошагового огляду, орієнтованого на React, дивіться як усунути ситуації конкуренції, які не можна вирішити за допомогою дебаунсування.
Те, що запит виконаний, не означає, що система є послідовною
Запис у базу даних може вдатися, навіть якщо репліки ще не оновилися. Опублікування повідомлення зазвичай завершується після того, як брокер його приймає, а не після того, як споживач його обробив. Надсилання електронного листа зазвичай завершується, коли провайдер приймає запит, а не коли хтось його отримує. Легко інтерпретувати оператор await як засіб для отримання більшої кількості підтверджень, ніж насправді надає API.
await publishEvent(event);
Цей рядок може гарантувати, що подія дійшла до брокера. Однак це не обов’язково означає, що проекції, сповіщення чи індекси пошуку нижче в ланцюжку також оновилися.
await sendEmail(message);
Так само це може означати лише те, що постачальник прийняв повідомлення. Це не гарантує його доставки, того, що одержувач його прочитав, або навіть того, що постачальник зберіг його після подальшої обробки.
Те, що означає стан „виконано“, визначається умовами контракту API, який створює Promise, і await не може посилити ці умови. Проблеми виникають тоді, коли наступний код припускає, що вже відбулася більш широка зміна стану, наприклад, коли ендпоїнт публікує подію та негайно запитує модель для читання, якою керує споживач, очікуючи, що нові дані вже там є. Promise було виконано правильно; просто архітектура ще не встигла це врахувати. Залежно від продукту, правильний дизайн може повертати статус „у процесі“, читати дані з моделі для запису, чекати на пряме підтвердження або використовувати концепцію евентуальної консистентності в інтерфейсі користувача.
Дизайн Promise, які відповідають своїм назвам
Більшість плутанин щодо await зникає, як тільки одне запитання стає звичкою: чи представляє значення, яке я очікую, завершення всієї операції, яка мене цікавить? Якщо розглянути попередні випадки, відповідь однозначно «ні» у таких ситуаціях:
- функція створює дочірні Promises без їх повернення чи очікування;
- метод масиву викидає Promises, які повертають його асинхронні керування;
- API повертає об’єкт керування та повідомляє про завершення через події;
- видавець завершує роботу раніше, ніж споживачі виконають свою частину.
Наявність await доводить лише те, що поточна функція призупинилася до тих пір, поки одне значення у форматі Promise не буде визначене. Це не доводить, що пов’язана робота завершена, що побічні ефекти виникли саме один раз, що попередня робота скасована, що помилки залишаються актуальними, або що результат все ще є актуальним.
Ось чому правильна реалізація асинхронного коду у більшості випадків є проблемою проектування API. Об’єкт Promise, який повертає функція, має бути у стані завершення у момент, що відповідає обов’язкам, про які говорить його назва. Функція під назвою saveDocument має чітко показувати, чи стосується її Promise локального зберігання, віддаленої синхронізації чи обох. Функція під назвою scheduleReport може закінчитися успішно після прийняття завдання, а не після завершення звіту, але її назва та тип повернення мають чітко це вказувати, наприклад, шляхом повернення ідентифікатора завдання, який може використовуватися для моніторингу.
Що може та чого не може обіцяти await
awaitобчислює один вираз та чекає на завершення саме цього значення; він нічого не знає про „завдання“ в цілому.- Місце, де ви викликаєте асинхронні функції, визначає, чи буде робота виконуватися паралельно;
awaitвизначає лише момент продовження виконання. - Робота, яка не повертається та не очікується, є відокремленою: її помилки, час виконання та завершення залишаються невидимими для того, хто її викликає.
- API для калебеків, подій та потоків мають бути обгорнуті у Promises, які гарантують справжнє завершення, перш ніж
awaitматиме сенс. Promise.allне скасовує та не скидає виконання; оберітьallSettled, компенсацію чиAbortSignalзалежно від необхідної семантики.- Завершення, актуальність та послідовність — це три різні гарантії, і лише перша походить від
await. - Ставіться до кожного Promise, який ви повертаєте, як до контракту, і нехай його завершення означає саме те, що передбачає назва функції.
Пов’язана література
- Рефакторинг ланцюгів Promise у async/await без зміни поведінки: Як перетворити ланцюги .then() на зрозумілий код async/await, що насправді зупиняється за допомогою await, коли виконувати кроки паралельно та як try/catch замінює .catch().
- Поширені хибні уявлення про async/await, які спричиняють проблеми в продакшені: Пояснює дев’ять тонких непорозумінь щодо async/await, від ситуацій конкуренції до некерованих відмов, які тихо ламають реальні JavaScript-додатки.