Event Loop в Node.js

Одной фразой: Event Loop — это реализация reactor pattern: один поток крутит бесконечный цикл, раздаёт I/O-задачи системе и обрабатывает результаты по мере готовности. Именно поэтому Node.js быстр на I/O и опасен для CPU.

Зачем мне это знать

Ты пишешь Node.js бэкенд. К тебе приходят 1000 запросов в секунду. У тебя один поток. Если ты не понимаешь, как Event Loop распределяет работу — ты напишешь код, который заблокирует этот единственный поток, и все 1000 клиентов будут ждать.

Реальный кейс: JSON.parse() на 50MB JSON в обработчике запроса — и весь сервер встал. Или fs.readFileSync() вместо fs.readFile() — то же самое. Без понимания Event Loop ты не отличишь безопасный код от опасного.

Ментальная модель: Reactor Pattern

В книге “Node.js Design Patterns” Event Loop объясняется через reactor pattern — и это самая точная ментальная модель.

Суть паттерна в двух идеях:

  1. Демультиплексор событий (event demultiplexer) — системный механизм, который следит за множеством I/O-ресурсов одновременно. На Linux это epoll, на macOS — kqueue. Ты говоришь: “следи за этими 1000 сокетами и скажи, когда хоть один будет готов”. Он блокируется и ждёт — эффективно, на уровне ядра ОС.

  2. Reactor (Event Loop) — получает уведомления от демультиплексора и вызывает соответствующие колбэки по одному, в одном потоке.

Цикл работает так:

1. Event Loop передаёт ресурсы демультиплексору: "следи за этими"
2. Демультиплексор блокируется, ждёт события
3. Событие пришло → демультиплексор возвращает список готовых ресурсов
4. Event Loop берёт колбэк для каждого готового ресурса
5. Выполняет колбэк синхронно (в это время ничего другого не происходит)
6. Колбэк может зарегистрировать НОВЫЕ операции → они уходят в демультиплексор
7. Когда все колбэки выполнены → назад к шагу 2
8. Если нечего ждать → Event Loop завершается, процесс выходит

Аналогия: представь одного повара на кухне ресторана. Он может делать только одно действие руками в любой момент. Но у него есть духовка (файловый I/O), таймер на плите (setTimeout), доставка продуктов (сетевые запросы). Повар не стоит и не ждёт духовку — он кладёт блюдо, ставит колбэк (“когда пикнет — достать”), и берёт следующий заказ. Event Loop — это процесс, по которому повар решает, какой заказ взять следующим.

libuv — движок под капотом

Reactor pattern в Node.js реализован через libuv — C-библиотеку, которая абстрагирует различия между ОС (epoll/kqueue/IOCP) и добавляет пул потоков (по умолчанию 4) для операций, которые ОС не может делать асинхронно (файловый I/O, DNS lookup, crypto).

Это важное разграничение:

  • Сетевой I/O (HTTP, TCP, UDP) → обрабатывается ядром ОС напрямую, без пула
  • Файловый I/O (fs.*) → через пул потоков libuv
  • DNS (dns.lookup()) → через пул потоков
  • Crypto (crypto.pbkdf2(), etc.) → через пул потоков

JS-поток только раздаёт задания и обрабатывает результаты. Настоящую работу делает система и libuv.

Фазы Event Loop

Event Loop — не просто одна очередь. Это цикл из фаз, каждая со своей очередью колбэков:

   ┌───────────────────────────┐
┌─>│         timers             │  ← setTimeout, setInterval
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │     pending callbacks      │  ← системные колбэки (TCP errors и т.д.)
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │       idle, prepare        │  ← внутреннее использование Node
│  └─────────────┬─────────────┘      ┌───────────────┐
│  ┌─────────────┴─────────────┐      │   incoming:   │
│  │          poll              │<─────┤  connections, │
│  └─────────────┬─────────────┘      │   data, etc.  │
│  ┌─────────────┴─────────────┐      └───────────────┘
│  │          check             │  ← setImmediate()
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
└──┤      close callbacks       │  ← socket.on('close'), ...
   └───────────────────────────┘

