Event Loop в JavaScript: что это и как работает
Что такое Event Loop, как работает?
Теория: что такое Event Loop
Event Loop — это “диспетчер” выполнения JavaScript в среде (браузер, Node.js), который позволяет не блокировать программу ожиданием событий и при этом выполнять код в предсказуемом порядке: синхронно до конца, затем отложенные работы из очередей.
Важная идея: пока выполняется текущий фрагмент JavaScript, никакой другой JavaScript не выполняется; новые колбэки только планируются и ждут своей очереди.
Основные части модели
- Стек вызовов (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" и "5" — синхронно, пока выполняется текущая задача (основной скрипт).
- "3" и "4" — как микрозадачи, сразу после завершения текущей задачи.
- "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.