Event loop: что выведет код
Самый частый вопрос-разминка на фронтенд-собеседовании: «Что выведет этот код?» — и почему setTimeout(…, 0) почти всегда оказывается последним.
Event loop (цикл событий) — механизм, который по одной скармливает задачи единственному потоку JavaScript: сначала до конца выполняется синхронный код, затем вычерпывается вся очередь микрозадач, и только потом берётся одна макрозадача.
Интервьюер задаёт этот вопрос не из вредности. Он проверяет, понимаете ли вы, что JavaScript однопоточный, и что «асинхронно» не значит «параллельно». От этого понимания зависит, сможете ли вы объяснить, почему тяжёлый цикл подвешивает интерфейс, почему обновление состояния в React не видно сразу в следующей строке и почему «поставлю setTimeout на ноль, и оно выполнится сразу» — не работает.
Вопрос
Классическая формулировка. Не спешите, проговорите вслух порядок.
console.log('1 — синхронный код');
setTimeout(() => console.log('2 — setTimeout, 0 мс'), 0);
Promise.resolve().then(() => console.log('3 — микрозадача'));
console.log('4 — синхронный код');
Результат:
1 — синхронный код
4 — синхронный код
3 — микрозадача
2 — setTimeout, 0 мс
Синхронные строки выполняются первыми, потому что движок просто идёт по скрипту сверху вниз. Колбэк setTimeout и колбэк .then() не выполняются в момент, когда мы их встретили: они лишь регистрируются. А дальше вступает правило приоритета — микрозадачи всегда обгоняют макрозадачи.
Три места, где живёт ваш код
Чтобы отвечать уверенно, держите в голове ровно три сущности.
| Что это | Кто туда попадает | Когда выполняется |
| Call stack (стек вызовов) | Обычный синхронный код, тело функций | Прямо сейчас, до полного опустошения стека |
| Очередь микрозадач | Promise.then/catch/finally, продолжение после await, queueMicrotask, MutationObserver | Сразу как стек опустел — вся очередь целиком |
| Очередь макрозадач | setTimeout, setInterval, обработчики событий, сетевые колбэки, requestAnimationFrame (отдельная фаза) | По одной за оборот цикла, после микрозадач |
Правило, которое решает 90% таких задач
После каждой макрозадачи очередь микрозадач вычерпывается до конца — включая микрозадачи, которые появились уже во время её вычерпывания. Только когда микрозадач не осталось, event loop берёт следующую макрозадачу.
Уровень посложнее: очереди внутри очередей
Здесь спотыкается большинство кандидатов. Задача та же, но колбэки сами порождают новые задачи.
console.log('A — синхронно');
setTimeout(() => {
console.log('B — макрозадача 1');
Promise.resolve().then(() => console.log('C — микрозадача внутри макро'));
}, 0);
Promise.resolve().then(() => {
console.log('D — микрозадача');
setTimeout(() => console.log('E — макрозадача 2'), 0);
});
console.log('F — синхронно');
Результат:
A — синхронно
F — синхронно
D — микрозадача
B — макрозадача 1
C — микрозадача внутри макро
E — макрозадача 2
Разберём по шагам. A и F — синхронные, выводятся первыми. Стек опустел — вычерпываем микрозадачи: печатается D, а внутри регистрируется таймер E (он встаёт в очередь макрозадач после уже стоящего там B). Микрозадач больше нет — берём первую макрозадачу: B. Она регистрирует микрозадачу C. Макрозадача закончилась — снова вычерпываем микроочередь: C. И только теперь берём следующую макрозадачу: E.
async/await — это тот же .then(), только другими буквами
Слово await не «останавливает программу». Оно разрезает функцию на две части: всё до await выполняется синхронно, а всё после — становится микрозадачей.
async function loadUser() {
console.log('2 — тело async-функции до await');
await null;
console.log('4 — всё, что после await');
}
console.log('1 — старт');
loadUser();
Promise.resolve().then(() => console.log('5 — then, поставленный позже'));
console.log('3 — конец скрипта');
Результат:
1 — старт
2 — тело async-функции до await
3 — конец скрипта
4 — всё, что после await
5 — then, поставленный позже
Обратите внимание: await null — ожидание вообще ничего, но продолжение всё равно уходит в микроочередь. Именно поэтому строка «конец скрипта» печатается раньше, чем «после await».
Ловушка: тело промиса синхронно
Многие уверены, что new Promise(...) «запускает что-то в фоне». Нет: функция-исполнитель вызывается немедленно, прямо в стеке. Асинхронным является только .then().
console.log('до new Promise');
const p = new Promise((resolve) => {
console.log('тело промиса — выполняется СИНХРОННО');
resolve('готово');
});
p.then((value) => console.log('then:', value));
console.log('после new Promise');
Результат:
до new Promise
тело промиса — выполняется СИНХРОННО
после new Promise
then: готово
Как это работает под капотом
Почему setTimeout(fn, 0) не значит «выполни сейчас»? Ноль — это не «сразу», а «не раньше, чем через 0 мс». Три причины, из-за которых задержка всегда больше нуля:
- Очередь. Колбэк встаёт в очередь макрозадач. Он не может выполниться, пока движок не доделает текущий синхронный код и не вычерпает все микрозадачи. Если ваш код считает что-то полсекунды — таймер подождёт полсекунды.
- Clamping. Спецификация HTML требует: если таймеры вложены друг в друга глубже пятого уровня, минимальная задержка принудительно поднимается до 4 мс. Поэтому «рекурсивный
setTimeout(…, 0)» никогда не крутится с частотой процессора. - Рендеринг и фон. Между макрозадачами браузер может вставить перерисовку страницы, а в неактивной вкладке — вообще притормозить таймеры (обычно до 1000 мс).
Убедимся своими глазами: заблокируем поток тяжёлым циклом и посмотрим, когда сработает «нулевой» таймер.
const start = Date.now();
setTimeout(() => {
console.log('setTimeout(0) сработал через', Date.now() - start, 'мс');
}, 0);
// намеренно блокируем единственный поток примерно на 100 мс
while (Date.now() - start < 100) {}
console.log('синхронный блок закончился');
Результат (число будет своё, но порядок — всегда такой):
синхронный блок закончился
setTimeout(0) сработал через 100 мс
Это же объясняет «фризы» интерфейса: пока стек занят, браузер не может ни обработать клик, ни перерисовать кадр. Правильный ответ на вопрос «как не подвесить UI тяжёлым расчётом» — не setTimeout, а Web Worker (отдельный поток) или разбиение работы на порции.
Частые ошибки на собеседовании
- «setTimeout(0) выполнится сразу после текущей строки». Нет: сначала весь синхронный код, потом все микрозадачи, и только потом таймер.
- «Promise выполняется в отдельном потоке». Нет. JavaScript однопоточный. Параллельно работает не движок, а браузерные API (таймеры, сеть) — они лишь кладут колбэк в очередь.
- «Тело
new Promiseасинхронно». Оно синхронное. Асинхронна только доставка результата в.then(). - Путать очередь микрозадач с приоритетом «одна за раз». Микрозадачи вычерпываются все подряд. Бесконечно порождая микрозадачи, можно навсегда заблокировать рендеринг — это реальный способ «уронить» вкладку.
- Забывать, что
awaitв цикле сериализует запросы. Классический follow-up: «как ускорить?» — ответPromise.all.
Итоги — шпаргалка
- Порядок всегда один: синхронный код → все микрозадачи → одна макрозадача → снова все микрозадачи → …
- Микрозадачи:
.then/.catch/.finally, продолжение послеawait,queueMicrotask. - Макрозадачи:
setTimeout,setInterval, обработчики событий, сетевые колбэки. setTimeout(fn, 0)= «не раньше чем через 0 мс», а не «сейчас». Плюс clamping до 4 мс при глубокой вложенности.- Исполнитель
new Promise(fn)— синхронный.awaitрежет функцию на «до» (синхронно) и «после» (микрозадача). - Тяжёлые вычисления не спасает
setTimeout— нужен Web Worker или разбиение на порции.