← назад к разделу

Сервис на Node написан на одном языке, но внутри процесса работают две разные программы: движок V8 выполняет ваш JavaScript, а библиотека libuv ждёт сеть, диск и таймеры. Пока всё хорошо, границу между ними не видно. Она проявляется, когда порядок колбэков оказывается не таким, как ожидали, когда «однопоточный» Node вдруг занимает четыре ядра, или когда память процесса растёт по гигабайту в сутки. Разберём, что происходит под капотом, чтобы на такие вопросы был ответ.

Две программы в одном процессе

V8 это движок JavaScript от Chrome: он разбирает код, компилирует его в байт-код, выполняет и по ходу перекомпилирует горячие функции в машинный код. В V8 нет ни файлов, ни сокетов, ни таймеров, только язык. Всё, что ждёт внешнего мира, делает libuv, библиотека на C: она спрашивает у операционной системы, на каких сокетах появились данные, держит таймеры и пул потоков для операций, которые ядро не умеет делать неблокирующе. Node склеивает их: функции вроде fs.readFile и http.createServer написаны на C++ поверх libuv и отдают результат обратно в V8 в виде колбэка.

Отсюда ответ на вопрос «можно ли запустить Node без V8»: в обычной сборке нет, движок встроен, но архитектура это допускает, и экспериментальные сборки с другим движком существовали. И отсюда же ответ на вопрос о потоках: ваш JavaScript выполняется в одном потоке, а процесс Node многопоточный, потому что у libuv свой пул, а у V8 свои потоки сборщика мусора и компилятора.

Фазы цикла событий

Цикл событий это не одна очередь, а несколько, которые libuv обходит по кругу в фиксированном порядке. Четыре фазы, которые стоит знать: таймеры, где выполняются колбэки setTimeout и setInterval, чей срок вышел; опрос (poll), где цикл ждёт события ввода-вывода и выполняет их колбэки, здесь проходит большая часть работы сервера; проверка (check), где выполняются колбэки setImmediate; и закрытие, где отрабатывают обработчики close. Между любыми двумя колбэками, не между фазами, а именно между колбэками, Node опустошает две очереди, которые к libuv не относятся: сначала очередь process.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. Синхронный код идёт первым, потому что цикл ещё не начался. nextTick раньше промиса, потому что его очередь опустошается первой. А порядок двух последних в главном модуле не гарантирован: таймер на ноль миллисекунд на самом деле на одну, и если цикл успел дойти до фазы таймеров раньше, чем прошла миллисекунда, первым напечатается immediate. Внутри колбэка ввода-вывода порядок строгий: setImmediate всегда раньше setTimeout, потому что фаза проверки идёт сразу за опросом.

Два следствия для сервиса. Рекурсивный process.nextTick или бесконечная цепочка промисов не дают циклу дойти до фазы опроса, и сервер перестаёт отвечать, хотя процессор занят на сто процентов. И любой синхронный код, который считает дольше десятков миллисекунд, блокирует все соединения сразу: у цикла нет вытеснения, он ждёт, пока колбэк вернётся.

Пул потоков и настоящая многопоточность

Сеть libuv обслуживает без потоков: сокеты неблокирующие, и один системный вызов сообщает о тысячах готовых соединений. Для файлов, DNS через getaddrinfo, сжатия zlib и криптографии crypto.pbkdf2 такой возможности у операционной системы нет, и libuv выполняет их в пуле потоков, по умолчанию из четырёх. Отсюда грабля: сервис, который на каждый запрос хеширует пароль, упирается не в процессор, а в четыре потока, и пятый запрос ждёт. Размер пула задаёт переменная окружения UV_THREADPOOL_SIZE, и выставить её нужно до старта процесса.

Для вычислений в самом JavaScript пул не поможет, он выполняет только C-код. Здесь три инструмента. worker_threads запускает отдельный экземпляр V8 со своим циклом событий в том же процессе; данные между ними копируются через сообщения или разделяются через SharedArrayBuffer. cluster и его современный аналог в виде нескольких процессов под балансировщиком поднимают копию приложения на каждое ядро, каждая со своей памятью; так масштабируют HTTP-сервер. child_process запускает любую внешнюю программу. Правило выбора простое: тяжёлый расчёт внутри сервиса это воркер, рост числа одновременных запросов это процессы, и ни то ни другое не нужно, пока профиль не показал, что цикл событий занят вычислениями.

Сборка мусора в V8

