RRUNTIME LABruntime observatoryNNEON · Статьи на 90 языкахПозвонить
CONNECTING
v24.19.0linux/x64
CPU-bound работа
04

Блокировка vs Worker

Сравните паузу heartbeat и настоящий HTTP load test при CPU-работе в main thread и ограниченном Worker pool.

PROCESS IDтекущий сервер
UPTIMEпосле запуска
LOOP DELAY P95perf_hooks
UTILIZATIONevent loop
HTTP ROUNDTRIPbrowser → server
LIVE TRACE

Временная шкала

ГОТОВ
0 ms
События появятся здесьЗапустите выбранный сценарий
#ВРЕМЯИСТОЧНИКСОБЫТИЕ
Ожидаю запуск эксперимента…
ГЛАВА 04
ПОДРОБНЫЙ РАЗБОР · ОТ БАЗЫ К КОДУ

Разбираем: Блокировка vs Worker

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

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

Главный поток — как единственный оператор экстренной линии. Если поручить ему на несколько минут считать огромную таблицу, он перестанет отвечать на звонки. Worker — второй оператор, которому можно передать вычисление, сохранив первую линию свободной.

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

Event Loop хорошо масштабирует ожидание I/O, но не ускоряет CPU-bound JavaScript. Синхронный расчёт занимает main thread. Worker Thread создаёт отдельный V8 isolate, call stack и Event Loop внутри того же процесса; данные передаются клонированием, transfer list или через SharedArrayBuffer.

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

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

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

01

Main Thread

Поток, где выполняются callbacks Node HTTP, Nest request lifecycle и основной JavaScript приложения.

02

CPU-bound

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

03

Worker Thread

Отдельная JS-среда Node со своим V8 isolate. Может считать параллельно с main.

04

Message passing

Обмен данными между потоками через postMessage; обычные значения клонируются или передаются.

05

p95 / p99

Границы tail latency: 95% или 99% запросов завершились не медленнее этого значения. Среднее способно скрывать редкие длинные задержки.

06

Queue depth

Число CPU jobs, ожидающих свободный Worker. Рост очереди показывает, что admission rate превышает пропускную способность пула.

02 · МЕХАНИКА

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

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

  1. 01
    Heartbeat main

    Таймер создаёт контрольные события примерно каждые 70 мс.

  2. 02
    Блокировка

    Синхронный CPU-цикл не возвращает управление Event Loop.

  3. 03
    Накопленная задержка

    Heartbeat не может прервать расчёт и приходит только после освобождения стека.

  4. 04
    Создание Worker

    Та же вычислительная идея запускается в другом V8 isolate.

  5. 05
    Сообщение результата

    Main продолжает heartbeat и позже получает асинхронный message callback.

  6. 06
    Настоящий HTTP load test

    Отдельный генератор создаёт одинаковый смешанный профиль /fast и /cpu сначала для main-thread обработчика, затем для пула из двух Workers.

  7. 07
    Сравнение хвостов

    RPS показывает throughput, а fast p95, общий p99, Event Loop delay и max queue объясняют цену каждого режима для разных запросов.

03 · КОНТЕКСТ

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

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

01

Отзывчивее не значит быстрее

Опыт доказывает, что main thread остаётся доступным. Он не обещает меньшего времени вычисления: создание Worker, запуск isolate и передача данных имеют стоимость.

02

Потоки всё равно делят CPU

Worker работает отдельно от Event Loop сервера, но конкурирует за ядра, память и cgroup-лимит того же процесса/контейнера. При насыщенном CPU heartbeat может слегка дрожать.

03

Worker — не дочерний процесс

У Worker свой V8 isolate и JS heap, но тот же процесс ОС и возможность общей памяти. Изоляция слабее, чем у child_process, зато обмен обычно дешевле.

04

Большие сообщения имеют цену

Обычные значения проходят structured clone. Для крупных ArrayBuffer полезна передача владения через transfer list; SharedArrayBuffer требует собственной синхронизации.

05

