Головна / Статті / Що насправді гарантує async/await та що залишає на ваш розсуд.

Що насправді гарантує async/await та що залишає на ваш розсуд.

Зрозуміти, що насправді призупиняє оператор await, як уникнути послідовних запитів, та чому для обробки помилок, скасувань, порядку виконання та повторних спроб потрібні рішення, що виходять за межі async/await.

3113 слів

Функція, наповнена виразами 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() на іншу нитку, не паралелізує цикл та не запобігає тому, щоб важка синхронна робота блокувала все інше, що хоче виконуватися на тій самій нитці.

Те, що змінює async, — це контракт повернення функції. Її виклик завжди повертає обіцянку, а повернення звичайного значення виконує цю обіцянку цим значенням. Специфікація ECMAScript описує оцінку асинхронних функцій через можливість обіцянок, яка стає результатом функції.

async function getNumber() {
  return 42;
}

const result = getNumber();

console.log(result);
// Promise

Логування result показує очікуючу або виконану Promise, а не 42. Число можна отримати лише шляхом очікування на обіцянку або виклику .then() на ній.

Проте повернення обіцянки не робить тіло функції асинхронним. Усе, що знаходиться до першого await, виконується синхронно у момент виклику. Якщо ви розмістите витратний обчислювальний процес усередині асинхронної функції та сподіваєтесь, що інтерфейс залишатиметься реактивним, ви будете розчаровані: функція зрештою поверне обіцянку, але важка робота все одно спочатку займе потік. Для справді вимогливих до процесора завдань існують інструменти на кшталт 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() відхиляється раніше, об’єднаний promise відхиляється негайно з цією причиною, замість того щоб чекати на решту операцій. Легко подумати, що два інші запити тепер зупиняються. Це не так. Відхилення об’єднаного promise не скасовує нічого, що вже розпочалося, про що чітко зазначає MDN. Інші операції продовжують виконуватися, а їхні кінцеві результати просто ігноруються.

При читанні це переважно марнує ресурси. При записі це може мати серйозні наслідки:

await Promise.all([
  updateProfile(),
  writeAuditLog(),
  sendWebhook(),
]);

Якщо writeAuditLog() відхиляється, updateProfile() може вже бути виконаним, а sendWebhook() може вже бути у процесі передачі. Виявлення цього відхилення не скасовує жодної з цих операцій.

Це проблема проектування системи, а не синтаксична проблема. Якщо ці кроки мають успішно або невдалио виконуватися разом, потрібен справжній механізм атомарності: транзакція бази даних для змін у одній базі даних або, для віддалених систем, певна комбінація компенсаційних дій, ідемпотентних операцій та збереженого стану робочого процесу. Promise.all() обіцяє повідомити вас, коли група обіцянок буде вирішена. Він не гарантує відкату, скасування чи того, що побічні ефекти відбудуться як єдине ціле. Ці гарантії мають походити з іншого джерела.

Подібним інструментом є Promise.allSettled(), який чекає на кожен вхідний елемент та окремо повідомляє про кожен результат. Він корисний, коли потрібно точно знати, які кроки виконалися успішно, але також не забезпечує відкату.

try/catch бачить лише ті відмови, які проходять через нього

Однією з причин популярності async/await є те, що помилки обіцянок можна обробляти за допомогою знайомих конструкцій 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;
  }
}

Коли очікувана обіцянка відхиляється, оператор 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 зупинитися, що стосується як самого запиту, так і читання тіла відповіді. Один момент, який потрібно врахувати: скасований запит повертає помилку AbortError, тож той, хто викликає search(), повинен розпізнати цю помилку та проігнорувати її, а не показувати користувачеві.

Фраза „сумісні 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",
});

Кожен з запитів правильно чекає на власне оновлення. Жоден з цих факторів не визначає, який запис потрапить до бази даних останнім, як будуть виконуватися повторні спроби з обох сторін, чи будуть два запити оброблятися різними серверами, або чи застарілі дані можуть перезаписати новіші дані.

Версія цієї проблеми в браузері — це класична гонка за пошуком. Запит A починається раніше за запит B, але завершується пізніше, і код, який відображає отримані результати, показує застарілу інформацію на екрані:

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 у продакшені. Кожного разу, коли запущений виклик зазнає невдачі, треба розділяти два твердження: «Я не отримав сигналу про успіх» та «Операція точно не відбулась». Це не одне й те саме твердження.

Більш проста та точна ментальна модель

Щоб ефективно розуміти механізм async/await, не обов’язково запам’ятовувати специфікацію ECMAScript. Вам потрібна проста схема: асинхронна функція повертає обіцянку; її тіло виконується звичайним чином до моменту досягнення оператора await; оператор await призупиняє виконання функції до тих пір, поки не буде отримано значення, на яке очікується, а потім запускає подальше виконання.

Усе інше — це окреме питання з власною відповіддю:

  • Кілька операцій: коли починається кожна з них та чи залежить вона від іншої? Це допомагає зрозуміти, чи є послідовність виконання навмисною чи випадковою.
  • Помилки: яке зобов’язання містить помилку, і чи справді щось очікує на неї або обробляє її? Це показує, чи try/catch справді захищає те, що ви вважаєте.
  • Непотрібні операції: чи підтримує API скасування, і чи доходить сигнал до операції, яка виконує роботу?
  • Порядок виконання: хто несе відповідальність за гарантію порядку — функція, стан інтерфейсу чи база даних?
  • Повторні спроби: чи безпечно повторювати операцію після неоднозначної помилки?
  • Кілька побічних ефектів як одна одиниця: який механізм атомарності чи координації це забезпечує, адже послідовні виклики await не можуть цього зробити?
  • Основні висновки

    async/await є навмисно простим. Він робить асинхронний потік керування зрозумілим, що має величезну цінність, але саме ця зрозумілість змушує код виглядати більш синхронним, ніж є насправді система під ним. Коли ви стикаєтесь з await, уникайте тлумачення цього як «програма чекає тут». Розглядайте це як сигнал: ця функція зупиняється до отримання значення, тож які операції вже тривають, що може виконатися протягом цього часу та які гарантії мусить забезпечити ваш власний дизайн? Це питання відповідає тому, що насправді робить синтаксис, і воно прямує вас до рішень щодо проектування, які запобігають помилкам у продакшені.

    Пов’язана література

  • Що JSON.stringify мовчки видаляє, перетворює та відмовляється серіалізувати — Дізнайтеся, які значення JavaScript JSON.stringify ігнорує або змінює, як toJSON, замінники та функції відновлення це виправляють, та коли structuredClone є кращим інструментом.
  • Що насправді робить npm install: реєстр, package.json та Lockfiles — Практичний огляд npm: реєстр та командна лінія, як npm install знаходить пакети, що записує package.json та package-lock.json, та як публікувати пакети.
  • Рефакторинг ланцюгів Promise у async/await без зміни поведінки — як перетворити ланцюги .then() на зрозумілий код async/await, що саме зупиняє await, коли виконувати кроки паралельно та як try/catch замінює .catch().