Блокировка vs Worker
Сравните паузу heartbeat при блокировке main thread с вычислением в Worker Thread.
Временная шкала
Разбираем: Блокировка 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
Поток, где работает Express, callbacks HTTP и основной JavaScript приложения.
CPU-bound
Работа, скорость которой ограничена вычислениями CPU, а не ожиданием внешнего ресурса.
Worker Thread
Отдельная JS-среда Node со своим V8 isolate. Может считать параллельно с main.
Message passing
Обмен данными между потоками через postMessage; обычные значения клонируются или передаются.
Что происходит по шагам
Каждый шаг соответствует наблюдаемому состоянию runtime.
- 01Heartbeat main
Таймер создаёт контрольные события примерно каждые 70 мс.
- 02Блокировка
Синхронный CPU-цикл не возвращает управление Event Loop.
- 03Накопленная задержка
Heartbeat не может прервать расчёт и приходит только после освобождения стека.
- 04Создание Worker
Та же вычислительная идея запускается в другом V8 isolate.
- 05Сообщение результата
Main продолжает heartbeat и позже получает асинхронный message callback.
Где результат требует оговорки
Эти детали объясняют, почему похожий код иногда даёт другой 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 требует собственной синхронизации.
Сначала разберитесь, какие части 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',
);
}Именно вызовы emit(...) превращаются в строки live trace. await и Promise удерживают HTTP-поток открытым до завершения сценария.
Популярные заблуждения
Миф слева, корректная модель справа.
Если обернуть расчёт в async function, он уйдёт в другой поток.
async меняет форму результата на Promise, но синхронное тело всё равно работает в текущем потоке.
Promise сам по себе делает работу параллельной.
Promise описывает будущее значение; место выполнения работы определяется используемым API.
Нужно создавать новый Worker на каждый HTTP-запрос.
Создание isolate дорого; в production обычно используют ограниченный worker pool и очередь.
Worker гарантированно ускоряет любую задачу.
Короткая задача может стать медленнее из-за старта и обмена данными. Главный выигрыш этого опыта — отзывчивость main thread.
Ответьте своими словами
Если ответ получается объяснить без терминов из документации, ментальная модель уже начала складываться.
- Почему setInterval не может прервать while-loop?
- Какие данные дорого пересылать Worker через структурное клонирование?