Один короткий прогон — не capacity plan

Цифры зависят от CPU, ОС, версии Node и фоновой нагрузки. Эксперимент демонстрирует причинную связь; production-решение требует прогрева, нескольких прогонов и реалистичного профиля трафика.

06

Public control plane изолирован

На открытом сайте весь CPU-сценарий запускается в одноразовом child process с timeout, memory cap и ограниченной очередью. В private/dev он намеренно выполняется напрямую, чтобы владелец лаборатории мог наблюдать влияние на основной процесс.

01
Теория

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

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

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

03
Runtime-код

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

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

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

src/demos.js · учебный фрагментJavaScript
// Плохо для main thread:
heavyCpuWork(360);

// CPU-bound работу можно вынести:
const worker = new Worker('./cpu-worker.js');
worker.on('message', result => {
  console.log(result);
});
05 · Runtime-код

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

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

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

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

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

const workerPath = new URL('./cpu-worker.js', import.meta.url);

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);
}

function startHeartbeat(emit, lane, label, durationMs) {
  const intervalMs = 70;
  const startedAt = performance.now();
  let expectedAt = startedAt + intervalMs;
  let number = 0;

  const interval = setInterval(() => {
    number += 1;
    const now = performance.now();
    const lag = Math.max(0, Math.round(now - expectedAt));
    emit(
      lane,
      lag > 80 ? 'warning' : 'heartbeat',
      `${label} #${number}; задержка ${lag} мс`,
    );
    expectedAt = now + intervalMs;
  }, intervalMs);

  return new Promise((resolve) => {
    setTimeout(() => {
      clearInterval(interval);
      resolve(Math.round(performance.now() - startedAt));
    }, durationMs);
  });
}

async function blockingComparison(emit) {
  emit('result', 'section', 'Часть A — CPU-работа в главном потоке');
  const mainHeartbeat = startHeartbeat(emit, 'main-thread', 'Пульс main', 650);

  await sleep(150);
  emit('call-stack', 'warning', 'Блокируем главный поток примерно на 360 мс');
  const iterations = blockMainThread(360);
  emit(
    'call-stack',
    'sync',
    `Главный поток снова свободен (${iterations.toLocaleString('ru-RU')} итераций)`,
  );
  await mainHeartbeat;

  emit('result', 'section', 'Часть B — та же работа в Worker Thread');
  const workerHeartbeat = startHeartbeat(emit, 'main-thread', 'Пульс main', 650);

  await sleep(150);
  emit('worker-thread', 'schedule', 'CPU-задача отправлена отдельному Worker');

  const workerResult = await new Promise((resolve, reject) => {
    const worker = new ThreadWorker(workerPath, {
      workerData: { durationMs: 360 },
    });

    worker.once('message', resolve);
    worker.once('error', reject);
    worker.once('exit', (code) => {
      if (code !== 0) reject(new Error(`Worker завершился с кодом ${code}`));
    });
  });

  emit(
    'worker-thread',
    'callback',
    `Worker закончил за ${workerResult.durationMs} мс; main всё это время отправлял пульс`,
  );
  await workerHeartbeat;

  emit(
    'result',
    'result',
    'Сравните разрыв heartbeat в части A с равномерными событиями в части B',
  );

  await runLoadComparison(emit);
}

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

06 · PRODUCTION-КЕЙСЫ

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

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

КЕЙС 01

Генерация PDF выполняется внутри Nest controller handler

NestJS-сервис формирует многостраничный счёт с графиками. Первоначальная реализация строит layout и сжимает изображения непосредственно внутри controller handler.

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

CPU-bound генерация занимает main isolate на секунды. Один большой документ увеличивает latency всех клиентов этого Node-процесса и может сорвать readiness probe.

ДОПРОБЛЕМНАЯ РЕАЛИЗАЦИЯ
@Controller('invoices')
export class InvoicesController {
  constructor(private readonly invoices: InvoicesService) {}

