Головна / Статті / Поширені хибні уявлення про async/await, що спричиняють проблеми в продакшені.

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

Пояснює дев’ять тонких непорозумінь щодо async/await — від умов змагання до некерованих відмов — які тихо руйнують JavaScript-застосунки у реальному світі.

3620 слів

Протягом тривалого часу багато розробників вважають, що добре розуміють async/await, лише тому, що вміло ним користуються. Знання того, що функція async завжди повертає обіцянку, уміння розміщувати await перед викликом, який звертається до мережі, та розуміння того, що конструкція try/catch допомагає краще керувати асинхронними помилками, може здаватися достатнім. Порівняно з кодом, насиченим керувальними функціями, цей стиль виглядає охайним та читається майже як звичайний синхронний JavaScript: отримати інформацію про користувача, завантажити його обліковий запис, оновити інтерфейс та виправити будь-які проблеми, що виникають по дорозі. Синтаксис настільки інтуїтивно зрозумілий, що легко не зупинятися та не аналізувати ментальну модель, яка стоїть за ним.

Справжня проблема полягає у тому, що ця поверхнева впевненість у володінні мовою охоплює лише форму async/await, а не основні правила асинхронного керування. Часто await сприймається так, ніби він заморожує всю програму, припускається, що виклик асинхронної функції автоматично пов’язує її результат із тим, хто її викликав, та вважається, що код, написаний у лінійному, зверху-вниз стилі, не може мати конфліктів у виконанні. Ці припущення рідко завдають помітної шкоди у невеликих демонстраціях, оскільки одночасно виконується лише одна операція, відповіді надходять швидко, а симульовані дані обробляються у передбачуваному порядку. Справжні продакшн-системи набагато менш толерантні.

Баги, які зрештою з’являються, не схожі на типові помилки асинхронної обробки з підручників. Функція повертається раніше, ніж певний побічний ефект насправді завершується. Стара заявка перезаписує стан, встановлений новішою. Блок try/catch не дозволяє виявити відхилення. Пакетна задача запускає більше одночасних операцій, ніж система може реально обробити. Кожна зобов’язаність, розглянута окремо, виглядає абсолютно нормальною — проте весь процес роботи поводиться зовсім не так, як спочатку планувалося.

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

Я думав, що await призупиняє більше однієї функції

Поширена перша хибна думка є цілком зрозумілою. Кожного разу, коли з’являється await, виникає спокуса уявляти, що він призупиняє все навколо себе. JavaScript ніколи не блокує вкладку браузера чи процес Node.js, але легко припустити, що навколишня логіка якимось чином почекає своєї черги.

Це не так працює. await призупиняє лише конкретну асинхронну функцію, у якій він знаходиться. Контроль повертається до середовища виконання, поки обіцянка, яку очікують, все ще залишається у стані очікування, а JavaScript продовжує обробляти все інше, що готове до виконання. Слухачі подій можуть бути активовані, таймери можуть спрацювати, непов’язані запити можуть бути вирішені, а навіть ще один виклик тієї самої функції може розпочатися протягом цього часу.

async function loadProfile() {
  console.log("Loading profile");

const profile = await fetchProfile();
  console.log("Profile loaded");
  return profile;
}
console.log("Before");
loadProfile();
console.log("After");

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

Це може здатися дрібною технічною деталлю, але наслідки цього виходять далеко за межі просто неправильно впорядкованих журналів консолі. Обробник запитів може надіслати свою відповідь, поки запис у журнал аудиту все ще в процесі. Інструмент командного рядка може завершити свою роботу, поки запис у файл ще не завершився. Тест може повідомити про успіх ще до того, як виконаються умови перевірки, розташовані всередині асинхронної функції-колбека. Асинхронна функція поводиться саме так, як і було заплановано — справжня проблема полягає у тому, що викликаючий код ніколи не пов’язує завершення своїх операцій із завершенням асинхронного виклику.

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

Я викликав асинхронні функції, не визначивши, хто буде керувати їхнім завершенням

Однією з постійних проблем є виклик асинхронної функції без очікування її результату чи повернення цього результату. Іноді це просто недбалість. Іншими разами завдання здається настільки незначним, що його можна просто запустити у фоновому режимі.

Справжня проблема не обов’язково полягає у тому, що завдання виконується незалежно — а у тому, що ніхто насправді не визначив, хто несе відповідальність за його результат.

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

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

Обіцянка, за якою ніхто не стежить, за своєю суттю не є фоновим завданням. Це просто робота без власника.

Ставлення до forEach, ніби він розуміє обіцянки

