Как работает 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
- Выполнить текущий call stack до пустоты.
- Опустошить всю microtask queue (каждая добавленная микрозадача тоже обрабатывается до перехода к шагу 3).
- Если нужен rendering (обычно ~60 раз/сек) — выполнить requestAnimationFrame-колбэки и перерисовать.
- Взять одну задачу из macrotask queue и поместить в call stack.
- Перейти к шагу 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».