ГЛАВА 08
ПОДРОБНЫЙ РАЗБОР · ОТ БАЗЫ К КОДУ

Разбираем: Node.js против Java, Go и Python

Этот раздел можно читать до запуска опыта. После теории вернитесь к live trace и сопоставьте каждый шаг с реальным событием.

СНАЧАЛА ПРОСТЫМИ СЛОВАМИ

Node похож не на «одного медленного работника», а на диспетчера, который не стоит рядом с каждым ожидающим заказом. Пока база данных, сеть или диск выполняют работу, главный JavaScript-поток может обслуживать другие готовые события. Это экономит потоки именно на ожидании I/O, но не превращает тяжёлый JavaScript в параллельный.

ТЕХНИЧЕСКАЯ ОСНОВА

Один Node-процесс содержит V8 isolate с главным JavaScript Event Loop, служебные потоки V8 и возможности libuv. Сокеты обычно наблюдаются механизмами готовности ОС, часть fs/crypto/DNS выполняется в ограниченном libuv pool, а CPU-bound JavaScript можно вынести в Worker Threads или процессы. Поэтому фраза «Node однопоточный» описывает выполнение JavaScript в одном isolate, а не весь процесс.

Зачем это знатьНа senior-собеседовании важно не объявить один runtime победителем, а связать модель конкурентности с workload: временем ожидания I/O, CPU-стоимостью, памятью на конкурентную задачу, моделью отказов и удобством профилирования.
ГДЕ ВЫПОЛНЯЕТСЯ РАБОТА
01ВАШ JS-КОДfunctions · callbacks
02NODE APIsfs · crypto · timers
03V8 + LIBUVheap · loop · pool
04ОПЕРАЦИОННАЯ СИСТЕМАI/O · threads · memory
01 · СЛОВАРЬ

Термины этого эксперимента

Сначала поймите слова — затем порядок выполнения.

01

Concurrency

Несколько задач находятся в работе и продвигаются с перекрытием по времени; это ещё не означает одновременное выполнение инструкций.

02

Parallelism

Инструкции действительно выполняются одновременно на нескольких ядрах или аппаратных потоках.

03

I/O-bound

Нагрузка, где большая часть времени уходит на ожидание сети, диска, базы данных или другого внешнего ресурса.

04

CPU-bound

Нагрузка, ограниченная вычислениями: сериализацией, компрессией, изображениями, криптографией или большими циклами.

05

V8 isolate

Изолированная среда V8 со своим heap и JavaScript execution state. Worker Thread создаёт отдельный isolate.

06

Throughput

Количество успешно обработанных операций за единицу времени; не то же самое, что latency одного запроса.

02 · МЕХАНИКА

Что происходит по шагам

Каждый шаг соответствует наблюдаемому состоянию runtime.

  1. 01
    Классифицируйте работу

    Отделите короткий JavaScript от сетевого ожидания, нативной операции и тяжёлого CPU-кода.

  2. 02
    Node регистрирует I/O

    Главный поток передаёт ожидание ОС/libuv и возвращается к готовым callbacks.

  3. 03
    Готовность возвращает continuation

    Когда операция готова, callback или Promise continuation ждёт свободного JavaScript-стека.

  4. 04
    CPU меняет картину

    Долгий синхронный JavaScript занимает isolate и задерживает все его соединения.

  5. 05
    Выбирается граница параллелизма

    Worker Threads подходят для CPU и общей памяти, процессы — для изоляции, очередь jobs — для распределённой работы.

  6. 06
    Модель проверяется метриками

    Сравнивайте throughput, p95/p99 latency, Event Loop delay, CPU, память и поведение под перегрузкой.

03 · КОНТЕКСТ

Где результат требует оговорки

Эти детали объясняют, почему похожий код иногда даёт другой trace.

01

Java — не только тяжёлый thread-per-request

Java поддерживает platform threads, неблокирующий NIO/reactive-подход и virtual threads. Virtual thread при blocking I/O может освободить carrier OS thread; это масштабирует привычный синхронный стиль, но не ускоряет CPU-задачу.

02

Go — не «по ОС-потоку на goroutine»

Лёгкие goroutines мультиплексируются scheduler-ом Go на набор OS threads. Параллелизм регулируется runtime и доступными ядрами, а блокирующие операции интегрируются с scheduler-ом.

03

Python зависит от реализации и сборки

asyncio также использует Event Loop для I/O. Обычная CPython-сборка сериализует большую часть Python bytecode через GIL, но multiprocessing, native extensions и опциональная free-threaded сборка меняют границы параллелизма.