Одним із перших асинхронних патернів, які тихо порушують звичайну модель мислення, є поєднання forEach з асинхронною функцією-відповідь. Синтаксис виглядає абсолютно логічним, через що легко не помітити, чому основна функція завершується раніше, ніж насправді виконається будь-яка робота.

async function saveUsers(users) {
  users.forEach(async (user) => {
    await saveUser(user);
  });

  console.log("All users saved");
}

Повідомлення про завершення може з’явитися ще до того, як буде збережена хоча б одна запис користувача. Причина в тому, що forEach викликає функцію-колбек, але ігнорує її результат. Асинхронний колбек завжди повертає обіцянку, тож кожен виклик тихо створює обіцянку, до якої ніхто не зберігає посилання. Оскільки немає чого очікувати, зовнішня функція негайно завершується, незалежно від того, що все ще роблять її внутрішні виклики.

Це насправді не помилка, специфічна для forEach. Вона виникає через припущення, що передача асинхронної функції будь-якому методу масиву автоматично підвищує рівень його інтерфейсу до розуміння обіцянок. Це не так. Допоміжний інструмент синхронної ітерації залишається синхронним, незалежно від того, який тип функції до нього передано, якщо тільки цей інструмент не був спеціально створений для координації асинхронних операцій.

Та сама проблема виникає з map, filter та reduce. Використання map із асинхронним калебеком повертає масив, заповнений поточними обіцянками, а не масив кінцевих значень. filter ніколи не чекає на асинхронний умовник, тому він оцінює самі об’єкти обіцянок — які завжди є істинними — замість результатів, які вони зрештою генерують. Можливо створити асинхронну версію reduce, яка буде працювати правильно, але вона зазвичай є складною для розуміння, оскільки аккумулятор сам по собі є обіцянкою, яку потрібно ретельно розгортати при кожній ітерації.

Як тільки це стає зрозумілим, написання правильного коду більше не полягає у запам’ятовуванні того, який метод масиву є „дозволеним“, а у виборі моделі виконання, яка насправді потрібна завданню. Коли кожен крок справді залежить від завершення попереднього, цикл for...of із await всередині прямо виражає цю мету. Коли кроки є незалежними та можуть виконуватися одночасно, їхнє перетворення на масив обіцянок та їх одночасне очікування за допомогою Promise.all часто є правильним рішенням. А коли необхідно обмежити конкурентність, ні простий цикл, ні необмежений Promise.all не допоможуть вирішити проблему.

Синтаксис має випливати з оперативних потреб, а не навпаки. Є спокуса спочатку обрати той шаблон, який здається ідіоматичним, та сподіватися, що бажана поведінка випливе з нього, але такий порядок дій є хибним.

Припущення про те, що Promise.all прискорює роботу без ризиків

Після того, як ми дізналися, що forEach ніколи не чекає, природним наступним кроком є використання Promise.all, який зазвичай є правильним інструментом: перетворити елементи на асинхронні операції, передати отримані обіцянки Promise.all та чекати, поки всі вони завершаться одночасно.

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

Promise.all сам по собі не обмежує частоту виконання операцій. Більша частина роботи починається в момент створення обіцянок, тож обробка кількох тисяч записів може призвести до одночасного виконання кількох тисяч запитів до бази даних чи зовнішніх запитів. Такий код може працювати нормально з невеликим локальним набором даних, але потім заповнити пули з’єднань, досягти лімітів частоти запитів або вичерпати пам’ять при роботі з великим обсягом даних.

Його механізм обробки помилок також може дивувати. Коли одна з обіцянок у групі відхиляється, решта не зупиняються автоматично. Вони продовжують виконуватися, що означає, що вони все ще можуть записувати дані, надсилати повідомлення чи змінювати стан зовнішніх систем навіть після того, як код вже перейшов у блок обробки помилок. Стверджувати, що „пакет операцій зазнав невдачі“, технічно точно, але це не означає, що не відбулося жодних побічних ефектів.

Повторна спроба обробки всієї партії дозволяє знову виконати ті операції, які вже успішно пройшли під час першої спроби. У цьому випадку проблема вже не стосується механізмів обіцянок — вона полягає у ідемпотентності та обробці часткового завершення.

Перш ніж вдатися до використання Promise.all, корисно поставити інші запитання. Скільки операцій насправді можна безпечно виконувати одночасно? Чи справді вони є незалежними одна від одної? Що має означати одна невдача для решти операцій партії? Чи існує спосіб зупинити роботу, яка вже розпочалася? Якщо відбудеться повторна спроба, чи можуть елементи, які вже були оброблені, бути дубльовані? Чи справді користувач потребує кожного окремого результату, чи може бути прийнятним чесний частковий успіх?

