Event Loop в Node.js

См. также Event Loop

В браузере Event Loop работает иначе — это часть HTML5 spec (не JS), с другой моделью очередей (microtask/macrotask). Этот файл — про Node-контекст: libuv, 6 фаз, process.nextTick, setImmediate, Thread Pool.

Зачем нужно

Event Loop -- сердце Node.js. Именно он позволяет однопоточному серверу обрабатывать тысячи соединений одновременно. Без понимания Event Loop невозможно писать производительный код, отлаживать race conditions и понимать порядок выполнения асинхронных операций.

Где используется

  • Оптимизация серверного кода: понимание что блокирует цикл
  • Отладка: почему callback вызвался раньше/позже ожидаемого
  • Архитектура: правильный выбор между setTimeout, setImmediate, process.nextTick
  • Интервью: один из самых частых вопросов по Node.js

Предпосылки

  • Что такое Node.js — архитектура Node.js, V8 и libuv
  • Понимание callback-ов, Promise, async/await

Общая картина

   ┌───────────────────────────────────────────────────────┐
   │                    Node.js Process                     │
   │                                                       │
   │   ┌─────────────┐     ┌──────────────────────────┐   │
   │   │  V8 Engine   │     │        libuv             │   │
   │   │  (JS код,    │────▶│  (Event Loop,            │   │
   │   │   Call Stack) │     │   Thread Pool,           │   │
   │   └─────────────┘     │   Async I/O)             │   │
   │                        └──────────────────────────┘   │
   └───────────────────────────────────────────────────────┘

Поток выполнения:
  1. V8 выполняет синхронный JS-код (Call Stack)
  2. Асинхронные операции (fs, net, timers) передаются в libuv
  3. libuv выполняет операции через Thread Pool или OS async APIs
  4. Результаты возвращаются через Event Loop в виде callback-ов
  5. V8 выполняет callback-и

Фазы Event Loop

Event Loop состоит из 6 фаз. Каждая фаза имеет FIFO-очередь callback-ов. Когда Event Loop входит в фазу, он выполняет callback-и из очереди до тех пор, пока очередь не пуста или не достигнут лимит.

   ┌─────────────────────────────────────┐
   │         ┌──────────────────┐        │
   │    ┌───▶│   1. timers      │        │
   │    │    │ (setTimeout,     │        │
   │    │    │  setInterval)    │        │
   │    │    └────────┬─────────┘        │
   │    │    ┌────────▼─────────┐        │
   │    │    │ 2. pending       │        │
   │    │    │    callbacks     │        │
   │    │    │ (системные I/O)  │        │
   │    │    └────────┬─────────┘        │
   │    │    ┌────────▼─────────┐        │
   │    │    │ 3. idle/prepare  │        │
   │    │    │ (внутренний)     │        │
   │    │    └────────┬─────────┘        │
   │    │    ┌────────▼─────────┐        │
   │    │    │ 4. poll          │        │
   │    │    │ (I/O callbacks:  │        │
   │    │    │  fs, net, etc.)  │        │
   │    │    └────────┬─────────┘        │
   │    │    ┌────────▼─────────┐        │
   │    │    │ 5. check         │        │
   │    │    │ (setImmediate)   │        │
   │    │    └────────┬─────────┘        │
   │    │    ┌────────▼─────────┐        │
   │    │    │ 6. close         │        │
   │    │    │    callbacks     │        │
   │    │    │ (socket.destroy()) │        │
   │    └────┴──────────────────┘        │
   │                                     │
   │  Между каждой фазой: microtask queue│
   │  (process.nextTick → Promise.then)  │
   └─────────────────────────────────────┘

Фаза 1: timers

Выполняет callback-и setTimeout и setInterval, у которых истёк таймер.

// setTimeout НЕ гарантирует точное время выполнения
// Он гарантирует: "не раньше чем через N мс"

setTimeout(() => {
  console.log('Минимум 100мс прошло');
}, 100);