04

Node эффективен не для всего

Много ожидающих сокетов — сильный сценарий. Большая синхронная JSON-сериализация или расчёт на каждом запросе может превратить один isolate в bottleneck.

05

Архитектура важнее языка в вакууме

Connection pool, backpressure, алгоритм, batching, кэш, число процессов и лимиты часто сильнее влияют на результат, чем название runtime.

01
Теория

Сначала разберитесь, какие части Node участвуют в выполнении.

02
Упрощённый код

Затем уберите служебные детали и рассмотрите только главную идею.

03
Runtime-код

После этого сопоставьте модель с кодом, который создаёт live trace.

04 · Упрощённый код

Минимальная модель без служебного кода

src/demos.js · учебный фрагментJavaScript
const waits = Array.from({ length: 24 }, (_, index) =>
  delay(25 + (index % 4) * 10)
);

// Один Event Loop координирует все pending waits:
await Promise.all(waits);

// Но этот CPU-код занимает главный JavaScript isolate:
heavyCpuWork(180);
05 · Runtime-код

Полный код, который выполняет сценарий

Это не альтернативный пример: ниже показаны функции и файлы, используемые кнопкой запуска.

ФАКТИЧЕСКИЙ SOURCE

Код сформирован из реальной серверной функции. Для сценариев с отдельным процессом или Worker показаны все участвующие файлы.

src/demos.js
сценарий72 строк
import { performance } from 'node:perf_hooks';

const sleep = (ms) =>
  new Promise((resolve) => setTimeout(resolve, ms));

function blockMainThread(durationMs) {
  const startedAt = performance.now();
  let iterations = 0;

  // Намеренная блокировка для учебного сценария. В production так делать нельзя.
  while (performance.now() - startedAt < durationMs) {
    iterations += Math.sqrt((iterations % 10_000) + 1);
  }

  return Math.round(iterations);
}

async function runtimeModelsComparison(emit) {
  const taskCount = 24;
  const startedAt = performance.now();
  emit(
    'main-js',
    'schedule',
    `Регистрируем ${taskCount} I/O-подобных ожиданий без ${taskCount} JavaScript-потоков`,
  );

  const waits = Array.from({ length: taskCount }, (_, index) =>
    sleep(25 + (index % 4) * 10).then(() => index),
  );
  emit(
    'main-js',
    'sync',
    `Все ожидания зарегистрированы за ${(
      performance.now() - startedAt
    ).toFixed(1)} мс; стек снова свободен`,
  );

  const results = await Promise.all(waits);
  emit(
    'event-loop',
    'result',
    `${results.length} continuations выполнены тем же Event Loop после готовности таймеров`,
  );

  const timerStartedAt = performance.now();
  const delayedTimer = new Promise((resolve) => {
    setTimeout(() => {
      emit(
        'event-loop',
        'warning',
        `Таймер после CPU-блокировки вошёл в стек через ${Math.round(
          performance.now() - timerStartedAt,
        )} мс`,
      );
      resolve();
    }, 20);
  });

  emit(
    'main-js',
    'warning',
    'Теперь 180 мс CPU-bound JavaScript блокируют один главный isolate',
  );
  blockMainThread(180);
  await delayedTimer;

  emit(
    'architecture',
    'result',
    'Вывод: дешёвое ожидание I/O даёт throughput, но CPU-параллелизм требует Worker, процессов или другого runtime-подхода',
  );
}

Live trace инструментирован самим приложением: строки и timestamps фиксируются при реальных вызовах emit(...), а названия source/lane задаёт сценарий. Это не profiler V8/libuv и не прямой снимок их внутренних очередей. await и Promise удерживают HTTP-поток открытым до завершения сценария.

06 · PRODUCTION-КЕЙСЫ

Как учебная ошибка превращается в инцидент

Реалистичный сервис: исходный код, наблюдаемая проблема, исправление и причина, по которой оно работает.

КЕЙС 01

CPU-heavy scoring ошибочно размещён в Node API

Fintech API вычисляет риск по большому набору транзакций. Команда выбрала Node за высокий I/O throughput и предположила, что async handler автоматически использует несколько CPU cores.

КОНТЕКСТ ИНЦИДЕНТА

Синхронный scoring блокирует один V8 isolate. Реплики помогают распределять запросы, но каждый тяжёлый запрос по-прежнему блокирует свой экземпляр и ухудшает tail latency.

