Кластеризация и 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).
Сравнение
| Критерий | cluster | worker_threads |
| Единица | процесс | поток внутри процесса |
| Память | полностью изолирована | своя куча + возможен SharedArrayBuffer |
| Стоимость запуска | высокая (десятки МБ) | заметно ниже |
| Падение | не затрагивает остальных | может уронить весь процесс |
| Для чего | масштабирование обработки запросов | CPU-задачи: хеширование, парсинг, картинки |
Типичные ошибки кандидатов
- Считают
worker_threadsспособом масштабировать HTTP-нагрузку. - Думают, что воркеры кластера делят память и переменные.
- Не вспоминают про восстановление упавшего воркера через
cluster.on('exit'). - Создают новый
Workerна каждый запрос, не зная про пул. - Забывают, что cron внутри приложения при N процессах выполнится N раз.
Как ответить кратко
clusterподнимает по процессу на ядро, все слушают один порт, а главный процесс распределяет соединения — так масштабируют обработку запросов; в контейнерах ту же роль обычно играют реплики.worker_threads— потоки внутри процесса для CPU-задач, чтобы не блокировать event loop; под частые вызовы нужен пул. Главное следствие многопроцессности: память перестаёт быть общей, поэтому кэш, сессии, rate limit и cron должны жить снаружи — в Redis или базе.