Іноді Promise.all все ще є правильною відповіддю. Іноді Promise.allSettled підходить краще. Інші рази ситуація вимагає послідовного циклу, обмежувача конкурентності, черги або транзакції, яка охоплюватиме всю операцію. Те, який варіант є правильним, залежить від гарантій, які має забезпечити операція, а не від того, наскільки швидким здається код на перший погляд.

Припущення про те, що код, який виконується зверху вниз, не може мати конкурентних ситуацій

Мабуть, найдорожчим непорозумінням з усіх є віра в те, що await захищає код від конкурентних ситуацій. У межах однієї функції оператори після await дійсно виконуються у порядку, що створює враження єдиної послідовності. Але під час кількох одночасних викликів цієї ж функції кілька її виконань можуть легко перекриватися.

Уявіть два запити, кожен з яких читає поточний баланс, обчислює новий баланс та зберігає його. Обидва запити чекають на завершення операції читання, а потім — на завершення операції запису. Кожна команда всередині кожного запиту виконується у тому порядку, якого можна очікувати. Проте ситуація конкуренції все одно виникає, оскільки обидва запити можуть прочитати однаковий початковий баланс ще до того, як хоча б один з них записує свої оновлення.

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

Це варто пам’ятати щодо кожного await, який знаходиться між читанням стану та виконанням дії з ним. Пауза — це не просто безневинна перерва в часі. Це період, протягом якого можуть виконуватися інші операції, які можуть скасувати припущення, зроблені функцією безпосередньо перед паузою.

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

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

Очікування того, що try/catch перехопить операцію, яка ніколи не була очікувана

Ще одна поширена помилка здається абсолютно безпечною під час перегляду коду. Асинхронний виклик знаходиться всередині блоку try/catch, і здається логічним припускати, що будь-яка помилка буде оброблена саме там.

try {
  sendAnalyticsEvent(event);
} catch (error) {
  logError(error);
}

Коли sendAnalyticsEvent є асинхронною функцією, яка відхиляється після того, як вже повернула свою обіцянку, сусідній блок catch ніколи не бачить цієї помилки. Синхронна частина виклику успішно завершилася у момент, коли вона повернула обіцянку. Відхилення відбувається пізніше, поза рамками стеку, який спостерігає блок try.

Блок catch працює лише тоді, коли обіцянку очікують всередині нього:

try {
  await sendAnalyticsEvent(event);
} catch (error) {
  logError(error);
}

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

Та сама проблема спостерігається у внутрішніх функціях-викликах та обробниках подій. Асинхронна функція-виклик може відхилитися всередині себе, але якщо код, що її викликає, ніколи не чекає та не перевіряє обіцянку, яку вона повертає, це відхилення не має куди піти. Огортання місця виклику блоками try/catch не допомагає, оскільки відхилення належить до обіцянки, яку ніщо у межах цього контексту не стежить.

Це також має значення для блоків обробки помилок, які поглинають занадто багато інформації. Перетворення кожної помилки на значення null створює окрему проблему: користувачі більше не можуть відрізнити справжню помилку від закономірно порожнього результату. Проста обробка помилки — це не мета; код повинен зберегти сенс цієї помилки для того, хто буде ним користуватися далі.

Плутання скасування зі зворотним процесом

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

Це не гарантує скасування операцій, які вже були передані на сервер.

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

Це розрізнення існує тому, що скасування з боку клієнта та стан сервера знаходяться по різних боках мережевого кордону. Клієнт може відмовитися від отримання результату, але не має можливості подолати цей кордон та скасувати дії, які вже відбулися.

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

Скасування вирішує питання про те, чи все ще потрібен результат. Воно не вирішує питання про те, чи залишається базова дія послідовною.

Написання асинхронної синтакси перед визначенням робочого процесу

Загальною рисою всіх цих помилок є сприйняття асинхронного проектування як чисто синтаксичної проблеми — вирішення того, чи використовувати await, Promise.all чи асинхронний калебек, перш ніж з’ясувати, які гарантії насправді потребує операція.

Більш корисні запитання виникають раніше. Чи потрібно тому, хто викликає функцію, чекати на її завершення перед подальшими діями? Чи можуть кілька завдань безпечно виконуватися одночасно? Чи має значення їхній відносний порядок? Яка правильна реакція, коли деякі операції успішні, а інші — ні? Чи безпечно повторювати цю операцію? Хто відповідає за обробку помилки? Чи може застарілий результат все одно перезаписати спільний стан? І коли щось тайм-аутує, чи означає це, що операція зазнала невдачі, чи просто той, хто її викликав, припинив чекати?