  @Get(':id/pdf')
  @Header('Content-Type', 'application/pdf')
  async download(@Param('id') id: string) {
    const invoice = await this.invoices.get(id);

    // CPU-bound layout + image compression
    const pdf = renderInvoicePdf(invoice);
    return new StreamableFile(pdf);
  }
}

async у handler не переносит синхронное тело renderInvoicePdf в другой поток. До возврата функции Event Loop этого isolate не обслуживает другие callbacks.

ПОСЛЕИСПРАВЛЕННАЯ РЕАЛИЗАЦИЯ
@Controller('invoices')
export class InvoicesController {
  constructor(
    @InjectQueue('invoice-export')
    private readonly exportQueue: Queue,
  ) {}

  @Post(':id/export')
  @HttpCode(HttpStatus.ACCEPTED)
  async startExport(@Param('id') id: string) {
    const job = await this.exportQueue.add(
      'invoice-pdf',
      { invoiceId: id },
      { jobId: `invoice-${id}` },
    );

    return {
      jobId: job.id,
      statusUrl: `/exports/${job.id}`,
    };
  }
}

// Отдельное Nest worker-приложение / container:
@Processor('invoice-export')
export class InvoiceExportProcessor extends WorkerHost {
  process(job: Job<{ invoiceId: string }>) {
    return renderAndStorePdf(job.data.invoiceId);
  }
}

HTTP-процесс только валидирует и ставит bounded job. CPU выполняется отдельным worker-процессом, который масштабируется и перезапускается независимо.

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

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

@Get / @Param
Nest регистрирует GET-маршрут, а @Param извлекает динамический :id из URL.
new StreamableFile(buffer)
Nest-обёртка для отправки Buffer или Stream как файла. Она не переносит вычисление самого файла в другой поток.
renderInvoicePdf(invoice)
Условная синхронная CPU-heavy функция: строит PDF и возвращает Buffer, всё это время блокируя текущий isolate.
queue.add(name, data, options)
BullMQ сохраняет job в Redis. data — сериализуемые входные данные, options задают id, retry и другие правила.
@InjectQueue(name)
Nest BullMQ внедряет Queue с указанным зарегистрированным именем через DI-контейнер.
@Processor / WorkerHost
Nest BullMQ регистрирует отдельный consumer очереди; метод process получает Job и выполняет фоновую работу.
ПОЧЕМУ ИСПРАВЛЕНИЕ РАБОТАЕТ

Worker Thread подходит короткой CPU-задаче с быстрым ответом, durable queue — длительной работе, которую нельзя потерять при рестарте HTTP-процесса. Выбор определяется SLA и требованием надёжности.

ЧТО БЫЛО ВИДНО В PRODUCTION
  • Провалы heartbeat совпадают с экспортом крупных документов.
  • p99 всех маршрутов растёт, хотя их собственные зависимости быстрые.
  • Перезапуск HTTP-процесса обрывает незавершённый PDF.
07 · НЕ ПЕРЕПУТАЙТЕ

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

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

МИФ

Если обернуть расчёт в async function, он уйдёт в другой поток.

НА САМОМ ДЕЛЕ

async меняет форму результата на Promise, но синхронное тело всё равно работает в текущем потоке.

МИФ

Promise сам по себе делает работу параллельной.

НА САМОМ ДЕЛЕ

Promise описывает будущее значение; место выполнения работы определяется используемым API.

МИФ

Нужно создавать новый Worker на каждый HTTP-запрос.

НА САМОМ ДЕЛЕ

Создание isolate дорого; в production обычно используют ограниченный worker pool и очередь.

МИФ

Worker гарантированно ускоряет любую задачу.

НА САМОМ ДЕЛЕ

Короткая задача может стать медленнее из-за старта и обмена данными. Главный выигрыш этого опыта — отзывчивость main thread.

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

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

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

  1. Почему setInterval не может прервать while-loop?
  2. Какие данные дорого пересылать Worker через структурное клонирование?
  3. Почему общий p95 Worker-режима может быть высоким, хотя fast p95 и Event Loop delay стали ниже?