Async/Await против Promises: в чём на самом деле разница внутри
В чём на самом деле разница между async/await и Promises в JavaScript: порядок выполнения, память, трейсы стека и когда нативные API Promises всё ещё остаются преимущественными в Node.js.
Источники: Async/Await vs Promises — The Real Difference Nobody Explains Clearly
Хватит говорить «это просто синтаксический сахар». Вот что на самом деле происходит при выполнении кода, использовании памяти, отладочных трейсов и обеспечении конкурентности.
Попросите группу разработчиков на JavaScript объяснить, в чем разница между Promises и async/await, и большинство из них процитируют ту же фразу, которую вы слышали сотни раз:
«Async/await — это всего лишь косметический слой поверх Promises; он позволяет асинхронному коду выглядеть так, будто он выполняется синхронно».
Это утверждение не совсем неверно, но это упрощение, которое скрывает больше, чем раскрывает. Описывать async/await как «просто синтаксический сахар» — это примерно то же, что описывать автоматическую коробку передач как «просто косметический панель, прикрученный к механической коробке передач». Это упускает из виду то, как на самом деле функционирует основной механизм, как распространяются ошибки в системе, как выделяется память и почему выбор одного стиля вместо другого может существенно изменить поведение приложения при его работе в производственных условиях.
Если бы эти два подхода действительно были взаимозаменяемы на всех уровнях, задайте себе следующие вопросы:
- Почему трассировки ошибок совершенно различаются, когда сбой происходит внутри
awaitи когда — внутри.catch()?
.then() и эквивалентной функцией async?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, он не удаляет функцию — он просто приостанавливает работу конкретной стековой рамки на месте.
- Управление возвращается на главный поток, освобождая его для выполнения других задач — обновления интерфейса в браузере или обработки других входящих запросов в Node.js.
- Вся локальная информация функции — её переменные, область видимости, положение в стеке — сохраняется нетронутой в памяти.
- Как только ожидаемый 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 — для эффективного управления конкурентностью.