Навигация

Цикл событий и стек вызовов

JavaScript — однопоточный язык: в один момент времени выполняется только одна операция. Но благодаря Event Loop (цикл событий) он может работать с таймерами, сетевыми запросами, событиями пользователя и Promise, не блокируя основной поток. Понимание Event Loop критично для async/await, Promise, хуков React и отладки производительности.

Содержание

  1. Стек вызовов (call stack)
  2. Web API / C++ API
  3. Task queue: макрозадачи
  4. Microtask queue: микрозадачи
  5. Порядок выполнения: алгоритм Event Loop
  6. Почему setTimeout(fn, 0) не выполняется сразу
  7. Render queue и requestAnimationFrame
  8. queueMicrotask и process.nextTick
  9. Визуализация Event Loop
  10. Что блокирует Event Loop
  11. Event Loop и React
  12. Ключевые тезисы для интервью
  13. Заключение
  14. Полезные ссылки

Стек вызовов (call stack)

Стек вызовов — это структура данных, в которой JavaScript отслеживает выполнение функций. Когда функция вызывается, она помещается на вершину стека. Когда завершается — удаляется.

function first() {
  console.log('First');
}

function second() {
  first();
  console.log('Second');
}

function third() {
  second();
  console.log('Third');
}

third();
// First
// Second
// Third

Порядок стека:

  1. third()
  2. second()
  3. first()
  4. console.log('First') → удаляется
  5. first() → удаляется
  6. console.log('Second') → удаляется
  7. second() → удаляется
  8. console.log('Third') → удаляется
  9. 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 сек)

Что произошло:

  1. console.log('Start') выполнился синхронно.
  2. setTimeout передан Web API, который ждёт 1000 мс.
  3. console.log('End') выполнился синхронно.
  4. Стек освободился.
  5. Через секунду callback таймера попал в task queue.
  6. Event Loop перенёс callback в стек и выполнил.

Task queue: макрозадачи

Макрозадачи (macrotasks) — задачи из очереди, которые Event Loop выполняет по одной за итерацию:

  • setTimeout / setInterval
  • setImmediate (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 работает так:

  1. Выполнить одну макрозадачу из очереди (или синхронный скрипт целиком при первом запуске).
  2. Выполнить все микрозадачи из очереди, включая те, что появились во время выполнения.
  3. Обновить рендер, если пришло время (обычно ~60 FPS).
  4. Повторить с шага 1.
console.log('1');

setTimeout(() => console.log('2'), 0);

Promise.resolve().then(() => console.log('3'));

console.log('4');
// 1
// 4
// 3
// 2

Порядок:

  1. Синхронный код: 1, 4.
  2. Микрозадачи: 3.
  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      │
│  (таймеры, сеть, события...)  │
└──────────────────────────────┘

Алгоритм на каждом витке:

  1. Event Loop смотрит на стек.
  2. Если стек пуст, берёт задачу из очереди с наивысшим приоритетом.
  3. Выполняет микрозадачи.
  4. При необходимости запускает render.
  5. Повторяет.

Что блокирует 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) — это не «сейчас», а «на следующем тике», а бесконечные микрозадачи блокируют рендер так же, как и синхронный код.

Полезные ссылки