Урок по треку Node.js и бэкенд 7 мин

Event loop в Node.js и libuv

Фазы event loop, микрозадачи, process.nextTick, setImmediate и пул потоков libuv.

Node.js выполняет твой JavaScript в одном потоке. Чтобы при этом обслуживать тысячи соединений, он не ждёт ввод-вывод, а регистрирует колбэк и идёт дальше. Когда операция готова, колбэк попадает в очередь, и его забирает event loop. Цикл событий и всю асинхронную механику даёт библиотека libuv, написанная на C.

Из чего состоит Node.js

  • V8 выполняет JavaScript, держит стек вызовов и кучу.
  • libuv даёт event loop, таймеры, неблокирующую работу с сетью и пул потоков (по умолчанию 4, меняется переменной UV_THREADPOOL_SIZE).
  • Привязки (bindings) соединяют JS-API (fs, net, crypto) с libuv и системой.

Сеть в libuv работает через механизмы ОС (epoll в Linux, kqueue в macOS, IOCP в Windows) и пул потоков не занимает. В пул уходят операции, у которых нет удобного неблокирующего API: большинство функций fs, dns.lookup, тяжёлые функции crypto (pbkdf2, scrypt, randomBytes асинхронно), zlib.

Фазы цикла

Один оборот цикла проходит фазы по порядку:

  1. timers: колбэки setTimeout и setInterval, у которых истекло время.
  2. pending callbacks: отложенные системные колбэки (например, некоторые ошибки TCP).
  3. idle, prepare: внутренние.
  4. poll: ждёт и обрабатывает события ввода-вывода (пришли данные, прочитан файл). Если других дел нет, цикл ждёт здесь.
  5. check: колбэки setImmediate.
  6. close callbacks: события close, например socket.on('close').

Когда ни таймеров, ни активных хэндлов (серверов, сокетов) и запросов не осталось, процесс завершается.

Микрозадачи и process.nextTick

Между фазами и после каждого колбэка Node опустошает две очереди вне фаз:

  1. очередь process.nextTick;
  2. очередь микрозадач промисов (then, await, queueMicrotask).

В CommonJS-скрипте сначала идут все nextTick, потом все промисы. Обе очереди выполняются до конца, включая задачи, добавленные по ходу.

console.log('A');
setTimeout(() => console.log('B'), 0);
Promise.resolve().then(() => console.log('C'));
process.nextTick(() => console.log('D'));
console.log('E');
// A E D C B

Синхронный код (A, E), затем nextTick (D), затем промис (C), и только потом фаза таймеров (B).

Тонкость: если nextTick добавлен изнутри промиса, он выполнится после того, как очередь промисов опустеет. А в ES-модуле (.mjs) верхний уровень сам выполняется как микрозадача, поэтому там then с верхнего уровня срабатывает раньше nextTick.

setTimeout(0) против setImmediate

На верхнем уровне скрипта порядок не определён: всё зависит от того, успел ли пройти 1 мс таймера до входа в цикл. Внутри колбэка ввода-вывода порядок жёсткий: после poll идёт check, поэтому setImmediate всегда раньше.

const fs = require('node:fs');
fs.readFile(__filename, () => {
  setTimeout(() => console.log('timeout'), 0);
  setImmediate(() => console.log('immediate'));
});
// immediate, timeout

Почему нельзя блокировать цикл

Пока выполняется синхронный код, цикл стоит: никакие запросы не обслуживаются. Типичные блокировки: fs.readFileSync в обработчике запроса, JSON.parse огромной строки, тяжёлые регулярки (ReDoS), синхронный crypto.pbkdf2Sync, долгие циклы. Решения: асинхронные API, разбиение работы на куски через setImmediate, вынос вычислений в worker_threads.

Рекурсивный process.nextTick тоже опасен: очередь никогда не опустеет, и цикл не дойдёт до ввода-вывода (I/O starvation). Рекурсивный setImmediate так не делает, потому что новые immediate уходят на следующий оборот.

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

  • Считать, что Node целиком однопоточный. JS однопоточный, но libuv использует пул потоков.
  • Думать, что setTimeout(fn, 0) срабатывает сразу. Минимум 1 мс и только в фазе timers.
  • Путать «nextTick» и «setImmediate»: по названию наоборот. nextTick срабатывает раньше, сразу после текущей операции.
  • Увеличивать UV_THREADPOOL_SIZE в надежде ускорить HTTP. Сеть пул не использует.

Что спросят на собесе

  • Какие фазы у event loop в Node.js и что выполняется в каждой.
  • В каком порядке сработают nextTick, промис, setTimeout и setImmediate.
  • Какие операции уходят в пул потоков libuv, а какие нет.
  • Что значит «заблокировать event loop» и как этого избежать.
  • Чем event loop Node отличается от браузерного.

Закрепите за 5 минут

После урока в тренажёре 3-5 проверочных вопросов, дальше модуль уходит в повторения.