Рантайм Node.js
Как устроен Node.js под бэкендом: модули и пакеты, event loop и libuv, потоки данных, ошибки и остановка, масштабирование и диагностика. На собеседовании .NET-разработчика здесь проверяют, понимает ли он, чем один поток событий отличается от пула потоков ASP.NET Core и чем это грозит в проде.
Перед этой темой: JavaScript и TypeScript.
Как устроены модули и пакеты в Node.js?
Заголовок раздела «Как устроены модули и пакеты в Node.js?»Что это
- Модуль — файл со своей областью видимости: наружу видно только то, что он экспортирует.
- Пакет — папка с
package.json: имя, версия, зависимости, скрипты.
Две системы модулей
| CommonJS | ES-модули | |
|---|---|---|
| Импорт | const fs = require('node:fs') |
import fs from 'node:fs' |
| Экспорт | module.exports = { f } |
export function f() {} |
| Когда действует | .cjs или нет "type": "module" |
.mjs или "type": "module" |
Как пользоваться
npm install expressзаписывает зависимость вpackage.jsonи ставит пакет вnode_modules.package-lock.jsonфиксирует точные версии всего дерева — его коммитят.- В CI ставят через
npm ci: строго по lock-файлу.
Частая ошибка
Писать require в ES-модуле. Результат — ReferenceError: require is not defined in ES module scope.
Как устроено
- CommonJS.
requireсинхронно читает и выполняет файл при первом вызове и кеширует результат. Экспорт — обычный объект. - ESM. Загрузчик сначала разбирает все
importи строит граф, потом выполняет модули. Импорты — живые привязки к экспортам, а не копии. - Тип файла
.jsопределяет ближайшийpackage.json. С Node 22.7 файл без поля"type", но с синтаксисомimport, распознаётся как ESM автоматически.
Версии и диапазоны
| Запись | Что разрешает |
|---|---|
^1.2.3 |
от 1.2.3 до 2.0.0, не включая |
~1.2.3 |
от 1.2.3 до 1.3.0, не включая |
^0.2.3 |
от 0.2.3 до 0.3.0: в 0.x minor считается ломающим |
1.2.3 |
ровно эту версию |
Ловушки
npm installподгоняет lock-файл подpackage.json, аnpm ciставит строго по lock и падает при расхождении.- В ESM относительный импорт пишут с расширением:
import './db.js', а не'./db'. - В ESM нет
__dirnameиrequire. Замена —import.meta.dirnameиcreateRequire.
Правило
Новый проект — "type": "module", lock-файл в git, npm ci в CI.
Под капотом
require()ES-модуля работает без флага с Node 22.12 и 20.19, стабилен с 24.15 и 25.4. Если в графе есть top-levelawait, будетERR_REQUIRE_ASYNC_MODULE.- Поле
"exports"вpackage.jsonзадаёт публичные точки входа и условияimportиrequire. Остальные файлы пакета закрыты для импорта. - Type stripping включён по умолчанию с 22.18 и 23.6, стабилен с 24.12 и 25.2. Node заменяет типы пробелами,
tsconfig.jsonне читает и типы не проверяет. - Не поддерживаются
enum,namespaceс кодом и parameter properties — ошибкаERR_UNSUPPORTED_TYPESCRIPT_SYNTAX. Импорт типов — только черезimport type, расширение.tsв импорте обязательно, файлы вnode_modulesне обрабатываются.
Компромиссы
| npm | pnpm | |
|---|---|---|
node_modules |
плоский: виден любой пакет дерева | изолированный: видны только объявленные |
| Диск | копия в каждом проекте | общее хранилище и жёсткие ссылки |
| Install-скрипты зависимостей | выполняются | с pnpm 10 по умолчанию нет |
TypeScript без сборки хорош для скриптов и инструментов. Для сервиса типы всё равно проверяют tsc --noEmit в CI, а флаг erasableSyntaxOnly (TypeScript 5.8) запрещает синтаксис, который Node не сотрёт.
Как проверить в проде
npm ls пакетпоказывает, откуда в дереве взялась версия.overridesвpackage.jsonподнимает уязвимую транзитивную зависимость.npm auditв CI и запрет install-скриптов закрывают типичные атаки на цепочку поставки.
Итог: ESM по умолчанию, require(esm) для совместимости, lock-файл и строгий менеджер пакетов — против сюрпризов в зависимостях.
// main.mjs — ES-модульimport { readFile } from 'node:fs/promises';import { createRequire } from 'node:module';
const require = createRequire(import.meta.url); // require внутри ESM — только такconst os = require('node:os'); // CommonJS-модуль: загрузка синхронная
console.log(typeof module, typeof __dirname); // undefined undefinedconsole.log(import.meta.filename.endsWith('main.mjs')); // true
const source = await readFile(import.meta.filename, 'utf8'); // top-level await — только в ESMconsole.log(source.length > 0, typeof os.cpus); // true function// order.ts — запуск: node order.ts (Node 22.18+ или 23.6+)type Order = { id: number; total: number };
function sum(orders: Order[]): number { return orders.reduce((acc, o) => acc + o.total, 0);}
console.log(sum([{ id: 1, total: 10 }, { id: 2, total: 5 }])); // 15
// enum Status { New, Paid } — ERR_UNSUPPORTED_TYPESCRIPT_SYNTAXconst Status = { New: 'new', Paid: 'paid' } as const; // замена enum без трансформацииtype Status = (typeof Status)[keyof typeof Status];const s: Status = Status.Paid;console.log(s); // paid- Чем
^1.2.3отличается от~1.2.3? — Первый разрешает новые minor и patch, второй — только patch. - Зачем коммитить
package-lock.json? — Чтобы все машины и CI ставили одно и то же дерево версий. - Можно ли вызвать
requireдля ES-модуля? — С Node 22.12 да, если в его графе нет top-levelawait. - Что такое фантомная зависимость? — Импорт пакета, которого нет в вашем
package.json, но который npm поднял в плоскийnode_modules. - Проверяет ли Node типы при запуске
.ts? — Нет, он только стирает их; проверка —tsc --noEmit.
Как Node.js обрабатывает тысячи соединений в одном потоке?
Заголовок раздела «Как Node.js обрабатывает тысячи соединений в одном потоке?»Что это
- Node.js — среда выполнения JavaScript вне браузера: движок V8 плюс библиотека libuv для ввода-вывода.
- Event loop — цикл, который берёт готовые события и по одному вызывает их колбэки. Событие — пришёл запрос, база ответила, сработал таймер.
Как это работает на сервере
- Пришёл запрос — обработчик отправляет запрос в базу и на
awaitвозвращает управление. - Поток свободен и принимает следующие запросы.
- База ответила — продолжение обработчика попадает в очередь, и event loop его выполняет.
Частая ошибка
Синхронная работа в обработчике: fs.readFileSync, JSON.parse огромной строки, тяжёлый цикл. Пока она идёт, сервер не отвечает никому.
Как устроено
Основные фазы одного витка цикла, по порядку:
- timers — колбэки
setTimeoutиsetIntervalс истёкшим сроком. - poll — новые события ввода-вывода и их колбэки. Если делать нечего, цикл ждёт здесь.
- check — колбэки
setImmediate. - close callbacks — события
'close', например у сокета.
После каждого колбэка Node выполняет всю очередь process.nextTick, затем все микрозадачи промисов.
Кто ждёт ввод-вывод
| Операция | Где ждёт |
|---|---|
| Сокеты: HTTP, TCP, драйверы БД | ОС: epoll, kqueue, IOCP |
fs, dns.lookup, crypto.pbkdf2, zlib |
пул потоков libuv, 4 по умолчанию |
Ловушки
- Рекурсивный
process.nextTickили бесконечная цепочка промисов не дают циклу дойти до ввода-вывода. - Порядок
setTimeout(fn, 0)иsetImmediateв главном модуле не определён. В колбэке ввода-выводаsetImmediateвсегда первый. - Пул из 4 потоков: пятый
crypto.pbkdf2ждёт, пока освободится поток.
Правило
Отложить работу — setImmediate, его и советует документация Node. Тяжёлое вычисление — вон из главного потока.
Под капотом
- С libuv 1.45 (Node 20) таймеры выполняются после фазы poll, а не до неё.
- ES-модуль выполняет асинхронный загрузчик, поэтому на верхнем уровне ES-модуля промис срабатывает раньше
process.nextTick. В CommonJS — наоборот. dns.lookupвызываетgetaddrinfoв пуле, и его по умолчанию используютhttpиnet.dns.resolve*идёт через c-ares, мимо пула.- Пул общий на процесс, максимум — 1024 потока через
UV_THREADPOOL_SIZE.
Компромиссы
| Node.js | ASP.NET Core | |
|---|---|---|
| Где выполняется код запроса | один поток event loop | потоки пула .NET |
| CPU-работа в запросе | задерживает все запросы | занимает один поток |
| Ядра | один процесс — одно ядро для JS | все ядра в одном процессе |
| Синхронный I/O | останавливает сервер | ведёт к голоданию пула |
Как проверить в проде
perf_hooks.monitorEventLoopDelay()даёт гистограмму задержки цикла. Рост p99 до десятков миллисекунд — признак блокировки.performance.eventLoopUtilization()— доля времени, когда цикл занят. Близко к 1 — процесс упёрся в CPU.- Функцию, которая держит поток, покажет CPU-профиль:
--cpu-profили DevTools. - Медленные файлы и DNS при свободном CPU — повод увеличить
UV_THREADPOOL_SIZEпри запуске процесса.
Итог: модель Node выигрывает на ожидании ввода-вывода и проигрывает на CPU, поэтому задержку цикла меряют так же, как в .NET меряют пул потоков.
// phases.cjs — CommonJSconst fs = require('node:fs');
fs.readFile(__filename, () => { // колбэк ввода-вывода: фаза poll setTimeout(() => console.log('timeout'), 0); // 5: фаза timers, следующий виток setImmediate(() => console.log('immediate')); // 4: фаза check, сразу после poll Promise.resolve().then(() => console.log('promise')); // 3: микрозадача process.nextTick(() => console.log('nextTick')); // 2: раньше промисов console.log('poll callback'); // 1});Числа в комментариях — порядок вывода.
// delay.cjs — CommonJSconst { monitorEventLoopDelay } = require('node:perf_hooks');const crypto = require('node:crypto');
const histogram = monitorEventLoopDelay({ resolution: 10 });histogram.enable();setTimeout(() => { const start = Date.now(); while (Date.now() - start < 200) {} // синхронная работа на 200 мс}, 10);setTimeout(() => { histogram.disable(); console.log(histogram.max / 1e6 > 150); // true: цикл стоял ~200 мс}, 500);
const start = Date.now();for (let i = 1; i <= 6; i++) { crypto.pbkdf2('secret', 'salt', 500_000, 64, 'sha512', () => { console.log(i, Date.now() - start); // 1–4 примерно вместе, 5 и 6 — вдвое позже });}С UV_THREADPOOL_SIZE=6 все шесть хэшей завершаются примерно одновременно.
- Что выполнится раньше:
process.nextTickилиPromise.then? — В CommonJSnextTick; в ES-модуле на верхнем уровне — промис. - Какие операции идут в пул libuv? — Файлы,
dns.lookup,crypto.pbkdf2иscrypt, асинхронныйzlib; сокеты — нет. - Зачем
setImmediate, если естьsetTimeout(fn, 0)? — Он выполняется сразу после ввода-вывода текущего витка и не зависит от таймеров. - Как понять, что цикл блокируется? —
monitorEventLoopDelayиeventLoopUtilization, затем CPU-профиль. - Чем модель отличается от ASP.NET Core? — Там запросы идут в пуле потоков на всех ядрах; в Node — один поток JS на процесс.
Как работать с потоками данных и ошибками в Node.js?
Заголовок раздела «Как работать с потоками данных и ошибками в Node.js?»Что это
- Поток (stream) — объект, через который данные идут порциями (chunks).
- Readable — источник: файл, тело HTTP-запроса. Writable — приёмник: файл, HTTP-ответ. Transform — преобразует по дороге: сжатие, парсинг.
Как пользоваться
- Соединять:
await pipeline(source, transform, destination)изnode:stream/promises. - Читать в коде:
for await (const chunk of stream).
Как ловить ошибки
| Стиль API | Как ловить |
|---|---|
промисы и async |
try/catch вокруг await |
колбэк (err, data) |
первым делом проверить err |
EventEmitter и потоки |
слушатель on('error') |
Частая ошибка
Читать большой файл через fs.readFile: он целиком попадает в память. Для больших данных — fs.createReadStream и pipeline.
Как устроено
- У потока есть внутренний буфер размером
highWaterMark.write()возвращаетfalse, когда буфер Writable заполнен, и источник должен ждать события'drain'. - Это и есть backpressure: медленный приёмник тормозит быстрый источник, а не копит данные в памяти.
pipelineделает это сам. При ошибке любого звена он уничтожает все потоки и отклоняет промис.
Когда что выбирать
| Задача | Инструмент |
|---|---|
| Соединить потоки | pipeline из node:stream/promises |
| Прочитать поток в коде | for await...of |
| Своё преобразование | Transform или async-генератор внутри pipeline |
Ловушки
.pipe()не передаёт ошибку дальше: сбой источника оставит приёмник открытым.'error'без слушателя уEventEmitter— исключение и падение процесса.- Игнорировать
falseотwrite()— буфер растёт, пока не кончится память. async-слушатель события. Его отклонённый промисEventEmitterне ловит, и получается unhandled rejection.
Правило
Потоки — только через pipeline, у каждого долгоживущего EventEmitter — обработчик 'error'.
Под капотом
- Исключение, дошедшее до event loop, —
uncaughtException. По умолчанию Node печатает стек и завершается с кодом 1. - Отклонённый промис без обработчика с Node 15 по умолчанию (
--unhandled-rejections=throw) вызывает событиеunhandledRejection. Если слушателя нет, он становится uncaught exception. - Документация Node прямо говорит: продолжать работу после
uncaughtExceptionнебезопасно.
Корректная остановка
docker stopили Kubernetes присылает SIGTERM, через срок — SIGKILL.server.close()перестаёт принимать соединения. С Node 19 он сразу закрывает простаивающие keep-alive соединения.- Активные запросы дожидаются с таймаутом короче срока оркестратора — в Kubernetes 30 секунд по умолчанию.
- Закрыть пулы БД и клиенты брокеров, затем выйти с кодом 0.
Компромиссы
Глобальный обработчик удобно превратить в «глушилку» ошибок, но процесс с неизвестным состоянием опаснее упавшего. Надёжнее несколько экземпляров и быстрый перезапуск.
Как проверить в проде
- Подписка на
SIGTERMотменяет стандартное завершение: безprocess.exitпроцесс будет жить, пока его не убьёт SIGKILL. - Node не рассчитан работать как PID 1: в Docker его запускают с
--initили через tini и командойCMD ["node", "dist/main.js"], а неnpm start. - Код выхода 137 у контейнера — SIGKILL: остановка не уложилась в срок или сработал OOM killer.
Итог: ошибки, не пойманные на месте, логируют и завершают процесс, а остановку по SIGTERM пишут явно и с таймаутом.
// pipe.mjs — ES-модульimport { pipeline } from 'node:stream/promises';import { Readable, Transform, Writable } from 'node:stream';
const upper = () => new Transform({ transform(chunk, _encoding, callback) { callback(null, chunk.toString().toUpperCase()); },});const collect = (parts) => new Writable({ write(chunk, _encoding, callback) { parts.push(chunk.toString()); callback(); },});
const parts = [];await pipeline(Readable.from(['a', 'b', 'c']), upper(), collect(parts));console.log(parts.join('')); // ABC
const broken = new Transform({ transform(_chunk, _encoding, callback) { callback(new Error('bad chunk')); },});const source = Readable.from(['x']);try { await pipeline(source, broken, collect([]));} catch (err) { console.log(err.message, source.destroyed); // bad chunk true: pipeline закрыл и источник}// server.mjs — ES-модульimport http from 'node:http';import { EventEmitter } from 'node:events';
const bus = new EventEmitter();try { bus.emit('error', new Error('nobody listens')); // 'error' без слушателя — исключение} catch (err) { console.log(err.message); // nobody listens}
process.on('unhandledRejection', (reason) => { console.error('unhandled rejection:', reason); // залогировать и завершиться process.exit(1);});
const server = http.createServer((req, res) => res.end('ok'));server.listen(3000);
process.once('SIGTERM', () => { // docker stop, Kubernetes console.log('SIGTERM: draining'); server.close(() => process.exit(0)); // новых соединений нет, текущие дожидаемся setTimeout(() => process.exit(1), 10_000).unref(); // страховка от зависших запросов});- Что такое backpressure и как его соблюдает
pipeline? — Приёмник тормозит источник:write()вернулfalse— ждём'drain';pipelineделает это сам. - Чем
pipelineлучше.pipe()? — Передаёт ошибки и уничтожает все потоки, а сnode:stream/promisesего можно ждать черезawait. - Что будет, если у
EventEmitterнет слушателя'error'? —emit('error')выбросит исключение, и процесс упадёт. - Можно ли продолжать работу после
uncaughtException? — Нет: залогировать и завершиться, перезапуск сделает оркестратор. - Почему контейнер с
npm startдолго останавливается? — npm не передаёт SIGTERM процессу Node, и тот получает SIGKILL по таймауту.
Как масштабировать и профилировать Node.js-сервис?
Заголовок раздела «Как масштабировать и профилировать Node.js-сервис?»Что это
- Масштабирование — запуск нескольких процессов, чтобы задействовать все ядра и машины.
- Профилирование — измерение, на что уходят CPU и память.
Как пользоваться
- Несколько экземпляров за балансировщиком: Nginx, Kubernetes Service.
- Тяжёлое вычисление — в
worker_threads, чтобы не блокировать event loop. node --inspect app.js, затемchrome://inspectв Chrome: вкладки Performance и Memory.
Частая ошибка
Хранить сессии или кеш в памяти процесса. При нескольких экземплярах каждый видит своё, поэтому общее состояние держат в Redis или в базе.
Когда что выбирать
| Способ | Когда |
|---|---|
| Несколько подов или контейнеров | по умолчанию в Kubernetes и Docker |
cluster или PM2 |
одна машина без оркестратора |
worker_threads |
CPU-задача внутри процесса: хэширование, парсинг, обработка картинок |
Как устроено
- cluster. Главный процесс принимает соединения и раздаёт их рабочим процессам по кругу (round-robin; на Windows — нет). Память у процессов не общая.
- worker_threads. Потоки в одном процессе, у каждого свой изолят V8, куча и event loop. Данные передают через
postMessage(копия или передача владения) илиSharedArrayBuffer. - Документация Node прямо говорит: воркеры полезны для CPU-работы и почти не помогают с вводом-выводом.
Ловушки
- Воркер на каждый запрос — дорогой старт. Используют пул воркеров, например библиотеку piscina.
- Типичные утечки: глобальный
Mapкак кеш без ограничения, незакрытые таймеры, слушатели событий, которые никто не снимает. Последнее подсвечиваетMaxListenersExceededWarning.
Правило
Упёрлись в ввод-вывод — больше экземпляров. Упёрлись в CPU — воркеры или отдельный сервис.
Под капотом: память
- Node передаёт V8 лимит памяти с учётом cgroups. По умолчанию старое поколение кучи получает около половины памяти контейнера, с потолком в несколько гигабайт.
- Память процесса (RSS) — это куча плюс
Bufferвне кучи, код и стеки воркеров. Поэтому--max-old-space-sizeставят с запасом до лимита контейнера. С Node 24.7 есть--max-old-space-size-percentage. - Куча упёрлась в лимит —
FATAL ERROR: Reached heap limitсо стеком. Контейнер упёрся раньше — SIGKILL и код 137 без диагностики.
Как проверить в проде
--heapsnapshot-near-heap-limit=2пишет снимки кучи перед OOM,--heapsnapshot-signal=SIGUSR2— по сигналу. Снимок блокирует поток и требует памяти примерно вдвое больше кучи.- Два снимка сравнивают в DevTools (Memory → Comparison): ищут растущие типы объектов и цепочки удержания.
kill -USR1 <pid>включает инспектор у работающего процесса.--inspectслушает127.0.0.1:9229, наружу его не открывают.- Трейсы и метрики — OpenTelemetry: SDK загружают до приложения (
--importили--require). Библиотеки публикуют события черезdiagnostics_channel, без подмены модулей.
Компромиссы: версии
- Node 24 — Active LTS до 20 октября 2026, затем maintenance до апреля 2028. Node 22 — maintenance до апреля 2027. Node 20 не поддерживается с апреля 2026.
- С Node 27 выходит одна мажорная версия в год, в апреле, и каждая становится LTS в октябре. Нечётных Current-версий больше нет.
Итог: масштабируют процессами, память настраивают под лимит контейнера, утечки ловят снимками кучи, а версию берут из LTS.
// fib.mjs — ES-модуль: CPU-работа в worker_threadsimport { Worker, isMainThread, parentPort, workerData } from 'node:worker_threads';
if (isMainThread) { let ticks = 0; const timer = setInterval(() => ticks++, 10); // главный цикл продолжает работать const worker = new Worker(new URL(import.meta.url), { workerData: 38 }); worker.once('message', (result) => { clearInterval(timer); console.log(result, ticks > 10); // 39088169 true });} else { const fib = (n) => (n < 2 ? n : fib(n - 1) + fib(n - 2)); // считается в отдельном потоке parentPort.postMessage(fib(workerData));}Если посчитать fib(38) в главном потоке, setInterval не сработает ни разу, пока вычисление не закончится.
- Чем
clusterотличается отworker_threads? — cluster — процессы с общим портом и раздельной памятью; воркеры — потоки в одном процессе со своими изолятами. - Нужен ли cluster в Kubernetes? — Обычно нет: масштабируют подами, а оркестратор сам перезапускает и балансирует.
- Как найти утечку памяти? — Два-три heap snapshot под нагрузкой, сравнение в DevTools, поиск растущих объектов и их удерживающих ссылок.
- Почему контейнер убит с кодом 137, а
heap out of memoryв логе нет? — Лимит контейнера кончился раньше лимита кучи V8; уменьшить--max-old-space-size. - Какую версию Node ставить в прод? — LTS: Active или Maintenance; на октябрь 2026 — Node 24, после 28 октября — и Node 26.