Каждая фаза работает одинаково: берёт колбэки из своей FIFO-очереди и выполняет их, пока очередь не опустеет или не достигнут лимит. Затем — переход к следующей фазе.

Изменение в Node.js 20+ (libuv 1.45.0): таймеры теперь выполняются только после poll-фазы, а не до и после, как раньше. Это может влиять на порядок setImmediate и таймеров в edge-кейсах.

Фазы, которые реально важны

1. Timers — выполняет колбэки setTimeout и setInterval, у которых время истекло. Ключевой нюанс: таймер задаёт минимальную задержку, не точную. Если Event Loop занят в poll — таймер подождёт.

2. Poll — рабочая лошадка. Два действия:

  • Считает, сколько можно ждать новые I/O-события
  • Выполняет готовые I/O-колбэки (ответы от БД, чтение файлов, HTTP)

Поведение poll, когда очередь пуста:

  • Есть setImmediate() в очереди? → уходит в check-фазу
  • Нет setImmediate()? → ждёт здесь новые I/O-события (не крутится вхолостую!)
  • Есть истёкшие таймеры? → возвращается к timers-фазе

Именно poll контролирует, когда сработают таймеры. Если poll занят длинным колбэком — таймер задержится.

3. Check — колбэки setImmediate(). Выполняются сразу после poll.

Практический пример из документации — таймер ставишь на 100ms, файл читается 95ms, его колбэк работает 10ms:

const fs = require('node:fs');
 
const timeoutScheduled = Date.now();
 
setTimeout(() => {
  const delay = Date.now() - timeoutScheduled;
  console.log(`${delay}ms прошло`); // ~105ms, не 100!
}, 100);
 
fs.readFile('/path/to/file', () => {
  const start = Date.now();
  while (Date.now() - start < 10) {} // симуляция 10ms работы
});
 
// Что происходит:
// 1. Event Loop входит в poll, очередь пуста, ждёт
// 2. Через 95ms файл прочитан → колбэк попадает в poll-очередь
// 3. Колбэк работает 10ms (блокирует!)
// 4. 105ms прошло → таймер просрочен
// 5. Event Loop идёт в timers → выполняет setTimeout-колбэк
// Задержка: 105ms вместо запрошенных 100ms

Микрозадачи: между каждой фазой

Между каждой фазой (и между каждым колбэком!) Event Loop сначала опустошает две специальные очереди:

  1. process.nextTick() — высший приоритет
  2. Promise-колбэки (.then, catch, finally, await) — сразу после nextTick
setTimeout(() => console.log('1: timeout'), 0);
setImmediate(() => console.log('2: immediate'));
Promise.resolve().then(() => console.log('3: promise'));
process.nextTick(() => console.log('4: nextTick'));
console.log('5: sync');
 
// Результат:
// 5: sync        ← синхронный код первый (call stack)
// 4: nextTick    ← nextTick-очередь до промисов
// 3: promise     ← микрозадачи до макрозадач
// 1: timeout     ← может поменяться с immediate (см. ниже)
// 2: immediate

setTimeout vs setImmediate: почему порядок плавает

В корневом контексте (не внутри I/O) — порядок не гарантирован. Причина: setTimeout(fn, 0) на самом деле setTimeout(fn, 1) (минимум 1ms в Node.js). Если Event Loop дошёл до timers-фазы быстрее 1ms — таймер ещё не истёк, и setImmediate сработает первым. Если медленнее — таймер успел, он первый.

Но внутри I/O-колбэкаsetImmediate всегда раньше:

const fs = require('node:fs');
 
fs.readFile(__filename, () => {
  setTimeout(() => console.log('timeout'), 0);
  setImmediate(() => console.log('immediate'));
});
 
// Всегда:
// immediate  ← мы в poll-фазе, check идёт следующей
// timeout    ← timers — в следующей итерации цикла

Почему? Потому что I/O-колбэк выполняется в poll-фазе. После poll идёт check (setImmediate). А timers — это начало следующей итерации цикла.

process.nextTick(): зачем он вообще нужен