// Если poll-фаза занята 150мс обработкой I/O,
// callback выполнится через ~150мс, не 100мс

Фаза 2: pending callbacks

Выполняет callback-и системных операций, отложенных с предыдущей итерации (например, ошибки TCP-соединения, ECONNREFUSED).

Фаза 3: idle / prepare

Внутренняя фаза libuv. Не доступна из JavaScript-кода напрямую.

Фаза 4: poll

Самая важная фаза. Выполняет I/O callback-и (чтение файлов, сетевые запросы, etc.).

const fs = require('fs');

// Этот callback выполнится в poll-фазе
fs.readFile('data.txt', (err, data) => {
  console.log('Файл прочитан'); // poll phase

  // А этот — в check-фазе (следующая после poll)
  setImmediate(() => {
    console.log('setImmediate после I/O');
  });
});

Поведение poll-фазы:

  1. Если очередь poll не пуста — выполняет callback-и по очереди
  2. Если очередь poll пуста:
    • Если есть setImmediate — переход к check-фазе
    • Если нет — ждёт новых I/O событий (блокируется)
    • Проверяет истёкшие таймеры — если есть, переход к timers

Фаза 5: check

Выполняет callback-и setImmediate. Всегда выполняется после poll-фазы.

setImmediate(() => {
  console.log('check phase');
});

Фаза 6: close callbacks

Выполняет callback-и закрытия: socket.on('close', ...), process.on('exit', ...).

const net = require('net');
const server = net.createServer;

server.on('close', () => {
  console.log('Сервер закрыт'); // close callbacks phase
});

Microtask Queue

Между каждой фазой Event Loop обрабатываются microtask-и. Они имеют приоритет над всеми фазами.

Приоритет выполнения (от высшего к низшему):

  1. process.nextTick     ← nextTick queue (высший приоритет)
  2. Promise.then/catch     ← Promise microtask queue
  3. Текущая фаза Event Loop
// Демонстрация порядка выполнения
console.log('1 — синхронный');

process.nextTick(() => {
  console.log('2 — nextTick');
});

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

setTimeout(() => {
  console.log('4 — setTimeout');
}, 0);

setImmediate(() => {
  console.log('5 — setImmediate');
});

console.log('6 — синхронный');

// Результат:
// 1 — синхронный
// 6 — синхронный
// 2 — nextTick
// 3 — Promise.then
// 4 — setTimeout
// 5 — setImmediate

process.nextTick vs setImmediate

// process.nextTick:
//   - Выполняется ПЕРЕД следующей фазой Event Loop
//   - Высший приоритет среди всех асинхронных операций
//   - ОПАСНО: рекурсивный nextTick блокирует Event Loop

// setImmediate:
//   - Выполняется в check-фазе (после poll)
//   - Безопаснее для рекурсии
//   - Рекомендуется документацией Node.js

// ❌ ОПАСНО — бесконечный nextTick блокирует I/O
function badRecursion() {
  process.nextTick(badRecursion);  // I/O никогда не выполнится!
}

// ✅ БЕЗОПАСНО — setImmediate даёт Event Loop пройти все фазы
function safeRecursion() {
  setImmediate(safeRecursion);  // I/O выполняется между итерациями
}

Порядок setTimeout(0) vs setImmediate

// В ГЛАВНОМ модуле (не внутри I/O) — порядок НЕ гарантирован:
setTimeout( => console.log('timeout'), 0);
setImmediate( => console.log('immediate'));
// Может быть: timeout → immediate
// Или:        immediate → timeout
// (зависит от производительности процесса)

// ВНУТРИ I/O callback — порядок ГАРАНТИРОВАН:
const fs = require('fs');
fs.readFile(__filename, () => {
  setTimeout( => console.log('timeout'), 0);
  setImmediate( => console.log('immediate'));
});
// Всегда: immediate → timeout
// (setImmediate в check-фазе, setTimeout ждёт следующей итерации timers)

libuv и Thread Pool

libuv — кроссплатформенная библиотека асинхронного I/O

