Головна / Статті

Async/Await проти Promises: У чому насправді різниця за кулісами

У чому насправді різниця між async/await та Promises у JavaScript: порядок виконання, пам’ять, стек-трейси та ситуації, коли базові API Promises все ще є кращими у Node.js.

Видавець: REACTAPP.TOP10 хв читання2366 слів

Джерела: Async/Await vs Promises — The Real Difference Nobody Explains Clearly

Досить фрази „це просто синтаксичний елемент“. Ось що насправді відбувається під капотом щодо виконання, пам’яті, стек-трейсів та конкурентності.

Якщо попросити групу розробників на JavaScript пояснити, чим Promises відрізняються від async/await, більшість скаже ту саму фразу, яку ви чули вже сотню разів:

„Async/await — це не що інше, як косметичний шар над Promises; він просто дозволяє асинхронний код читатися так, ніби він виконується синхронно.“

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

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

  • Чому сліди помилок зовсім не схожі, коли проблема виникає всередині await та коли — всередині .catch()?
  • Чому знімки пам’яті показують різну поведінку зберігання даних між ланцюгом викликів .then() та еквівалентною async функцією?
  • Чому досвідчені інженери все ще використовують базові API Promise, навіть у кодових базах, які в іншому повністю побудовані навколо async/await?
  • Давайте відкладемо спрощені пояснення та заглибимося у те, що насправді відбувається всередині середовища виконання JavaScript під час виконання асинхронного коду.

    1. Основна ментальна модель: Push проти Pause

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

    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: Реєстрація обробників подій (Push)

    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: призупинення корутини (пауза та продовження)

    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, він не видаляє функцію — він просто призупиняє цей конкретний фрейм стеку на місці.

    1. Контроль повертається до основної нитки, звільняючи її для виконання інших завдань — оновлення інтерфейсу в браузері чи обробки інших запитів у Node.js.
    2. Усе, що стосується локального стану функції — її змінні, область видимості та положення — залишається недоторканим у пам’яті.
    3. Як тільки очікувана обіцянка вирішується, двигун передає отримане значення назад у той самий контекст виконання та продовжує його з того місця, де зупинився.

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

    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 є спільною утилітою, яка викликається з п’ятдесяти різних місць у вашому кодовому базі, цей слід не дає можливості визначити, який викликач спричинив цю конкретну невдачу.

    Як виглядає слід стеку для 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 немає механізму для очікування цих Promises; він просто переходить до наступної ітерації.

    Існують два правильні варіанти, залежно від того, що саме вам потрібно.

    Варіант А виконує роботу з кожним елементом окремо за допомогою циклу 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. Коли сирі Promises все ще кращі

    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 — для ефективного керування конкурентністю.