Цикл событий и стек вызовов
JavaScript — однопоточный язык: в один момент времени выполняется только одна операция. Но благодаря Event Loop (цикл событий) он может работать с таймерами, сетевыми запросами, событиями пользователя и Promise, не блокируя основной поток. Понимание Event Loop критично для async/await, Promise, хуков React и отладки производительности.
Содержание
- Стек вызовов (call stack)
- Web API / C++ API
- Task queue: макрозадачи
- Microtask queue: микрозадачи
- Порядок выполнения: алгоритм Event Loop
- Почему
setTimeout(fn, 0)не выполняется сразу - Render queue и
requestAnimationFrame queueMicrotaskиprocess.nextTick- Визуализация Event Loop
- Что блокирует Event Loop
- Event Loop и React
- Ключевые тезисы для интервью
- Заключение
- Полезные ссылки
Стек вызовов (call stack)
Стек вызовов — это структура данных, в которой JavaScript отслеживает выполнение функций. Когда функция вызывается, она помещается на вершину стека. Когда завершается — удаляется.
function first() {
console.log('First');
}
function second() {
first();
console.log('Second');
}
function third() {
second();
console.log('Third');
}
third();
// First
// Second
// Third
Порядок стека:
third()second()first()console.log('First')→ удаляетсяfirst()→ удаляетсяconsole.log('Second')→ удаляетсяsecond()→ удаляетсяconsole.log('Third')→ удаляетсяthird()→ удаляется
Пока на вершине стека выполняется долгая синхронная операция, браузер не может отвечать на действия пользователя, обновлять DOM или обрабатывать сетевые ответы. Поэтому важно не блокировать стек.
Web API / C++ API
Асинхронные операции (setTimeout, HTTP-запросы, DOM-события) не выполняются в стеке. Они передаются в Web API (браузер) или C++ API (Node.js). Когда операция завершается, её callback не сразу попадает в стек, а помещается в очередь задач.
console.log('Start');
setTimeout(() => {
console.log('Timeout');
}, 1000);
console.log('End');
// Start
// End
// Timeout (через ~1 сек)
Что произошло:
console.log('Start')выполнился синхронно.setTimeoutпередан Web API, который ждёт 1000 мс.console.log('End')выполнился синхронно.- Стек освободился.
- Через секунду callback таймера попал в task queue.
- Event Loop перенёс callback в стек и выполнил.
Task queue: макрозадачи
Макрозадачи (macrotasks) — задачи из очереди, которые Event Loop выполняет по одной за итерацию:
setTimeout/setIntervalsetImmediate(Node.js)- I/O-операции
- события UI (
click,scroll,keydown) requestAnimationFrame(в некоторых интерпретациях — отдельная очередь)
setTimeout(() => console.log('A'), 0);
setTimeout(() => console.log('B'), 0);
// A
// B
Макрозадачи выполняются в порядке поступления, но между ними Event Loop может выполнить микрозадачи и обновить рендер.
Microtask queue: микрозадачи
Микрозадачи (microtasks) — задачи с более высоким приоритетом, чем макрозадачи. Они выполняются все до конца перед следующей макрозадачей:
- Promise:
.then/.catch/.finally queueMicrotask(callback)MutationObserver
console.log('Start');
setTimeout(() => {
console.log('Timeout');
}, 0);
Promise.resolve().then(() => {
console.log('Promise');
});
console.log('End');
// Start
// End
// Promise
// Timeout
Почему так: после выполнения синхронного кода Event Loop сначала опустошает микрозадачи, и только потом берёт первую макрозадачу из очереди.
Микрозадачи могут порождать новые микрозадачи — Event Loop выполнит их тоже, прежде чем перейти к следующей макрозадаче. Это значит, что бесконечная цепочка микрозадач заблокирует рендер и следующие макрозадачи.
Порядок выполнения: алгоритм Event Loop
Упрощённо Event Loop работает так:
- Выполнить одну макрозадачу из очереди (или синхронный скрипт целиком при первом запуске).
- Выполнить все микрозадачи из очереди, включая те, что появились во время выполнения.
- Обновить рендер, если пришло время (обычно ~60 FPS).
- Повторить с шага 1.
console.log('1');
setTimeout(() => console.log('2'), 0);
Promise.resolve().then(() => console.log('3'));
console.log('4');
// 1
// 4
// 3
// 2
Порядок:
- Синхронный код:
1,4. - Микрозадачи:
3. - Макрозадачи:
2.
Почему setTimeout(fn, 0) не выполняется сразу
setTimeout(fn, 0) означает не «выполнить прямо сейчас», а «поместить callback в очередь макрозадач с минимальной задержкой».
console.log('Start');
setTimeout(() => console.log('Timeout'), 0);
console.log('End');
// Start
// End
// Timeout
Callback будет выполнен только после того, как:
- текущий синхронный код завершится;
- выполнятся все микрозадачи;
- наступит минимальная задержка (в браузере обычно ~4 мс, если задержка меньше).
Поэтому setTimeout(..., 0) часто используют, чтобы отложить выполнение до следующего «тика» Event Loop.
Render queue и requestAnimationFrame
Браузер обновляет экран примерно 60 раз в секунду. Перед каждым обновлением у него есть фаза render: вычисление стилей, layout, paint, composite. Event Loop старается успеть выполнить задачи и микрозадачи между кадрами.
Render queue — это не отдельная очередь задач в том же смысле, что task/microtask, а фаза цикла: после выполнения макро- и микрозадач браузер может обновить DOM, если это необходимо и если не превышен бюджет кадра.
requestAnimationFrame (rAF) просит браузер вызвать callback перед следующим repaint:
function animate() {
// обновить анимацию
requestAnimationFrame(animate);
}
requestAnimationFrame(animate);
Важные особенности:
- rAF приостанавливается во вкладках, скрытых пользователем, чтобы экономить ресурсы.
- callback rAF получает timestamp;
- rAF лучше подходит для анимации, чем
setTimeout, потому что синхронизирован с обновлением экрана.
queueMicrotask и process.nextTick
queueMicrotask
Явный способ поставить функцию в очередь микрозадач:
console.log('Start');
queueMicrotask(() => {
console.log('Microtask');
});
console.log('End');
// Start
// End
// Microtask
queueMicrotask полезен, когда нужно отложить действие до конца текущего синхронного кода, но выполнить его раньше макрозадач.
process.nextTick (Node.js)
В Node.js есть дополнительная очередь — nextTick. Она имеет более высокий приоритет, чем микрозадачи:
console.log('Start');
process.nextTick(() => console.log('nextTick'));
Promise.resolve().then(() => console.log('Promise'));
console.log('End');
// Start
// End
// nextTick
// Promise
Порядок в Node.js: синхронный код → nextTick → микрозадачи → макрозадачи → I/O callbacks → timers → …
Важно: слишком много nextTick может привести к starvation — голоданию других очередей, потому что Node.js будет бесконечно выполнять nextTick до перехода к микро- и макрозадачам.
Визуализация Event Loop
Представьте Event Loop как охранника у двери в стек вызовов:
┌─────────────────────────────────────┐
│ Call Stack │
│ (выполняет только одну задачу) │
└──────────────────┬──────────────────┘
│
▼
┌─────────────────────────────────────┐
│ Event Loop │
│ следит за стеком и очередями │
└──────────────────┬──────────────────┘
│
┌──────────────┼──────────────┐
▼ ▼ ▼
┌────────┐ ┌──────────┐ ┌───────────┐
│ Task │ │ Microtask│ │ Render │
│ Queue │ │ Queue │ │ Phase │
│ (macr) │ │ (micro) │ │ (~60fps) │
└────────┘ └──────────┘ └───────────┘
▲ ▲
│ │
┌──────────────────────────────┐
│ Web API / C++ API │
│ (таймеры, сеть, события...) │
└──────────────────────────────┘
Алгоритм на каждом витке:
- Event Loop смотрит на стек.
- Если стек пуст, берёт задачу из очереди с наивысшим приоритетом.
- Выполняет микрозадачи.
- При необходимости запускает render.
- Повторяет.
Что блокирует Event Loop
Всё синхронное, долгое выполнение блокирует Event Loop:
// плохо: блокирует интерфейс
while (true) {}
// плохо: тяжёлые вычисления в главном потоке
const huge = Array(10_000_000).fill(0);
huge.map(x => x * x); // заморозит UI
Как не блокировать:
- разбивать большие задачи на части через
setTimeout/requestIdleCallback; - выносить вычисления в Web Workers;
- использовать
requestAnimationFrameдля анимаций; - не создавать бесконечные цепочки микрозадач.
Event Loop и React
React 18+ использует конкурентный режим, который позволяет прерывать рендеринг для обработки более приоритетных обновлений. Event Loop лежит в основе этой работы:
- обновления состояния попадают в очередь;
- React может разбить большой рендер на части, уступая управление Event Loop;
useTransitionиuseDeferredValueпомогают управлять приоритетами обновлений.
Понимание микро- и макрозадач объясняет, почему useEffect выполняется после commit-фазы и почему setState внутри Promise иногда ведёт себя иначе, чем в синхронном обработчике.
Ключевые тезисы для интервью
- Микрозадачи (Promise,
queueMicrotask,MutationObserver) выполняются все до конца перед каждой следующей макрозадачей. setTimeout(fn, 0)не выполняется сразу: callback ждёт освобождения стека, завершения всех микрозадач и минимальной задержки (~4 мс).- Event Loop блокируют длинные синхронные операции, бесконечные цепочки микрозадач и тяжёлые вычисления в главном потоке.
queueMicrotaskставит задачу в очередь микрозадач с приоритетом выше макрозадач;setTimeout(..., 0)— в очередь макрозадач.process.nextTickв Node.js имеет приоритет выше микрозадач и выполняется перед ними.requestAnimationFrameвызывает callback перед следующим repaint, синхронизируя анимации с обновлением экрана (~60 FPS).- Event Loop определяет порядок выполнения Promise и
async/await: промисы — это микрозадачи, выполняемые до следующей макрозадачи. - Блокировка стека вызовов замораживает UI: для тяжёлых задач используйте Web Workers,
setTimeoutилиrequestIdleCallback.
Заключение
Event Loop — это механизм, который обеспечивает асинхронное поведение в однопоточном JavaScript. Понимание порядка выполнения синхронного кода, микрозадач и макрозадач необходимо для работы с Promise, async/await, хуками React и отладки производительности. Помните: микрозадачи всегда выполняются до макрозадач, setTimeout(fn, 0) — это не «сейчас», а «на следующем тике», а бесконечные микрозадачи блокируют рендер так же, как и синхронный код.