Как Node.js выполняет async операции:

  1. Операции ОС (OS async):
     - Сетевые запросы (TCP/UDP) — epoll (Linux), kqueue (macOS), IOCP (Windows)
     - Эти операции не используют Thread Pool

  2. Thread Pool (по умолчанию 4 потока):
     - fs.* (файловые операции)
     - dns.lookup
     - crypto.pbkdf2, crypto.randomBytes
     - zlib.*
     - Можно изменить размер: UV_THREADPOOL_SIZE=8

  Пример: 4 параллельных fs.readFile запускаются на 4 потоках
  Пятый запрос ждёт, пока один из потоков освободится
// Увеличить Thread Pool (до запуска приложения)
process.env.UV_THREADPOOL_SIZE = 8;

// Или в командной строке:
// UV_THREADPOOL_SIZE=8 node app.js

// Проверка влияния Thread Pool:
const crypto = require('crypto');

console.time('hash-1');
console.time('hash-2');
console.time('hash-3');
console.time('hash-4');
console.time('hash-5');

// Первые 4 выполнятся параллельно (~одинаковое время)
// Пятый ждёт свободный поток (~в 2 раза дольше)
for (let i = 1; i <= 5; i++) {
  crypto.pbkdf2('password', 'salt', 100000, 64, 'sha512', () => {
    console.timeEnd(`hash-${i}`);
  });
}
// hash-2: 85ms
// hash-1: 86ms
// hash-3: 87ms
// hash-4: 88ms
// hash-5: 170ms  ← ждал свободный поток

Блокировка Event Loop

// ❌ Блокирующий код — весь сервер замирает
const http = require('http');

http.createServer((req, res) => {
  if (req.url === '/heavy') {
    // Тяжёлое вычисление блокирует Event Loop
    let sum = 0;
    for (let i = 0; i < 1e9; i++) {
      sum += i;
    }
    res.end(`Sum: ${sum}`);
  } else {
    // Этот запрос ждёт, пока /heavy не закончит
    res.end('Hello');
  }
}).listen(3000);

// ✅ Решение: вынести CPU-bound задачу в Worker Thread
const { Worker } = require('worker_threads');

function heavyComputation() {
  return new Promise((resolve, reject) => {
    const worker = new Worker('./heavy-worker.js');
    worker.on('message', resolve);
    worker.on('error', reject);
  });
}

Как мониторить Event Loop

// Замер задержки Event Loop (Event Loop Lag)
let lastTime = Date.now();

setInterval(() => {
  const now = Date.now();
  const lag = now - lastTime - 1000; // ожидаем 1000мс
  if (lag > 50) {
    console.warn(`Event Loop lag: ${lag}ms`);
  }
  lastTime = now;
}, 1000);

// Более точно: monitorEventLoopDelay (Node.js 12+)
const { monitorEventLoopDelay } = require('perf_hooks');

const h = monitorEventLoopDelay({ resolution: 20 });
h.enable;

setTimeout(() => {
  console.log(`min: ${h.min / 1e6}ms`);
  console.log(`max: ${h.max / 1e6}ms`);
  console.log(`mean: ${h.mean / 1e6}ms`);
  console.log(`p99: ${h.percentile(99) / 1e6}ms`);
  h.disable;
}, 5000);

Полный пример: порядок выполнения

const fs = require('fs');

console.log('START');                          // 1 — синхронный

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

setImmediate( => console.log('setImmediate 1'));    // check
setImmediate( => console.log('setImmediate 2'));    // check

process.nextTick( => console.log('nextTick 1'));    // microtask
process.nextTick(() => {
  console.log('nextTick 2');                          // microtask
  process.nextTick( => console.log('nextTick 3'));  // вложенный microtask
});

Promise.resolve.then( => console.log('Promise 1')); // microtask
Promise.resolve.then( => console.log('Promise 2')); // microtask