nextTick кажется опасным (и он опасен при злоупотреблении), но у него есть конкретный юз-кейс: гарантировать асинхронность API.

Проблема без nextTick:

let bar = null;
 
function someAsyncApiCall(callback) {
  callback(); // Вызываем синхронно — ловушка!
}
 
someAsyncApiCall(() => {
  console.log('bar:', bar); // null! Код ниже ещё не выполнился
});
 
bar = 1;

Решение — nextTick гарантирует, что колбэк выполнится после текущего синхронного кода, но до продолжения Event Loop:

let bar = null;
 
function someAsyncApiCall(callback) {
  process.nextTick(callback); // Теперь — после текущего стека
}
 
someAsyncApiCall(() => {
  console.log('bar:', bar); // 1 — всё инициализировано
});
 
bar = 1;

Ещё один паттерн — EventEmitter в конструкторе:

const EventEmitter = require('node:events');
 
class MyEmitter extends EventEmitter {
  constructor() {
    super();
    // БЕЗ nextTick: emit сработает ДО подписки — никто не услышит
    // this.emit('event');
 
    // С nextTick: emit ПОСЛЕ того, как конструктор вернёт объект
    process.nextTick(() => {
      this.emit('event'); // подписчики уже на месте
    });
  }
}
 
const emitter = new MyEmitter();
emitter.on('event', () => console.log('сработало!')); // OK

Суть: nextTick позволяет стеку вызовов развернуться до конца, прежде чем сработает колбэк. Это гарантирует, что все переменные инициализированы и все подписки оформлены.

Правило: Используй process.nextTick() только когда нужно выполнить колбэк после текущей операции, но до любого I/O. В большинстве случаев queueMicrotask() (стандарт) или setImmediate() — лучший выбор.

Подводные камни

Блокировка Event Loop — враг №1

// ПЛОХО: блокирует весь сервер
app.get('/report', (req, res) => {
  const data = fs.readFileSync('/huge-file.csv', 'utf8'); // 💀 sync I/O
  const parsed = JSON.parse(hugeJsonString);               // 💀 CPU-bound
  const sorted = millionItems.sort((a, b) => a - b);      // 💀 CPU-bound
  res.json(sorted);
});
 
// ХОРОШО: I/O асинхронно, CPU-bound — в worker
app.get('/report', async (req, res) => {
  const data = await fs.promises.readFile('/huge-file.csv', 'utf8');
  const result = await runInWorker(data); // Worker Thread для CPU
  res.json(result);
});

Рекурсивный nextTick морит I/O

// ОПАСНО: I/O-колбэки никогда не выполнятся
function bad() {
  process.nextTick(bad); // nextTick-очередь никогда не опустеет!
}
bad();
 
// БЕЗОПАСНО: setImmediate даёт Event Loop пройти через I/O
function good() {
  setImmediate(good);
}

Таймеры не точные

setTimeout(fn, 100) гарантирует не раньше 100ms. Если poll-фаза занята — может быть и 150ms, и 300ms. Для точного тайминга Node.js — неподходящий инструмент.

Пул потоков libuv — скрытое бутылочное горлышко

По умолчанию 4 потока. Если у тебя 100 параллельных fs.readFile() + crypto.pbkdf2() — они конкурируют за эти 4 потока. Настраивается через UV_THREADPOOL_SIZE (максимум 1024), но это палка о двух концах — больше потоков = больше переключений контекста.

Связь с другими темами

  • Promises / async-await — промисы попадают в очередь микрозадач, которая опустошается между фазами. await — синтаксический сахар, под капотом .then() и та же очередь
  • Closures — колбэки, которые Event Loop вызывает позже, захватывают переменные через замыкания. Паттерн nextTick + инициализация работает именно потому, что замыкание “видит” уже обновлённую переменную к моменту вызова
  • Worker Threads — когда CPU-вычисления слишком тяжёлые, выносишь их в Worker. Event Loop основного потока остаётся свободным
  • libuv thread poolfs.*, dns.lookup(), crypto.* используют пул libuv. Это не Worker Threads — это разные механизмы для разных задач