Event Loop в JavaScript: что это и как работает

Что такое Event Loop, как работает?

Теория: что такое Event Loop

Event Loop — это “диспетчер” выполнения JavaScript в среде (браузер, Node.js), который позволяет не блокировать программу ожиданием событий и при этом выполнять код в предсказуемом порядке: синхронно до конца, затем отложенные работы из очередей.
Важная идея: пока выполняется текущий фрагмент JavaScript, никакой другой JavaScript не выполняется; новые колбэки только планируются и ждут своей очереди.

Для JavaScript характерен принцип run-to-completion: текущая выполняемая “задача” должна завершиться полностью, прежде чем начнёт выполняться следующая задача.

Основные части модели

  • Стек вызовов (call stack): хранит текущие вызовы функций; пока стек не пуст, выполняется синхронный код.
  • Очереди задач (task queue): сюда попадают “обычные задачи” (например, таймеры и события), которые event loop будет запускать по одной.
  • Очередь микрозадач (microtask queue): сюда попадают короткие работы повышенного приоритета (например, продолжения промисов), которые выполняются после завершения текущей задачи и до перехода к следующей задаче.
  • Внешние источники событий: таймеры, сеть, файловая система, пользовательский ввод; они “готовят” события, но не исполняют JavaScript напрямую.

Упрощённая схема работы

┌────────────────────────────────────────┐
│ 1) Выполняется текущая задача (JS код)  │
│    Пока стек вызовов не пуст            │
└───────────────────────┬────────────────┘
                        │ (стек опустел)
                        v
┌────────────────────────────────────────┐
│ 2) Выполняются ВСЕ микрозадачи          │
│    Пока очередь микрозадач не пуста     │
└───────────────────────┬────────────────┘
                        │
                        v
┌────────────────────────────────────────┐
│ 3) Среда может выполнить служебные шаги │
│    Например, в браузере — обновление UI │
└───────────────────────┬────────────────┘
                        │
                        v
┌────────────────────────────────────────┐
│ 4) Берётся следующая задача из очереди  │
│    И запускается её обработчик          │
└────────────────────────────────────────┘
Если постоянно добавлять микрозадачи, можно надолго “задержать” выполнение обычных задач (таймеров, событий) и визуальные обновления, так как микрозадачи выполняются до полного опустошения своей очереди.

“Задачи” и “микрозадачи” — в чём разница

  • Задача (task) — крупнее по смыслу: обработчик события, выполнение таймера, доставка готового сетевого события в JavaScript. Обычно event loop берёт одну задачу, выполняет её до конца и только потом берёт следующую.
  • Микрозадача (microtask) — более приоритетная “доработка”, которую нужно выполнить сразу после текущей задачи: например, продолжение цепочки промисов или действие, поставленное через queueMicrotask.
  • Ключевое правило порядка: после завершения текущей задачи выполняются микрозадачи (все), и только затем берётся следующая задача.

Практическая таблица: что куда попадает

Источник/APIОбычно планируется какЧто это означает для порядка
setTimeout(fn, 0), setIntervalзадачавыполнится после завершения текущей задачи и после микрозадач этой итерации
обработчики событий DOM (например, click)задачавыполнится отдельной задачей, когда событие будет извлечено из очереди
Promise.then/catch/finally, продолжение async/awaitмикрозадачавыполнится раньше таймеров, если запланировано в рамках текущей задачи
queueMicrotask(fn)микрозадачавыполнится “перед возвратом” в event loop, после завершения текущей задачи

Браузер: как это проявляется на примерах

Понимание event loop удобнее всего закреплять на порядке вывода в консоль: синхронные строки выполняются сразу, затем промисы (микрозадачи), затем таймеры (задачи).
Ниже приведены примеры, которые демонстрируют правило “сначала текущая задача, затем все микрозадачи, затем следующая задача”.

Пример 1: синхронный код + промис + таймер

console.log("1");

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

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

console.log("5");

Типичный порядок:

  1. "1" и "5" — синхронно, пока выполняется текущая задача (основной скрипт).
  2. "3" и "4" — как микрозадачи, сразу после завершения текущей задачи.
  3. "2" — как следующая задача из очереди таймеров.

Пример 2: явная микрозадача через `queueMicrotask`

console.log("A");

queueMicrotask(() => console.log("B"));

setTimeout(() => console.log("C"), 0);

console.log("D");

Ожидаемая логика:

  • "A" и "D" печатаются сразу.
  • "B" выполняется в микрозадачах раньше, чем таймер.
  • "C" выполняется позже отдельной задачей.

Пример 3: “голодание” из-за микрозадач

let count = 0;

function spin() {
  queueMicrotask(() => {
    count++;
    if (count < 1000000) spin();
  });
}

spin();

setTimeout(() => console.log("таймер"), 0);

Объяснение:

  • spin() начинает добавлять микрозадачи.
  • После завершения текущей задачи среда обязана выполнить микрозадачи до опустошения очереди.
  • Поскольку микрозадачи постоянно добавляются, очередь долго не пустеет, и колбэк таймера может заметно задержаться.
Тяжёлые вычисления в микрозадачах или слишком длинные цепочки промисов способны приводить к “подвисанию” интерфейса, так как управление среде возвращается позже.

Node.js: особенности и “фазы” цикла

В Node.js event loop также управляет тем, когда выполняются колбэки, но дополнительно важно понимать, что цикл имеет фазы, связанные с таймерами, вводом‑выводом и специальными очередями.
На практике это влияет на порядок выполнения setTimeout, setImmediate, а также на поведение микрозадач и process.nextTick.

Упрощённые фазы (концептуально)

  • timers: выполнение колбэков setTimeout/setInterval, у которых истекла задержка.
  • poll: ожидание и обработка событий ввода‑вывода (например, сеть, файловые операции).
  • check: выполнение колбэков setImmediate.
  • close callbacks: обработка событий закрытия ресурсов.

Принципиальная идея: setTimeout относится к таймерам, setImmediate — к фазе check, а ввод‑вывод обычно “проявляется” в poll.

Пример: `setTimeout` и `setImmediate`

setTimeout(() => console.log("timeout"), 0);
setImmediate(() => console.log("immediate"));

Объяснение:

  • Оба колбэка запланированы, но выполняются в разных фазах.
  • Из-за этого порядок может различаться в зависимости от того, в каком контексте выполняется код и есть ли готовые события ввода‑вывода.

Микрозадачи и `process.nextTick` (важное отличие)

В Node.js, помимо микрозадач промисов, существует очередь process.nextTick, которая имеет очень высокий приоритет и может выполниться раньше, чем продолжения промисов.
Это полезно для “очень скорого” выполнения, но при чрезмерном использовании также способно задерживать обработку остальных событий.

Пример:

console.log("start");

process.nextTick(() => console.log("nextTick"));

Promise.resolve().then(() => console.log("promise"));

setTimeout(() => console.log("timeout"), 0);

console.log("end");

Ожидаемая идея порядка:

  • "start" и "end" выполняются синхронно.
  • Затем выполняется nextTick, затем микрозадача промиса.
  • Затем — таймер как отдельная задача.
Частое планирование process.nextTick в цикле способно “перехватывать” управление слишком надолго и замедлять ввод‑вывод, поэтому требуется осторожность и минимальная необходимая логика внутри таких колбэков.

Кратко: event loop обеспечивает “поочерёдное” выполнение JavaScript — сначала полностью выполняется текущая задача, затем очищается очередь микрозадач, после чего запускается следующая задача; в браузере это связано с событиями и рендерингом, а в Node.js порядок дополнительно объясняется фазами цикла и наличием очереди process.nextTick.