Антон Ларичев

Введение
JavaScript — однопоточный язык, но при этом он отлично справляется с сетевыми запросами, таймерами и пользовательскими событиями без блокировки интерфейса. Секрет в асинхронной модели выполнения, построенной вокруг Event Loop. Понимание того, как движок обрабатывает очереди задач и микрозадач, отличает middle-разработчика от junior и помогает писать предсказуемый код.
В статье разберём три кита асинхронности: цикл событий, промисы и синтаксис async/await. Покажем типичные грабли и способы их обойти.
Как устроен Event Loop
Движок V8 выполняет код в одном потоке через стек вызовов (Call Stack). Когда встречается асинхронная операция — setTimeout, fetch, чтение файла — она передаётся в Web API (или libuv в Node.js). По завершении колбэк попадает в одну из очередей: macrotask queue (таймеры, I/O, setImmediate) или microtask queue (промисы, queueMicrotask, MutationObserver).
Event Loop работает по простому правилу: после очистки стека сначала выполняются ВСЕ микрозадачи, и только потом берётся одна макрозадача. После каждой макрозадачи очередь микрозадач снова осушается полностью.
console.log('1: синхронный код');
setTimeout(() => console.log('2: макрозадача'), 0);
Promise.resolve().then(() => console.log('3: микрозадача'));
console.log('4: синхронный код');
// Порядок вывода: 1, 4, 3, 2
// Микрозадача из промиса выполнится раньше setTimeout
Если непрерывно создавать микрозадачи внутри микрозадач, макрозадачи (включая отрисовку и пользовательский ввод) будут заблокированы. Это редкий, но коварный способ повесить UI.
Промисы: состояния и цепочки
Промис — объект-обёртка над будущим результатом. У него три состояния: pending, fulfilled, rejected. Переход из pending в одно из конечных состояний необратим.
function loadUser(id) {
return new Promise((resolve, reject) => {
// имитация сетевого запроса
setTimeout(() => {
if (id <= 0) {
reject(new Error('Некорректный id'));
return;
}
resolve({ id, name: 'Алиса' });
}, 100);
});
}
loadUser(1)
.then(user => user.name.toUpperCase())
.then(name => console.log('Имя:', name))
.catch(err => console.error('Ошибка:', err.message))
.finally(() => console.log('Запрос завершён'));
Каждый then возвращает новый промис, поэтому цепочки можно собирать сколь угодно длинными. Ошибка из любого звена «проваливается» вниз до ближайшего catch — это сильно упрощает обработку сбоев по сравнению с колбэк-стилем.
Параллельные запросы
Когда задачи независимы, не нужно ждать их по очереди. Promise.all запускает их параллельно и возвращает массив результатов.
const [user, orders, settings] = await Promise.all([
fetch('/api/user').then(r => r.json()),
fetch('/api/orders').then(r => r.json()),
fetch('/api/settings').then(r => r.json())
]);
Если важно дождаться всех результатов независимо от ошибок — берите Promise.allSettled. Нужен первый успешный — Promise.any. Нужен самый быстрый любого исхода — Promise.race.
async/await: синхронный вид у асинхронного кода
async/await — синтаксический сахар над промисами. Функция с ключевым словом async всегда возвращает промис, а await приостанавливает её до резолва без блокировки потока.
async function fetchProfile(userId) {
try {
const user = await loadUser(userId);
const posts = await fetch(`/api/posts?user=${user.id}`).then(r => r.json());
return { user, posts };
} catch (err) {
// ошибки промисов ловятся обычным try/catch
console.error('Не удалось загрузить профиль:', err);
throw err;
}
}
Код читается сверху вниз, как синхронный, но движок под капотом разворачивает его в цепочку then/catch. Стек ошибок в современных движках сохраняется корректно — отладка стала проще.
Частые ошибки
1. Последовательный await вместо параллельного. Самая распространённая проблема производительности.
// Плохо: 600 мс суммарно
const a = await fetchA(); // 200 мс
const b = await fetchB(); // 200 мс
const c = await fetchC(); // 200 мс
// Хорошо: 200 мс, запросы независимы
const [a, b, c] = await Promise.all([fetchA(), fetchB(), fetchC()]);
2. forEach с async-колбэком. Метод forEach игнорирует возвращаемые промисы — цикл не будет ждать завершения.
// Не работает как ожидается
items.forEach(async item => await save(item));
console.log('готово'); // выведется ДО завершения сохранений
// Правильно: for...of для последовательной обработки
for (const item of items) {
await save(item);
}
// Или Promise.all для параллельной
await Promise.all(items.map(item => save(item)));
3. Проглоченные ошибки. Промис без catch и без await выбрасывает unhandledRejection. В Node.js это приведёт к падению процесса в новых версиях.
4. Лишний new Promise. Если функция уже возвращает промис — оборачивать её в new Promise не нужно, это антипаттерн promise constructor anti-pattern.
Заключение
Асинхронность в JavaScript держится на трёх уровнях абстракции: Event Loop как механизм планирования, промисы как объекты будущих значений и async/await как удобный синтаксис. Понимая порядок выполнения микро- и макрозадач, вы перестанете удивляться странному порядку логов и научитесь писать код, который не блокирует интерфейс.
Главный практический совет: запускайте независимые операции параллельно через Promise.all, не теряйте ошибки, и помните, что await внутри обычных циклов работает, а внутри forEach — нет.






Комментарии
0