Worker threads и кластеризация
На одном потоке async I/O масштабируется, но CPU-работа блокирует event loop и голодит все запросы. Выгружай CPU-работу в пул worker_threads под число ядер; распределяй stateless-сервер по ядрам через cluster или реплики. Чистый I/O они не чинят.
API для превью работал нормально на 50 req/s и падал на 200. CPU при этом не упирался в 100% — он держался около 30% на 8-ядерной машине, — но p99 улетел за 6 секунд, и health-check начали валиться. Виноватой была одна строка: sharp(buf).resize(800).toBuffer() для части входов декодировался синхронно, и 400 мс трансформации картинки на главном потоке замораживали event loop на 400 мс — ни один другой запрос нельзя было распарсить, ни один таймер не мог сработать, ни один сокет нельзя было прочитать, и так для каждого конкурентного клиента сразу. Фиксом была не машина побольше; машина простаивала на 70%. Дело в том, что вся работа была втиснута в один из восьми доступных потоков, а остальные семь смотрели со стороны. К концу урока ты будешь точно знать, когда тянуться к пулу воркеров, когда — к cluster, а когда ни то ни другое вообще не нужно.
Стена одного потока
Node исполняет твой JavaScript на одном потоке. Для I/O это преимущество: await fetch() или await db.query() паркует работу в libuv или в ядре ОС, и event loop идёт дальше, так что один поток жонглирует тысячами конкурентных соединений почти бесплатно. Модель ломается ровно в тот миг, когда работа становится CPU-bound — хэширование пароля через bcrypt, трансформация картинки, gzip полезной нагрузки, парсинг 20-мегабайтного JSON-тела или JSON.parse над огромной строкой. Ничто из этого не уступает loop’у. Пока оно бежит, loop заблокирован: ни колбэков, ни таймеров, ни новых запросов — для каждого клиента, а не только для того, чей запрос это запустил.
Официальный совет — Don’t Block the Event Loop — это и есть вся суть. Синхронная CPU-задача на 200 мс — это не «на 200 мс медленнее для того юзера»; она добавляет 200 мс head-of-line задержки каждому запросу, уже стоящему в очереди за ней. Выходов ровно два, и они отображаются на две разные проблемы:
- Больше потоков в одном процессе —
worker_threads— для CPU-параллелизма с опциональной общей памятью. - Больше процессов —
clusterили N реплик за балансировщиком — чтобы задействовать все ядра для сетевого сервера.
Ни то ни другое не для I/O. Если эндпоинт медленный, потому что ждёт базу, воркер не поможет — узкое место это база, а loop вообще не был заблокирован.
worker_threads: настоящие потоки, изолированная память
worker_threads даёт настоящие потоки ОС внутри одного процесса Node, у каждого свой V8-изолят и свой event loop. Ты порождаешь поток со скриптом и передаёшь ему данные; он считает и шлёт результат назад.
// main.js
import { Worker } from "node:worker_threads";
function hashInWorker(password) {
return new Promise((resolve, reject) => {
const worker = new Worker("./hash-worker.js", { workerData: password });
worker.on("message", resolve);
worker.on("error", reject);
worker.on("exit", (code) => {
if (code !== 0) reject(new Error(`worker exited ${code}`));
});
});
}
// hash-worker.js
import { workerData, parentPort } from "node:worker_threads";
import bcrypt from "bcrypt";
parentPort.postMessage(bcrypt.hashSync(workerData, 12)); // CPU-работа, вне главного loopВсё определяют два факта. Первый: память изолирована. Воркер не делит переменные с родителем; каждая полезная нагрузка postMessage структурно клонируется, что стоит CPU и памяти пропорционально размеру — отправка 50-мегабайтного буфера так копирует 50 МБ. Для крупных бинарных данных есть два выхода: передать ArrayBuffer в transfer-листе сообщения (zero-copy — владение переходит, отправитель больше не может к нему обращаться) или поделиться им по-настоящему через SharedArrayBuffer (буфер в общей памяти, видимый всем потокам процесса) плюс Atomics для координации, так что несколько потоков читают и пишут одни и те же байты вообще без клонирования.
const ab = new ArrayBuffer(64 * 1024 * 1024);
worker.postMessage({ ab }, [ab]); // transfer-лист → zero-copy; ab здесь теперь detachedВторой: порождение воркера стоит реальных денег — новый поток, новый V8-изолят, мегабайты RAM и десятки миллисекунд старта. Порождать по одному на запрос — антипаттерн, который часто стоит дороже, чем работа, которую он выгружает. Правильная форма — пул: фиксированный набор долгоживущих воркеров, размером примерно по числу ядер CPU, которые тянут задачи из очереди. Библиотеки вроде piscina это реализуют; правило большого пальца — размер пула ≈ os.availableParallelism(), потому что больше потоков, чем ядер, только дёргает планировщик впустую.
Один Node-сервис делает две вещи: проксирует медленный upstream API (в основном ждёт I/O) и генерит PDF-отчёты (CPU-тяжёлые, ~300 мс каждый, нужен доступ на чтение к большому каталогу товаров в памяти). Надо масштабировать его на 8-ядерной машине. Что подходит лучше всего?
cluster: много процессов, один общий сокет
cluster решает другую проблему: масштабирование сетевого сервера по всем ядрам. Главный процесс форкает N рабочих процессов, и все они делят один слушающий сокет — входящие соединения распределяются между воркерами. На не-Windows-платформах схема по умолчанию — round-robin (каждое новое соединение отдаётся следующему воркеру по кругу) в главном процессе; иначе ОС распределяет через SO_REUSEPORT. Каждый воркер — это полноценный процесс Node со своей памятью и своим event loop, так что восемь воркеров честно используют восемь ядер для обработки запросов.
import cluster from "node:cluster";
import { availableParallelism } from "node:os";
import http from "node:http";
if (cluster.isPrimary) {
for (let i = 0; i < availableParallelism(); i++) cluster.fork();
cluster.on("exit", (worker) => cluster.fork()); // заменить умерший воркер
} else {
http.createServer((req, res) => res.end("handled by " + process.pid)).listen(3000);
}Определяющее ограничение: процессы не делят память. Сессионная мапа в памяти, локальный счётчик rate-limit или кэш уровня процесса существуют в каждом воркере отдельно — запрос, обработанный воркером A, не увидит состояние, которое выставил воркер B. Фикс — вынести общее состояние во внешнее хранилище (Redis, базу) и считать каждый воркер stateless. В современных деплоях cluster часто вытесняется запуском N реплик-контейнеров за балансировщиком (или процесс-менеджером вроде PM2), что даёт тот же параллелизм плюс рестарт, выкатку и масштабирование между хостами — дисциплина «stateless-процессы, общее хранилище» применима в любом случае.
| Механизм | Изоляция | Общая память? | Лучше всего для |
|---|---|---|---|
worker_threads | потоки в одном процессе | да — SharedArrayBuffer + Atomics или zero-copy transfer | CPU-работа с общими/крупными данными в процессе |
cluster | отдельные процессы, общий слушающий сокет | нет — обмен сообщениями или внешнее хранилище | масштабирование одного stateless-сервера по ядрам |
| реплики / контейнеры | отдельные процессы, часто отдельные хосты | нет — только внешнее хранилище | stateless-сервис за пределы одной машины, с выкаткой/рестартом |
▸Почему это работает
Почему бы не cluster-ить всё, чтобы избежать worker_threads? Потому что они решают ортогональные проблемы. cluster параллелит обработку соединений — он идеален, когда каждый запрос дёшев и тебе просто нужно больше их одновременно. Но если один запрос делает 300 мс CPU, cluster этому запросу не помогает: он всё равно блокирует loop своего воркера на 300 мс, останавливая остальные соединения, которые этот воркер обслуживает. worker_threads уносит этот один CPU-кусок с loop’а запроса целиком. Многие боевые сервисы используют оба: cluster/реплики для ширины, пул воркеров внутри каждого — для тяжёлых CPU-задач.
Выбор и режимы отказа
Дерево решений короткое. Работа I/O-bound? Не делай ничего — она уже async, и добавление потоков или процессов лишь добавит накладных расходов. Это CPU-работа, которой нужны общие или крупные данные в процессе? Пул worker_threads. Это масштабирование stateless-сетевого сервера по ядрам? cluster или реплики.
// Запуленные, не на каждый запрос — piscina держит размер ≈ ядра и переиспользует воркеры
import Piscina from "piscina";
const pool = new Piscina({ filename: "./resize-worker.js" });
app.post("/thumb", async (req, res) => {
const out = await pool.run(req.body); // CPU-работа бежит вне главного loop, на переиспользованном потоке
res.end(out);
});Повторяются два режима отказа. Первый: синхронная CPU-работа на главном потоке. Это и есть хук — машина, простаивающая на 30%, с p99 в 6 секунд, потому что один поток тащит всю тяжесть, пока семь ядер простаивают. Симптом: всплески лага event loop, таймауты и падающие health-check под нагрузкой, исчезающие при низком трафике. Фикс: выгрузить CPU-работу в пул воркеров. Второй: забыть, что воркеры cluster не делят память. Ты выкатываешь сессии в памяти или локальный кэш, в dev работает (один процесс), а в проде с восемью воркерами сессия юзера «случайно» пропадает каждый восьмой запрос, потому что его обработал другой воркер. Фикс: вынести это состояние в общее хранилище и держать воркеры stateless.
Что worker_threads делят с родителем, чего рабочие процессы cluster НЕ делят между собой?
Почему порождать воркеры из фиксированного пула, а не создавать новый Worker на каждый входящий запрос?
Эндпоинт /resize делает трансформацию картинки синхронно на главном loop и роняет p99 под нагрузкой. Расставь шаги переноса его на пул воркеров:
- 1 Убедись, что это CPU-bound: всплески лага event loop при простаивающем CPU, и I/O не узкое место
- 2 Вынеси трансформацию в отдельный worker-скрипт, который берёт вход и шлёт результат назад
- 3 Создай один фиксированный пул размером ≈ os.availableParallelism() (например, через piscina) на старте, не на запрос
- 4 Поменяй обработчик на await pool.run(input) вместо вычисления inline
- 5 Передавай крупные буферы через transfer/SharedArrayBuffer, чтобы избежать копий структурного клона, и нагрузочно протестируй p99, подтвердив, что loop остаётся свободным
- 01Машина простаивает на 30% по CPU, но p99 под нагрузкой — несколько секунд. Что почти наверняка происходит и каков фикс?
- 02Когда ты масштабируешь stateful-сервер через cluster (или реплики) и забываешь, что процессы не делят память, что ломается и как чинить?
Node исполняет твой JavaScript на одном потоке, что идеально для I/O — заавейченный fetch или запрос паркуется в libuv, и loop идёт дальше, — но смертельно для CPU-bound работы, потому что хэширование, трансформации картинок/видео, сжатие или парсинг огромного тела не уступают, и пока одно бежит, event loop заблокирован для каждого клиента, а не только для запроса, который его запустил (машина из хука с 30% простоя и p99 в 6 секунд). Выходов два, отображённых на две проблемы. worker_threads — это настоящие потоки в одном процессе с изолированной памятью, но общими байтами через SharedArrayBuffer/Atomics или zero-copy transfer ArrayBuffer; поскольку каждый postMessage структурно клонируется, а порождение потока стоит RAM и старта, ты используешь фиксированный пул размером по числу ядер, а не один воркер на запрос. cluster форкает N процессов, делящих один слушающий сокет (round-robin по умолчанию на не-Windows), чтобы задействовать все ядра для сетевого сервера, но процессы не делят память, так что сессии или кэши в памяти расходятся по воркерам, пока ты не вынесешь их в общее хранилище — то же правило управляет N репликами-контейнерами за балансировщиком, которые на практике часто вытесняют cluster. Решай по работе: чистому I/O не нужно ни то ни другое; CPU-работа с общими данными в процессе хочет пул воркеров; stateless-сетевой сервер хочет cluster или реплики. Теперь, когда видишь машину с 30% загрузкой CPU и p99 в несколько секунд, твой первый вопрос — не «нужны ли нам ещё серверы?», а «какая CPU-задача блокирует loop и в какой поток её переместить?»
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.