Event Loop в Node.js
В браузере 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-фазы:
- Если очередь poll не пуста — выполняет callback-и по очереди
- Если очередь 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
Частые ошибки
- Думать что setTimeout(fn, 0) выполнится мгновенно — он выполнится в следующей итерации timers-фазы, после microtask-ов
- Рекурсивный process.nextTick — блокирует Event Loop навсегда, I/O-callback-и не выполняются
- Синхронные операции в обработчиках —
JSON.parse(hugeString),readFileSync, тяжёлые циклы замораживают сервер - Не учитывать Thread Pool — 4 одновременных
fs.readFile+crypto.pbkdf2= 5 задач на 4 потока, одна ждёт - Путать порядок setTimeout(0) и setImmediate — в главном модуле порядок не гарантирован
Практика
- Написать скрипт, демонстрирующий порядок: sync > nextTick > Promise > setTimeout > setImmediate
- Создать HTTP-сервер с блокирующим эндпоинтом, измерить влияние на другие запросы
- Увеличить
UV_THREADPOOL_SIZEи сравнить время 8 параллельныхcrypto.pbkdf2 - Реализовать chunked-обработку массива из 1M элементов с
setImmediate - Использовать
monitorEventLoopDelayдля мониторинга задержки
Связанные темы
- Что такое Node.js — архитектура Node.js
- Stream API -- потоки — потоковая обработка данных
- process — process.nextTick и управление процессом
Ресурсы
- Node.js — The Event Loop
- libuv Design Overview
- Don't Block the Event Loop (Node.js Guide)
- Event Loop and the Big Picture (blog)
🎓 Источник: 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 для авто-тюнинга очереди