Главная / Статьи

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. Основная концептуальная модель: действие vs. пауза

    Чтобы понять, почему эти два подхода кажутся настолько разными при их написании и последующей отладке, необходимо разобраться в отличных моделях выполнения, на которых они основаны.

    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: регистрация обратных вызовов (действие)

    Обещание по сути представляет собой объект, который отражает будущий результат — успех или неудачу — какой-либо асинхронной операции.

    Возьмем типичную цепочку .then():

    function getUserData(userId) {
      return fetchUser(userId)
        .then(user => fetchOrders(user.orderId))
        .then(orders => calculateTotal(orders))
        .catch(err => handleError(err));
    }
    

    Здесь на самом деле ничего не останавливается. Функция getUserData выполняется синхронно, от начала до конца, за доли микросекунды. На самом деле она создает обещание, привязывает функции-обратные вызовы, которые будут выполнены, как только эти обещания примут определенное состояние, а затем сразу же покидает стек вызовов.

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

    Модель 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. Как только ожидаемый Promise решается, движок возвращает полученное значение обратно в тот же самый контекст выполнения и продолжает работу с того места, где остановился.

    Именно это различие — передача отдельного обратного вызова для выполнения позже против замораживания и последующего возобновления рамки выполнения — объясняет почти все практические отличия между этими двумя подходами.

    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 до тех пор, пока обещание не будет выполнено. Если оно отклоняется, выполнение остается в этом контексте достаточно долго, чтобы блок catch смог его поймать, вместо того чтобы функция вернулась ещё до возникновения ошибки.

    4. Область видимости и общее состояние: как избежать «гимнастики с областями видимости»

    Цепочки вложенных обещаний становятся особенно неудобными, когда последующему шагу требуются данные, сгенерированные ранее.

    Представьте, что вам нужно загрузить пользователя, затем его профиль, а после этого сгенерировать квитанцию, которая зависит от данных обоих шагов:

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