Як тільки на ці запитання буде дано відповідь, сам JavaScript зазвичай розміщується на своє місце без особливих труднощів.

У послідовностях, де порядок справді має значення, можна використовувати цикл, який по черзі очікує на кожен крок. Незалежні завдання з відомою, обмеженою кількістю можуть виконуватися одночасно. Більші пакети даних можна обробляти з обмеженням на одночасність. Роботу, яка не потребує блокування викликаючого коду, можна передати у стійку чергу. Оновлення спільного стану можна контролювати за допомогою чітких перевірок власності. Записи в базу даних можуть атомарно забезпечувати дотримання своїх інваріантів на рівні зберігання. У операціях, де наявність дублікатів є проблемою, можна використовувати ключі ідемпотентності, щоб повторна спроба не створювала другу копію з тим самим ефектом.

Найскладніше — це правильно розмістити оператор await. Йдеться про те, щоб зрозуміти, що насправді означає „завершення“ на кожному етапі виконання операції.

Знання ключового слова, але не контракту

Роками неправильне використання async/await пов’язане скоріше не з тим, що люди забувають, як працює синтаксис, а з тим, що він робить асинхронний код здаватися набагато більш послідовним, локальним та прогнозованим, ніж він насправді є.

Бачення оператора await спонукає припускати, що все навколо нього також зупиняється. Легко не помітити, що виклик асинхронної функції без визначення того, хто несе відповідальність за її завершення, може призвести до проблем. Використання синхронних методів масивів із асинхронними керувальними функціями призводить до того, що вони розглядаються так, ніби ці два елементи розуміють один одного, хоча це не так. Звернення до Promise.all без роздумів про навантаження чи те, що відбувається, коли лише деякі обіцянки виконуються, є поширеною, але ризикованою практикою. Довіра до коду, який виконується зверху вниз, навіть коли кілька викликів можуть справді перекриватися за часом, приховує проблеми, які є очевидними. Очікування того, що конструкція try/catch спрацює для обробки відмов від обіцянок, які ніколи не чекалися, а також сплутування скасованого запиту з доказом того, що на сервері нічого не сталося, — усе це походить від однієї й тієї самої сліпої зони.

Кожна з цих помилок має своє коріння у одній і тій самій проблемі: розбіжності між тим, як виглядає код, та тим, що він насправді обіцяє.

Щоб добре розуміти асинхронний код, потрібно відстежувати обіцянки між межами функцій, а не просто читати його зверху вниз. Це означає перевіряти, хто їх створює, хто на них чекає, хто відповідає за обробку відмов, та яка частина стану все ще має владу над результатом після того, як усе вирішиться. Це також означає уважно стежити за кожною точкою, де await створює паузу, адже саме в цій паузі інші операції можуть змінити припущення, на яких ґрунтувалася функція.

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

Ця зміна перетворює асинхронний JavaScript з набору ключових слів на модель, побудовану навколо концепцій власності, таймінгу та обробки помилок. Вона також пояснює, чому код, який роками здавався правильним, все ще може поводитися некоректно в продакшені, незважаючи на проходження всіх тестів.

Навчитися чекати на виконання обіцянки — це проста частина.

Зрозуміти, що робить решта програми під час цього очікування, вимагає значно більше часу.

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

  • Поширені помилки JavaScript та TypeScript, які тихо ламають код — пояснюється суттєві проблеми JavaScript та TypeScript — від порівнянь з NaN до асинхронного таймінгу та примусової заміни типів — які спричиняють баги, незважаючи на зовнішню правильність коду.
  • Поширені хибні уявлення про Node.js та бази даних, які спричиняють баги в продакшені — дізнайтеся, чому механізми async/await, пулінг з’єднань та ORM не запобігають автоматично ситуаціям змагання, виснаженню з’єднань чи вставці SQL-запитів у додатках на Node.js.
  • Дев’ять шаблонів Promise для надійного асинхронного JavaScript у продакшені — Дізнайтеся про практичні шаблони Promise: паралельні запити, таймаути, повторні спроби, обмеження конкурентності та скасування — для створення стійкого асинхронного JavaScript промислового рівня.
  • Виправлення помилок обробки помилок Async/Await у коді Node.js для продакшену — Дізнайтеся про п’ять поширених помилок у обробці помилок async/await у JavaScript та Node.js, які спричиняють беззвучні збої та ситуації конкуренції, а також про конкретні способи їх виправлення.
  • Сім поширених ідіом JavaScript, які тихо спричиняють проблеми в майбутньому — пояснює, як повсякденні шаблони JavaScript, такі як перевірка на істинність значень, опційне ланкування та синтаксис розповсюдження, приховують припущення, які тихо порушуються під час розвитку коду.