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.
Фазы цикла
Один оборот цикла проходит фазы по порядку:
- timers: колбэки
setTimeoutиsetInterval, у которых истекло время. - pending callbacks: отложенные системные колбэки (например, некоторые ошибки TCP).
- idle, prepare: внутренние.
- poll: ждёт и обрабатывает события ввода-вывода (пришли данные, прочитан файл). Если других дел нет, цикл ждёт здесь.
- check: колбэки
setImmediate. - close callbacks: события
close, напримерsocket.on('close').
Когда ни таймеров, ни активных хэндлов (серверов, сокетов) и запросов не осталось, процесс завершается.
Микрозадачи и process.nextTick
Между фазами и после каждого колбэка Node опустошает две очереди вне фаз:
- очередь
process.nextTick; - очередь микрозадач промисов (
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 отличается от браузерного.