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 или разбиение на порции.
Проверьте себя
1. Что выведет код: console.log(1); setTimeout(() => console.log(2), 0); Promise.resolve().then(() => console.log(3)); console.log(4);
A1 2 3 4
B1 4 3 2
C1 4 2 3
D1 3 4 2
2. Сколько микрозадач выполнит движок сразу после того, как отработала одна макрозадача?
AРовно одну — по одной микрозадаче за оборот цикла
BВсе, что есть в очереди, включая те, что появились во время их выполнения
CНи одной — микрозадачи выполняются только после всех макрозадач
DМаксимум четыре — дальше срабатывает clamping