Блокировка vs Worker
Сравните паузу heartbeat и настоящий HTTP load test при CPU-работе в main thread и ограниченном Worker pool.
Временная шкала
Разбираем: Блокировка 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
Поток, где выполняются callbacks Node HTTP, Nest request lifecycle и основной JavaScript приложения.
CPU-bound
Работа, скорость которой ограничена вычислениями CPU, а не ожиданием внешнего ресурса.
Worker Thread
Отдельная JS-среда Node со своим V8 isolate. Может считать параллельно с main.
Message passing
Обмен данными между потоками через postMessage; обычные значения клонируются или передаются.
p95 / p99
Границы tail latency: 95% или 99% запросов завершились не медленнее этого значения. Среднее способно скрывать редкие длинные задержки.
Queue depth
Число CPU jobs, ожидающих свободный Worker. Рост очереди показывает, что admission rate превышает пропускную способность пула.
Что происходит по шагам
Каждый шаг соответствует наблюдаемому состоянию runtime.
- 01Heartbeat main
Таймер создаёт контрольные события примерно каждые 70 мс.
- 02Блокировка
Синхронный CPU-цикл не возвращает управление Event Loop.
- 03Накопленная задержка
Heartbeat не может прервать расчёт и приходит только после освобождения стека.
- 04Создание Worker
Та же вычислительная идея запускается в другом V8 isolate.
- 05Сообщение результата
Main продолжает heartbeat и позже получает асинхронный message callback.
- 06Настоящий HTTP load test
Отдельный генератор создаёт одинаковый смешанный профиль /fast и /cpu сначала для main-thread обработчика, затем для пула из двух Workers.
- 07Сравнение хвостов
RPS показывает throughput, а fast p95, общий p99, Event Loop delay и max queue объясняют цену каждого режима для разных запросов.
Где результат требует оговорки
Эти детали объясняют, почему похожий код иногда даёт другой trace.
Отзывчивее не значит быстрее
Опыт доказывает, что main thread остаётся доступным. Он не обещает меньшего времени вычисления: создание Worker, запуск isolate и передача данных имеют стоимость.
Потоки всё равно делят CPU
Worker работает отдельно от Event Loop сервера, но конкурирует за ядра, память и cgroup-лимит того же процесса/контейнера. При насыщенном CPU heartbeat может слегка дрожать.
Worker — не дочерний процесс
У Worker свой V8 isolate и JS heap, но тот же процесс ОС и возможность общей памяти. Изоляция слабее, чем у child_process, зато обмен обычно дешевле.
Большие сообщения имеют цену
Обычные значения проходят structured clone. Для крупных ArrayBuffer полезна передача владения через transfer list; SharedArrayBuffer требует собственной синхронизации.
Один короткий прогон — не capacity plan
Цифры зависят от CPU, ОС, версии Node и фоновой нагрузки. Эксперимент демонстрирует причинную связь; production-решение требует прогрева, нескольких прогонов и реалистичного профиля трафика.
Public control plane изолирован
На открытом сайте весь CPU-сценарий запускается в одноразовом child process с timeout, memory cap и ограниченной очередью. В private/dev он намеренно выполняется напрямую, чтобы владелец лаборатории мог наблюдать влияние на основной процесс.
Сначала разберитесь, какие части Node участвуют в выполнении.
Затем уберите служебные детали и рассмотрите только главную идею.
После этого сопоставьте модель с кодом, который создаёт live trace.
Минимальная модель без служебного кода
// Плохо для main thread:
heavyCpuWork(360);
// CPU-bound работу можно вынести:
const worker = new Worker('./cpu-worker.js');
worker.on('message', result => {
console.log(result);
});Полный код, который выполняет сценарий
Это не альтернативный пример: ниже показаны функции и файлы, используемые кнопкой запуска.
Код сформирован из реальной серверной функции. Для сценариев с отдельным процессом или Worker показаны все участвующие файлы.
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-поток открытым до завершения сценария.
Как учебная ошибка превращается в инцидент
Реалистичный сервис: исходный код, наблюдаемая проблема, исправление и причина, по которой оно работает.
Генерация 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 и выполняет фоновую работу.
Популярные заблуждения
Миф слева, корректная модель справа.
Если обернуть расчёт в async function, он уйдёт в другой поток.
async меняет форму результата на Promise, но синхронное тело всё равно работает в текущем потоке.
Promise сам по себе делает работу параллельной.
Promise описывает будущее значение; место выполнения работы определяется используемым API.
Нужно создавать новый Worker на каждый HTTP-запрос.
Создание isolate дорого; в production обычно используют ограниченный worker pool и очередь.
Worker гарантированно ускоряет любую задачу.
Короткая задача может стать медленнее из-за старта и обмена данными. Главный выигрыш этого опыта — отзывчивость main thread.
Ответьте своими словами
Если ответ получается объяснить без терминов из документации, ментальная модель уже начала складываться.
- Почему setInterval не может прервать while-loop?
- Какие данные дорого пересылать Worker через структурное клонирование?
- Почему общий p95 Worker-режима может быть высоким, хотя fast p95 и Event Loop delay стали ниже?