Антон Ларичев

Введение
Event Loop — сердце Node.js, механизм, который позволяет однопоточной среде выполнения обрабатывать тысячи одновременных операций ввода-вывода. Понимание его работы критично для любого backend-разработчика: без этого знания легко написать код, который блокирует сервер, теряет производительность или ведёт себя непредсказуемо под нагрузкой.
Node.js построен поверх движка V8 и библиотеки libuv. Именно libuv реализует Event Loop, делегируя тяжёлые операции (файловая система, сеть, DNS) пулу потоков и системным вызовам, а результаты возвращает в основной поток через очереди колбэков.
Архитектура: однопоточность и неблокирующий I/O
JavaScript в Node.js выполняется в одном потоке. Это значит, что в любой момент времени работает ровно одна функция. Чтобы при этом не простаивать на медленных операциях, Node перекладывает их на ядро ОС или на отдельные потоки libuv, а основной поток продолжает обрабатывать другие задачи.
const fs = require('fs');
// Неблокирующее чтение: основной поток свободен
fs.readFile('/etc/hosts', (err, data) => {
console.log('Файл прочитан');
});
console.log('Запрос отправлен');
// Сначала выведется 'Запрос отправлен', потом 'Файл прочитан'
Фазы Event Loop
Event Loop проходит фазы по кругу. На каждой итерации (tick) выполняются колбэки из соответствующей очереди.
1. Timers
Выполняются колбэки setTimeout и setInterval, у которых истёк таймер. Важно: указанное время — это минимальная задержка, а не точная.
2. Pending callbacks
Колбэки некоторых системных операций — например, ошибки TCP-соединений.
3. Idle, prepare
Внутренние нужды libuv, разработчику недоступны.
4. Poll
Центральная фаза: получение новых событий ввода-вывода и выполнение их колбэков. Если очередь пуста, цикл может ждать здесь новых событий.
5. Check
Выполняются колбэки setImmediate. Они гарантированно сработают сразу после фазы poll.
6. Close callbacks
Колбэки закрытия — например, socket.on('close', ...).
setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));
// Порядок не гарантирован вне I/O контекста,
// но внутри I/O колбэка setImmediate всегда раньше setTimeout
Микрозадачи: process.nextTick и Promise
Помимо фаз Event Loop существуют две очереди микрозадач, которые обрабатываются между любыми операциями:
process.nextTick— наивысший приоритет, выполняется до всего остального.- Promise jobs (
.then,await) — следом за nextTick.
setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));
Promise.resolve().then(() => console.log('promise'));
process.nextTick(() => console.log('nextTick'));
console.log('sync');
// Вывод:
// sync
// nextTick
// promise
// timeout (или immediate)
// immediate (или timeout)
Микрозадачи опустошаются полностью между фазами. Это удобно, но опасно: рекурсивный process.nextTick может полностью заблокировать Event Loop, не давая обработать ни одно I/O событие.
Thread Pool и тяжёлые операции
libuv держит пул потоков (по умолчанию 4, регулируется через UV_THREADPOOL_SIZE). В нём выполняются операции, которые ОС не умеет делать асинхронно: fs.*, crypto.pbkdf2, zlib, DNS через dns.lookup.
const crypto = require('crypto');
const start = Date.now();
for (let i = 0; i < 4; i++) {
crypto.pbkdf2('pass', 'salt', 100000, 64, 'sha512', () => {
console.log(`Задача ${i}: ${Date.now() - start}ms`);
});
}
// Все 4 задачи завершатся примерно одновременно — пул вмещает 4 потока
// Пятая задача будет ждать освобождения потока
Частые ошибки
Блокирующий код в основном потоке
Тяжёлые синхронные вычисления (большие циклы, JSON.parse мегабайтных строк, синхронные fs.readFileSync) полностью останавливают Event Loop. Сервер перестаёт отвечать на запросы.
// Плохо: блокирует поток на сотни миллисекунд
app.get('/hash', (req, res) => {
const hash = crypto.pbkdf2Sync('pass', 'salt', 1000000, 64, 'sha512');
res.send(hash.toString('hex'));
});
// Хорошо: асинхронная версия отдаёт работу пулу потоков
app.get('/hash', (req, res) => {
crypto.pbkdf2('pass', 'salt', 1000000, 64, 'sha512', (err, hash) => {
res.send(hash.toString('hex'));
});
});
Голодание I/O через nextTick
Рекурсивный process.nextTick не даёт циклу выйти за пределы текущей фазы.
// Опасно: I/O колбэки никогда не выполнятся
function loop() {
process.nextTick(loop);
}
loop();
Путаница setTimeout(fn, 0) и setImmediate
Вне I/O контекста порядок не детерминирован. Если нужен гарантированный порядок «после текущего I/O» — используйте setImmediate.
Игнорирование размера thread pool
Если приложение активно использует crypto или fs, дефолтных 4 потоков мало. Увеличьте через переменную окружения:
UV_THREADPOOL_SIZE=16 node app.js
Заключение
Event Loop — не магия, а строгая последовательность фаз с предсказуемыми правилами. Зная порядок Timers → Poll → Check, приоритет микрозадач и ограничения thread pool, вы сможете писать Node.js-приложения, которые держат нагрузку и не падают под пиками. Главное правило: никогда не блокируйте основной поток — выносите тяжёлые вычисления в worker threads, отдельные сервисы или асинхронные API.






Комментарии
0