JavaScriptSeniorTechnical

Как работает event loop в JavaScript? Что такое call stack и task queue?

Event loop: движок выполняет call stack до пустоты, затем сбрасывает всю microtask queue (Promise.then, queueMicrotask), потом берёт одну task из macrotask queue (setTimeout, I/O) — и снова. Rendering opportunity между задачами, не между микрозадачами.

Архитектура однопоточного выполнения

JavaScript — однопоточный язык. Браузер (и Node.js) предоставляют runtime со следующими компонентами:

  • Call stack — стек вызовов функций. Каждый вызов создаёт фрейм; возврат из функции снимает фрейм.
  • Heap — память для объектов.
  • Microtask queue — очередь микрозадач: колбэки Promise.then/catch/finally, queueMicrotask(), MutationObserver.
  • Macrotask queue (Task queue) — очередь задач: setTimeout, setInterval, I/O-события, пользовательские события (click, keypress).

Алгоритм event loop

  1. Выполнить текущий call stack до пустоты.
  2. Опустошить всю microtask queue (каждая добавленная микрозадача тоже обрабатывается до перехода к шагу 3).
  3. Если нужен rendering (обычно ~60 раз/сек) — выполнить requestAnimationFrame-колбэки и перерисовать.
  4. Взять одну задачу из macrotask queue и поместить в call stack.
  5. Перейти к шагу 1.

Пример: порядок вывода

console.log('A');                        // 1. synchronous

setTimeout(() => console.log('timer'), 0); // macrotask

Promise.resolve()
  .then(() => {
    console.log('promise 1');             // microtask
    return Promise.resolve();
  })
  .then(() => console.log('promise 2')); // microtask (добавляется после первого .then)

queueMicrotask(() => console.log('qMicrotask')); // microtask

console.log('B');                        // 2. synchronous

// Вывод:
// A
// B
// promise 1
// qMicrotask
// promise 2
// timer

После «B» call stack пуст. Event loop берёт microtask queue: выполняет «promise 1», затем «qMicrotask». «promise 2» добавляется в очередь, когда разрешается вложенный Promise — он тоже выполняется до macrotask. Только после полного опустошения microtask queue выполняется setTimeout-колбэк.

Практическое следствие: блокировка main thread

// Плохо: синхронная тяжёлая операция блокирует rendering
function expensiveSort(arr) {
  // O(n²) на 100 000 элементов — зависание на ~2 сек
  return arr.sort((a, b) => a - b);
}

// Лучше: дробим через setTimeout (уступаем браузеру рендер между чанками)
async function chunkedSort(arr, chunkSize = 5000) {
  for (let i = 0; i < arr.length; i += chunkSize) {
    arr.splice(i, chunkSize, ...arr.slice(i, i + chunkSize).sort((a, b) => a - b));
    await new Promise(resolve => setTimeout(resolve, 0)); // rendering opportunity
  }
  return arr;
}

// Ещё лучше для CPU-задач: Web Worker (отдельный поток)

Node.js: дополнительные очереди

В Node.js модель расширена: process.nextTick() выполняется до Promise-микрозадач (это отдельная «nextTick queue»). setImmediate() — в конце текущей итерации event loop, после I/O-колбэков. Порядок setImmediate vs setTimeout(0) в Node.js не детерминирован вне I/O-контекста.

Подводные камни

  • Бесконечная microtask queue. Если микрозадача рекурсивно добавляет новые микрозадачи (Promise.resolve().then(self)), event loop никогда не дойдёт до macrotask и rendering. Страница зависнет без видимой ошибки.
  • setTimeout(fn, 0) — не «немедленно». Минимальная задержка в браузерах — 4 мс (по спецификации HTML) для вложенных таймеров. В Node.js — около 1 мс. Реальная задержка зависит от нагрузки.
  • await не означает «следующий тик». await Promise.resolve() помещает продолжение в microtask queue, а не в macrotask. Две await-паузы внутри одной async-функции — две микрозадачи, не два «тика» event loop.
  • Длинные синхронные операции блокируют UI. Нет yield-механизма внутри синхронного кода. Тяжёлые вычисления нужно дробить через setTimeout/scheduler.yield() или выносить в Web Worker.
  • Порядок process.nextTick vs Promise в Node.js. process.nextTick выполняется раньше Promise-микрозадач. Если в nextTick-колбэке бросить исключение, обработчик Promise может не успеть до него. Смешивание nextTick и Promise усложняет отладку.
  • requestAnimationFrame — не микрозадача и не macrotask. rAF выполняется в отдельном шаге перед rendering, после всех микрозадач. Изменение DOM в rAF колбэке гарантированно попадает в ближайший кадр; в setTimeout — нет.

Common mistakes

  • Смешивать «event loop» с похожим механизмом без критерия выбора.
  • Игнорировать риск: неверно оценить границы применения темы «event loop» и получить хрупкое решение.
  • Показывать только синтаксис и не объяснять поведение в runtime или сборке.

What the interviewer is testing

  • Объясняет порядок выполнения синхронного кода, задач и очередей микрозадач.
  • Показывает на примере, как работает: движок выполняет call stack до пустоты, затем обрабатывает microtasks перед следующей task; это объясняет порядок Promise-callback, timer, rendering opportunity и пользовательских событий.
  • Называет production-нюанс и граничный случай для темы «event loop».

Sources

Related topics