V8 освобождает память поколенческим сборщиком. Новые объекты попадают в молодое поколение, маленькую область, которую сборщик обходит часто и быстро копированием живых объектов из одной половины в другую. Объект, переживший две такие сборки, переезжает в старое поколение, где сборка идёт реже и дороже: разметка достижимых объектов от корней, подметание и изредка уплотнение. Большую часть разметки V8 делает параллельно и инкрементально, чтобы паузы не превышали нескольких миллисекунд, но старое поколение в несколько гигабайт всё равно означает заметные паузы.

Размер кучи ограничен: по умолчанию в районе двух-четырёх гигабайт в зависимости от версии и машины, и при его исчерпании процесс падает с FATAL ERROR: Reached heap limit. Поднимает предел флаг --max-old-space-size в мегабайтах, и в контейнере его ставят ниже лимита памяти, чтобы Node умер понятной ошибкой, а не OOMKilled. Посмотреть, что происходит, можно без инструментов:

живой пример

const before = process.memoryUsage();
const leak = [];
for (let i = 0; i < 200000; i++) leak.push({ id: i, payload: "x".repeat(64) });
const after = process.memoryUsage();
console.log("heapUsed рос, МБ:", ((after.heapUsed - before.heapUsed) / 1e6).toFixed(1));
console.log("rss, МБ:", (after.rss / 1e6).toFixed(1));
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

heapUsed это занятые объекты в куче V8, rss вся память процесса, включая буферы и код. Растёт heapUsed от запроса к запросу и не падает после сборки, значит, объекты достижимы, и это утечка.

Утечки: откуда берутся и как искать

Сборщик убирает только недостижимое, поэтому утечка в Node это всегда ссылка, которую забыли. Четыре типичных места. Глобальный кеш или Map без вытеснения, куда кладут на каждый запрос. Слушатели EventEmitter, которые добавляют в обработчике и не снимают: Node предупредит о них сообщением MaxListenersExceededWarning. Замыкания, которые держат большой объект ради одного поля, например колбэк таймера, захвативший весь запрос. И незакрытые ресурсы: таймеры без clearTimeout, соединения без end.

Ищут утечку снимками кучи. Запускают процесс с --inspect, подключают DevTools или node --heapsnapshot-signal=SIGUSR2, снимают два снимка с интервалом под нагрузкой и сравнивают: класс объектов, число которых только растёт, и есть виновник, а вкладка «Retainers» показывает, кто его держит. Для первого подозрения достаточно графика process.memoryUsage().heapUsed в метриках. Утечку удобно воспроизводить в тесте: сто тысяч запросов к одному обработчику и проверка, что куча после сборки вернулась к исходному размеру.

Ошибки, которые никто не поймал

Исключение в синхронном коде обработчика поймает фреймворк, а вот ошибка, вылетевшая из колбэка таймера или события, идёт в process целиком. Необработанное исключение вызывает событие uncaughtException, и по умолчанию процесс завершается; отклонённый промис, у которого нет catch, вызывает unhandledRejection, и с Node 15 это тоже падение процесса. Это правильное поведение: состояние после такой ошибки неизвестно, и продолжать работу опаснее, чем перезапуститься.

Поэтому обработчики этих событий ставят для одного: записать ошибку в лог, отправить метрику и завершиться с ненулевым кодом, чтобы оркестратор поднял свежий процесс. Ставить обработчик, который «проглатывает и продолжает», значит получить сервис, который работает, но неизвестно как. Корректное завершение по SIGTERM, когда сервер перестаёт принимать соединения и дожидается текущих, разобрано в статье про конфигурацию и жизненный цикл NestJS.

Коротко

  • Процесс Node это V8 для JavaScript плюс libuv для ожидания сети, диска и таймеров; JavaScript однопоточный, процесс нет.
  • Фазы цикла: таймеры, опрос ввода-вывода, проверка с setImmediate, закрытие; между колбэками опустошаются очередь process.nextTick, затем микрозадачи промисов.
  • Порядок setTimeout(0) и setImmediate в главном модуле не гарантирован, внутри колбэка ввода-вывода setImmediate первый; рекурсивный nextTick морит цикл голодом.
  • Файлы, DNS, zlib и crypto идут через пул libuv из четырёх потоков, UV_THREADPOOL_SIZE расширяет его; вычисления в JavaScript ускоряют worker_threads, масштабируют процессами.
  • Сборщик V8 поколенческий: молодое поколение быстро копированием, старое разметкой с инкрементальными паузами; предел кучи задаёт --max-old-space-size.
  • Утечка это забытая ссылка: кеш без вытеснения, не снятые слушатели, замыкания с большим захватом, незакрытые таймеры; ищут двумя снимками кучи через --inspect.
  • uncaughtException и unhandledRejection это сигналы завершиться с логом, а не продолжить работу.

Что почитать дальше