Кластеризация и worker threads

«Node однопоточный — как использовать все 16 ядер сервера?» Вопрос-продолжение темы event loop, теперь уже с практическим уклоном.

cluster запускает несколько процессов, слушающих один порт, — это про масштабирование I/O-нагрузки. worker_threads создаёт потоки внутри одного процесса — это про вычисления.

Что проверяет интервьюер

Различаете ли вы два механизма (их постоянно путают) и понимаете ли последствия многопроцессности: что состояние в памяти перестаёт быть общим, а кэш, счётчики и сессии придётся выносить наружу.

cluster: несколько процессов на один порт

const cluster = require('node:cluster');
const os = require('node:os');

if (cluster.isPrimary) {
  const workers = os.availableParallelism();     // число доступных ядер
  console.log('запускаем', workers, 'воркеров');

  for (let i = 0; i < workers; i++) cluster.fork();

  cluster.on('exit', (worker, code, signal) => {
    console.log('воркер', worker.process.pid, 'умер, поднимаем новый');
    cluster.fork();                              // самовосстановление
  });
} else {
  require('./server');                           // обычное приложение
}

Главный процесс сам не обрабатывает запросы: он открывает слушающий сокет и раздаёт соединения воркерам (в Linux по умолчанию — round-robin). Каждый воркер — полноценный процесс Node со своей памятью и своим event loop. На практике вместо ручного кода чаще берут PM2 (pm2 start server.js -i max) или просто запускают несколько реплик контейнера — второй вариант сегодня доминирует, потому что перезапуском и балансировкой занимается оркестратор.

Что ломается при переходе на несколько процессов

  • Кэш в памяти становится частичным: у каждого процесса свой, попадания случайны, инвалидация не работает.
  • Сессии в MemoryStore перестают работать — пользователя «разлогинивает» при попадании на другой процесс.
  • Счётчики и rate limit в памяти считают каждый по-своему: лимит фактически умножается на число процессов.
  • Планировщики и cron запускаются в каждом процессе — задача выполнится N раз.
  • WebSocket-соединения живут в конкретном процессе: рассылка требует общей шины (например, Redis pub/sub).

Общий вывод, который стоит произнести: приложение должно быть stateless, а всё разделяемое состояние — в Redis или базе. Это то же требование, что и для горизонтального масштабирования, просто здесь оно проявляется на одной машине.

worker_threads: вычисления вне главного потока

// main.js
const { Worker } = require('node:worker_threads');

function runHeavy(data) {
  return new Promise((resolve, reject) => {
    const worker = new Worker('./heavy.js', { workerData: data });
    worker.on('message', resolve);
    worker.on('error', reject);
    worker.on('exit', (code) => {
      if (code !== 0) reject(new Error('worker exited with ' + code));
    });
  });
}

// heavy.js
const { parentPort, workerData } = require('node:worker_threads');
const result = countPrimes(workerData.limit);   // тяжёлый синхронный расчёт
parentPort.postMessage(result);

Пока воркер считает, главный поток спокойно обслуживает запросы. Данные между потоками передаются копированием (структурированное клонирование) либо разделяются через SharedArrayBuffer, если копировать дорого. Создание потока не бесплатно — десятки миллисекунд, — поэтому под частые задачи держат пул воркеров, а не создают поток на каждый запрос (готовое решение — piscina).

Сравнение

Критерийclusterworker_threads
Единицапроцесспоток внутри процесса
Памятьполностью изолированасвоя куча + возможен SharedArrayBuffer
Стоимость запускавысокая (десятки МБ)заметно ниже
Падениене затрагивает остальныхможет уронить весь процесс
Для чегомасштабирование обработки запросовCPU-задачи: хеширование, парсинг, картинки

Типичные ошибки кандидатов

  • Считают worker_threads способом масштабировать HTTP-нагрузку.
  • Думают, что воркеры кластера делят память и переменные.
  • Не вспоминают про восстановление упавшего воркера через cluster.on('exit').
  • Создают новый Worker на каждый запрос, не зная про пул.
  • Забывают, что cron внутри приложения при N процессах выполнится N раз.

Как ответить кратко

cluster поднимает по процессу на ядро, все слушают один порт, а главный процесс распределяет соединения — так масштабируют обработку запросов; в контейнерах ту же роль обычно играют реплики. worker_threads — потоки внутри процесса для CPU-задач, чтобы не блокировать event loop; под частые вызовы нужен пул. Главное следствие многопроцессности: память перестаёт быть общей, поэтому кэш, сессии, rate limit и cron должны жить снаружи — в Redis или базе.

Проверьте себя
1. Чем cluster отличается от worker_threads?
AНичем, это два названия одного API
Bcluster создаёт отдельные процессы для обработки запросов, worker_threads — потоки внутри процесса для вычислений
Ccluster работает только в Linux, worker_threads — везде
Dworker_threads поднимает несколько процессов, а cluster — потоки
2. Что перестанет работать корректно после запуска приложения в четырёх процессах кластера?
AКэш и rate limit, хранящиеся в памяти процесса
BОтдача статических файлов
CПодключение к PostgreSQL
DЛогирование в stdout
3. Почему не стоит создавать новый Worker на каждый входящий запрос?
ANode запрещает больше одного воркера одновременно
BЗапуск потока стоит десятки миллисекунд и памяти — под частые задачи нужен пул воркеров
CВоркеры не умеют возвращать результат в главный поток
DКаждый воркер открывает свой порт