fs.readFile(__filename, () => {
  console.log('readFile callback');                     // poll
  setTimeout( => console.log('setTimeout in I/O'), 0);
  setImmediate( => console.log('setImmediate in I/O'));
  process.nextTick( => console.log('nextTick in I/O'));
});

console.log('END');                            // 2 — синхронный

// Результат:
// START
// END
// nextTick 1
// nextTick 2
// nextTick 3
// Promise 1
// Promise 2
// setTimeout 1
// setTimeout 2
// setImmediate 1
// setImmediate 2
// readFile callback
// nextTick in I/O
// setImmediate in I/O
// setTimeout in I/O

Практические следствия

// 1. Используй async/await вместо callback-ов — меньше ошибок
const fs = require('fs/promises');

async function processFile() {
  const data = await fs.readFile('input.txt', 'utf-8');
  const result = data.toUpperCase();
  await fs.writeFile('output.txt', result);
}

// 2. Разбивай тяжёлые вычисления
function processChunked(array, chunkSize = 1000) {
  let index = 0;
  function processChunk() {
    const end = Math.min(index + chunkSize, array.length);
    for (; index < end; index++) {
      // обработка элемента
    }
    if (index < array.length) {
      setImmediate(processChunk); // даём Event Loop отработать I/O
    }
  }
  processChunk;
}

// 3. Не блокируй Event Loop синхронными операциями
// ❌ fs.readFileSync в обработчике запроса
// ✅ fs.readFile или fs/promises

Частые ошибки

  1. Думать что setTimeout(fn, 0) выполнится мгновенно — он выполнится в следующей итерации timers-фазы, после microtask-ов
  2. Рекурсивный process.nextTick — блокирует Event Loop навсегда, I/O-callback-и не выполняются
  3. Синхронные операции в обработчикахJSON.parse(hugeString), readFileSync, тяжёлые циклы замораживают сервер
  4. Не учитывать Thread Pool — 4 одновременных fs.readFile + crypto.pbkdf2 = 5 задач на 4 потока, одна ждёт
  5. Путать порядок setTimeout(0) и setImmediate — в главном модуле порядок не гарантирован

Практика

  1. Написать скрипт, демонстрирующий порядок: sync > nextTick > Promise > setTimeout > setImmediate
  2. Создать HTTP-сервер с блокирующим эндпоинтом, измерить влияние на другие запросы
  3. Увеличить UV_THREADPOOL_SIZE и сравнить время 8 параллельных crypto.pbkdf2
  4. Реализовать chunked-обработку массива из 1M элементов с setImmediate
  5. Использовать monitorEventLoopDelay для мониторинга задержки

Связанные темы

Ресурсы


🎓 Источник: Node.js Введение в технологию

  • 📅 2018-09-18 · YouTube
  • Тезисы:
    • Паттерн Reactor — в основе event loop. Один поток, очередь событий, callback при готовности
    • readFile с error-first callback — стандарт ноды до promises
    • Порядок коллбэков НЕ известен заранее — нельзя полагаться на «закончится первым тот, кто запросил первым»
    • Cluster и Child Process — обходные пути для CPU-bound задач
    • Worker Threads (12+) — настоящие потоки JS внутри одного процесса
  • Цитата: «Event loop — это система массового обслуживания. В 2 раза медленнее C++, но выигрыш в скорости разработки»

🎓 Источник: Введение в Node js и серверный JavaScript

  • 📅 2019-11-16 · YouTube
  • Тезисы: event loop как реактор, lampam памяти, ручной GC через --expose-gc + global.gc, heap snapshot открывается в Chrome DevTools

🎓 Источник: Семинар по асинхронной очереди (2021-12-18)

  • 📅 2021-12-18 · YouTube
  • Тезисы:
    • 3 ресурса истощаются: CPU, память, file descriptors
    • GC тормозит чаще, чем нехватка памяти
    • Асинхронная очередь на входе — ограничивает конкурентность, улучшает latency
    • await блокирует функцию, но не event loop — продолжают идти другие запросы
    • Performance Hooks для авто-тюнинга очереди