Как устроен async/await в JavaScript: Event Loop, микрозадачи и очерёдность выполнения
Погружаемся в механику асинхронного JS: почему промисы выполняются раньше setTimeout, как Event Loop обрабатывает очереди и какие ошибки совершают новички при работе с async/await.
Содержание
Как устроен async/await в JavaScript: Event Loop, микрозадачи и очерёдность выполнения
Асинхронность — одна из тех тем, которую большинство JavaScript-разработчиков считают понятой раньше, чем она действительно понята. Синтаксис async/await выглядит обманчиво просто: пишешь await, и код «ждёт». Но стоит столкнуться с неожиданным порядком вывода в консоли или с промисом, который ведёт себя не так, как ожидалось, — и становится ясно, что поверхностного знания недостаточно.
В этой статье разберём, как на самом деле работает асинхронный JavaScript: что такое Event Loop, чем микрозадачи отличаются от макрозадач, почему Promise.resolve() выполняется раньше setTimeout(..., 0) и какие ошибки чаще всего допускают при работе с async/await.
Однопоточность и проблема блокировки
JavaScript — однопоточный язык. В любой момент времени выполняется ровно одна инструкция. Это означает, что если какая-то операция занимает много времени — например, сетевой запрос или чтение файла — весь остальной код будет ждать.
Именно для решения этой проблемы и существует асинхронная модель выполнения. Но «асинхронность» в JS — это не параллельность и не многопоточность. Это механизм откладывания задач и их последовательного выполнения в нужный момент.
Чтобы понять, как это работает, нужно разобраться с несколькими ключевыми компонентами среды выполнения.
Архитектура: Call Stack, Web APIs и очереди задач
Call Stack
Call Stack (стек вызовов) — это структура данных, в которой хранятся текущие вызовы функций. Когда вы вызываете функцию, она помещается на вершину стека. Когда функция завершается — снимается с него.
function greet(name) {
return `Hello, ${name}`;
}
function main() {
const message = greet('Alice');
console.log(message);
}
main();
// Call Stack: main → greet → (возврат) → console.log → (возврат) → main завершается
Пока в стеке что-то есть, JavaScript занят. Никакие другие задачи не выполняются.
Web APIs
Когда вы вызываете setTimeout, fetch или addEventListener, браузер (или Node.js) берёт эту задачу на себя и выполняет её вне стека — в так называемых Web APIs (или C++ APIs в Node.js). JavaScript-движок при этом свободен и продолжает выполнять следующий код.
Когда операция завершается (таймер истёк, данные пришли), результат помещается в одну из очередей задач.
Очереди задач: Macrotask Queue и Microtask Queue
Здесь начинается самое важное. Существуют две очереди:
- Macrotask Queue (очередь макрозадач) — сюда попадают колбэки
setTimeout,setInterval,setImmediate(Node.js), события ввода/вывода. - Microtask Queue (очередь микрозадач) — сюда попадают обработчики промисов (
.then,.catch,.finally) и колбэкиqueueMicrotask().
Event Loop: как это всё связано
Event Loop — это бесконечный цикл, который следит за состоянием Call Stack и очередей. Его алгоритм прост, но критически важен:
- Если Call Stack пуст — взять одну задачу из Macrotask Queue и выполнить её.
- После выполнения каждой макрозадачи — полностью опустошить Microtask Queue.
- Повторить.
Именно пункт 2 объясняет, почему промисы выполняются раньше setTimeout.
console.log('1: синхронный код');
setTimeout(() => {
console.log('2: setTimeout');
}, 0);
Promise.resolve().then(() => {
console.log('3: Promise.then');
});
console.log('4: ещё синхронный код');
// Вывод:
// 1: синхронный код
// 4: ещё синхронный код
// 3: Promise.then
// 2: setTimeout
Разберём по шагам:
console.log('1')— выполняется синхронно, попадает в стек и сразу выполняется.setTimeout— передаётся в Web API, колбэк будет помещён в Macrotask Queue через 0 мс.Promise.resolve().then(...)— промис уже разрешён, колбэк немедленно попадает в Microtask Queue.console.log('4')— выполняется синхронно.- Call Stack пуст. Event Loop проверяет Microtask Queue — там есть задача. Выполняет её:
console.log('3'). - Microtask Queue пуста. Event Loop берёт задачу из Macrotask Queue:
console.log('2').
Ключевой момент: микрозадачи всегда выполняются до следующей макрозадачи, независимо от того, когда они были добавлены.
Бесконечная очередь микрозадач — реальная опасность
Раз микрозадачи выполняются до следующей макрозадачи, рекурсивное добавление микрозадач заблокирует Event Loop:
function infiniteMicrotasks() {
Promise.resolve().then(infiniteMicrotasks);
}
infiniteMicrotasks();
// setTimeout здесь никогда не выполнится
setTimeout(() => console.log('Это никогда не выведется'), 100);
На практике это встречается редко, но понимать механизм важно.
async/await: синтаксический сахар над промисами
async/await — это не новая модель асинхронности. Это синтаксический сахар над промисами, который делает асинхронный код визуально похожим на синхронный.
// С промисами
function fetchUser(id) {
return fetch(`/api/users/${id}`)
.then(response => response.json())
.then(data => data.user);
}
// С async/await
async function fetchUser(id) {
const response = await fetch(`/api/users/${id}`);
const data = await response.json();
return data.user;
}
Оба варианта делают одно и то же. async-функция всегда возвращает промис. await приостанавливает выполнение внутри функции (не блокируя поток), ожидая разрешения промиса.
Что происходит под капотом
Когда интерпретатор встречает await, он:
- Вычисляет выражение справа от
await. - Если это промис — подписывается на его разрешение через
.then(). - Приостанавливает выполнение текущей
async-функции и возвращает управление в Call Stack. - Когда промис разрешается, продолжение функции помещается в Microtask Queue.
async function example() {
console.log('A');
await Promise.resolve();
console.log('B'); // это микрозадача
}
console.log('1');
example();
console.log('2');
// Вывод:
// 1
// A
// 2
// B
console.log('B') выполняется после console.log('2'), потому что после await продолжение функции уходит в Microtask Queue, а синхронный код (console.log('2')) выполняется сначала.
Типичные ошибки при работе с async/await
1. Забытый await
Одна из самых частых ошибок — работать с промисом как с обычным значением.
async function getUser() {
return { name: 'Alice' };
}
async function main() {
const user = getUser(); // Забыли await!
console.log(user.name); // undefined — user это Promise, а не объект
}
Исправление очевидно, но TypeScript или ESLint с правилом no-floating-promises помогут поймать такие ошибки автоматически.
2. Последовательное ожидание вместо параллельного
// Медленно: запросы выполняются последовательно
async function slow() {
const user = await fetchUser(1); // ждём ~200ms
const posts = await fetchPosts(1); // ждём ещё ~200ms
return { user, posts }; // итого ~400ms
}
// Быстро: запросы выполняются параллельно
async function fast() {
const [user, posts] = await Promise.all([
fetchUser(1),
fetchPosts(1)
]);
return { user, posts }; // итого ~200ms
}
Если задачи не зависят друг от друга, Promise.all — правильный выбор. Последовательный await оправдан только тогда, когда результат одной операции нужен для запуска следующей.
3. Неправильная обработка ошибок
// Проблема: ошибка внутри промиса не поймается
async function riskyOperation() {
const result = await fetch('/api/data'); // может выбросить ошибку
return result.json();
}
// Правильно: оборачивать в try/catch
async function safeOperation() {
try {
const result = await fetch('/api/data');
return await result.json();
} catch (error) {
console.error('Запрос не удался:', error);
return null;
}
}
Важный нюанс: return result.json() без await внутри try/catch — потенциальная ловушка. Если result.json() выбросит ошибку, catch её не поймает, потому что функция уже вернула промис до того, как он был отклонён.
4. async в forEach
const ids = [1, 2, 3];
// Это не работает так, как ожидается
ids.forEach(async (id) => {
const user = await fetchUser(id);
console.log(user);
});
console.log('Готово'); // Выведется раньше пользователей!
forEach не ждёт асинхронных колбэков. Используйте for...of или Promise.all с .map:
// Вариант 1: последовательно
for (const id of ids) {
const user = await fetchUser(id);
console.log(user);
}
// Вариант 2: параллельно
const users = await Promise.all(ids.map(id => fetchUser(id)));
users.forEach(user => console.log(user));
5. Необработанные отклонения промисов
// Это создаст UnhandledPromiseRejection
async function fail() {
throw new Error('Что-то пошло не так');
}
fail(); // промис отклонён, но никто не обрабатывает ошибку
В Node.js начиная с версии 15 необработанные отклонения промисов завершают процесс. В браузере они вызывают событие unhandledrejection. Всегда либо await промис внутри try/catch, либо добавляйте .catch().
Порядок выполнения: практический тест
Понимание Event Loop лучше всего проверяется на задачах с предсказанием вывода. Вот классический пример:
console.log('start');
setTimeout(() => console.log('timeout 1'), 0);
Promise.resolve()
.then(() => {
console.log('promise 1');
setTimeout(() => console.log('timeout 2'), 0);
})
.then(() => console.log('promise 2'));
setTimeout(() => console.log('timeout 3'), 0);
console.log('end');
Попробуйте предсказать вывод самостоятельно, прежде чем читать ответ.
Вывод:
start
end
promise 1
promise 2
timeout 1
timeout 3
timeout 2
Разбор: синхронный код выполняется первым (start, end). Затем — все микрозадачи: promise 1, а внутри его обработчика добавляется timeout 2 в Macrotask Queue. Цепочка .then продолжается: promise 2. После полного опустошения Microtask Queue — макрозадачи в порядке добавления: timeout 1, timeout 3, timeout 2.
Именно такие задачи часто встречаются на технических собеседованиях в компании, работающие с Node.js или фронтендом. Тем, кто только начинает разбираться с асинхронностью, может быть полезен структурированный курс — например, «JavaScript – для начинающих!» от sudo teach IT, где эти концепции объясняются с нуля и с практическими упражнениями.
Несколько слов о Promise.allSettled, Promise.race и Promise.any
Помимо Promise.all, в арсенале есть другие комбинаторы:
Promise.allSettled— ждёт завершения всех промисов, независимо от того, выполнились они или отклонились. Полезно, когда нужно обработать все результаты, даже частично неуспешные.Promise.race— разрешается или отклоняется, как только первый промис из массива завершится. Используется для реализации таймаутов.Promise.any— разрешается, когда хотя бы один промис выполнился успешно. Отклоняется только если все промисы отклонились.
// Таймаут для fetch с Promise.race
function fetchWithTimeout(url, ms) {
const timeout = new Promise((_, reject) =>
setTimeout(() => reject(new Error('Timeout')), ms)
);
return Promise.race([fetch(url), timeout]);
}
Вывод
async/await — удобный инструмент, но его поведение определяется механикой Event Loop, которая работает независимо от синтаксиса. Понимание разницы между микро- и макрозадачами, знание того, что происходит после каждого await, и осознание ловушек вроде параллельных запросов или async в forEach — это то, что отличает разработчика, который просто использует асинхронный код, от того, кто его контролирует.
Если вы хотите выстроить эти знания системно — от базовых типов и функций до промисов и асинхронности — курс «JavaScript – для начинающих!» может стать хорошей отправной точкой: он охватывает язык последовательно, без пропусков в фундаменте.
Асинхронный JavaScript перестаёт быть магией ровно в тот момент, когда вы понимаете, что Event Loop — это просто очень дисциплинированный диспетчер задач. Дальше всё становится предсказуемым.
Комментарии
Пока нет комментариев