ДОПРОБЛЕМНАЯ РЕАЛИЗАЦИЯ
@Controller('risk')
export class RiskController {
  constructor(private readonly ledger: LedgerService) {}

  @Post('score')
  async score(@Body() input: RiskScoreDto) {
    const history = await this.ledger.history(input.userId);

    // 700-1200 ms CPU in the main isolate
    const score = calculateRisk(history);

    return { score };
  }
}

async оптимизирует ожидание ledger I/O, но не распараллеливает JavaScript calculation. Модель Node эффективна для ожидания, а не для длинного CPU callback.

ПОСЛЕИСПРАВЛЕННАЯ РЕАЛИЗАЦИЯ
@Injectable()
export class RiskService {
  constructor(
    private readonly ledger: LedgerService,
    @Inject(RISK_POOL)
    private readonly workerPool: RiskWorkerPool,
  ) {}

  async score(input: RiskScoreDto) {
    const history = await this.ledger.history(input.userId);

    return this.workerPool.run({
      history,
      modelVersion: CURRENT_MODEL,
    });
  }
}

@Controller('risk')
export class RiskController {
  constructor(private readonly risk: RiskService) {}

  @Post('score')
  async score(@Body() input: RiskScoreDto) {
    return { score: await this.risk.score(input) };
  }
}

Вычисление получает отдельный V8 isolate через bounded Worker pool. Main Event Loop продолжает принимать HTTP, а очередь pool-а создаёт контролируемый backpressure.

ФУНКЦИИ И КОНСТРУКЦИИ

Что делают непривычные вызовы из обоих фрагментов кода.

ledger.history(userId)
Условный I/O-вызов истории операций. await освобождает stack, пока БД или внешний сервис готовит ответ.
calculateRisk(history)
Условная синхронная CPU-heavy функция. async-контроллер не делает её автоматически параллельной.
workerPool.run(data)
Передаёт сериализуемое задание свободному Worker Thread и возвращает Promise с результатом.
maxQueue
Верхняя граница ожидающих worker-задач. При переполнении сервис должен быстро отказать, а не бесконечно расходовать память.
@Inject(RISK_POOL)
Nest внедряет pool по DI-token, поэтому его размер, lifecycle и тестовую замену контролирует модуль.
ПОЧЕМУ ИСПРАВЛЕНИЕ РАБОТАЕТ

Выбор runtime зависит от workload. Go/Java могут предложить другую модель потоков, но CPU всё равно конечен; Python/Node часто выносят расчёты в processes. Архитектура важнее ярлыка языка.

ЧТО БЫЛО ВИДНО В PRODUCTION
  • Высокий ELU при нормальной latency базы и сети.
  • Один запрос создаёт длинный провал в Event Loop heartbeat.
  • Добавление replicas улучшает throughput, но не latency одного scoring.
07 · НЕ ПЕРЕПУТАЙТЕ

Популярные заблуждения

Миф слева, корректная модель справа.

МИФ

Node.js полностью однопоточный.

НА САМОМ ДЕЛЕ

Обычно один поток исполняет JavaScript конкретного isolate, но процесс использует служебные потоки, libuv pool и при необходимости Worker Threads.

МИФ

Асинхронность автоматически использует все CPU-ядра.

НА САМОМ ДЕЛЕ

Она позволяет не блокировать поток ожиданием. CPU-параллелизм требует нескольких isolates/процессов или нативного параллельного API.

МИФ

Java всегда создаёт дорогой OS thread на запрос.

НА САМОМ ДЕЛЕ

Это лишь одна модель. Есть NIO, reactive frameworks и virtual threads, которые мультиплексируются JVM.

МИФ

У Python всегда и при любых условиях нет параллельных threads.

НА САМОМ ДЕЛЕ

Это слишком широкое утверждение: важны CPython/GIL, free-threaded build, native code и выбранная модель multiprocessing/asyncio.

МИФ

Высокий throughput означает низкую latency.

НА САМОМ ДЕЛЕ

Система может обрабатывать много запросов в секунду и одновременно иметь плохие хвостовые p95/p99 задержки.

08 · САМОПРОВЕРКА

Ответьте своими словами

Если ответ получается объяснить без терминов из документации, ментальная модель уже начала складываться.

  1. Почему десять тысяч открытых сокетов и десять тысяч активных CPU-расчётов — принципиально разные нагрузки?
  2. Когда вы выберете Worker Thread, а когда отдельный процесс или BullMQ Worker?
  3. Какие метрики нужны, чтобы доказать преимущество модели, а не повторить рекламный тезис?