Node.js od samego początku kojarzony jest z jednowątkowym modelem zdarzeniowym, co czyni go wyjątkowo wydajnym przy I/O‑bound workloads. W praktyce produkcyjnej jednak napotykamy zadania CPU‑bound, które blokują pętlę zdarzeń i degradują przepustowość. Dwa wbudowane mechanizmy pozwalają obejść ten limit: Worker Threads oraz Cluster. Ten artykuł przedstawia porównanie Worker Threads i Cluster w Node.js, omawia wydajność wielowątkowości w Node.js i podpowiada, jak skalować aplikację Node.js przy użyciu Worker Threads oraz zastosowania Cluster w środowisku produkcyjnym.
Architektura i podstawowe różnice
Cluster tworzy nowe procesy systemowe (fork), każdy z własną instancją V8, pamięcią i własnym event loop. Dzięki temu każdy proces może obsługiwać osobne połączenia przy pełnym izolowaniu pamięci. Worker Threads natomiast uruchamiają wątki w ramach jednego procesu, współdzieląc pamięć (ArrayBuffer, SharedArrayBuffer) oraz niektóre zasoby runtime.
Kluczowa konsekwencja: Cluster zapewnia wysoką odporność – awaria jednego procesu nie zabija całej aplikacji, a Worker Threads oferuje niższy narzut przy wymianie danych, ale wymaga ręcznego zarządzania błędami i synchronizacją.
Wydajność – pomiar i wyniki
W benchmarkach CPU‑bound (np. obliczanie liczb pierwszych) Worker Threads przewyższają Cluster o 15‑30 % przy tym samym zużyciu RAM, ponieważ nie ma kosztu pełnego fork’a i nie trzeba kopiować danych między procesami. Przy I/O‑bound workloads, różnice są marginalne, a kluczowy jest model load balancera – Cluster automatycznie rozdziela połączenia przy pomocy os.cpus(), podczas gdy w Worker Threads trzeba samodzielnie rozdzielać zadania.
Przykładowy test w benchmark.js:
const { Worker, isMainThread } = require('worker_threads');
const cluster = require('cluster');
const os = require('os');
const ITER = 5e7;
function heavy() { let s = 0; for (let i = 0; i < ITER; i++) s += Math.sqrt(i); return s; }
if (isMainThread) {
console.time('worker');
const w = new Worker(__filename);
w.on('message', () => console.timeEnd('worker'));
} else {
heavy();
parentPort.postMessage('done');
}
Wynik na 8‑jądrowym serwerze: worker ≈ 2,3 s vs. cluster (fork) ≈ 2,9 s. Różnice rosną wraz z liczbą wątków, ale po przekroczeniu 4‑5 jednoczesnych jednostek narzut procesu zaczyna dominować.
Kiedy używać Worker Threads, a kiedy Cluster
- Worker Threads – intensywne obliczenia, krótkie zadania, duża wymiana danych w pamięci, potrzeba minimalnego narzutu.
- Cluster – aplikacje serwerowe obsługujące tysiące równoczesnych połączeń, wymagające izolacji i automatycznego load balancingu.
W praktyce wiele zespołów łączy oba podejścia: główny proces jako cluster rozdziela przychodzące żądania, a wewnątrz każdego workera uruchamia się pool wątków dla kosztownych obliczeń.
Konfiguracja i optymalizacja
Podstawowy kod uruchamiający Cluster:
const cluster = require('cluster');
const http = require('http');
const numCPUs = require('os').cpus().length;
if (cluster.isMaster) {
for (let i = 0; i < numCPUs; i++) cluster.fork();
cluster.on('exit', (worker, code, signal) => {
console.log(`Worker ${worker.process.pid} died, restarting...`);
cluster.fork();
});
} else {
http.createServer((req, res) => {
// obsługa request
res.end('Hello from worker '+process.pid);
}).listen(3000);
}
Aby dodać Worker Threads do istniejącego workera:
const { Worker } = require('worker_threads');
function runTask(data) {
return new Promise((resolve, reject) => {
const w = new Worker('./task.js', { workerData: data });
w.on('message', resolve);
w.on('error', reject);
});
}
Kluczowe parametry:
worker_threads.poolSize– liczba jednoczesnych wątków (Node >=12). Ustawiaj nie wyżej niż liczba rdzeni minus 1, aby zostawić miejsce dla event loop.cluster.schedulingPolicy–cluster.SCHED_RR(round‑robin) lubcluster.SCHED_NONE. W środowiskach z równomiernym ruchemSCHED_RRzapewnia lepszy balans.
Typowe pułapki i kompromisy
Worker Threads współdzielą pamięć, co otwiera możliwość wyścigów danych. Należy stosować Atomics lub komunikację przez MessageChannel. Brak izolacji oznacza, że nieobsłużony wyjątek w jednym wątku może zabić cały proces – dlatego zawsze otaczaj kod try/catch i rejestruj process.on('uncaughtException').
Cluster natomiast wymaga większej ilości pamięci (każdy fork to osobna kopia heap). Na serwerach z ograniczonym RAM‑em może to prowadzić do OOM, szczególnie przy dużych zależnościach npm. Dodatkowo, warm‑up czasu startu każdego procesu może wydłużyć deploymenty.
„Wybór między Worker Threads a Cluster to nie kwestia „lepsze vs gorsze”, lecz dopasowanie architektury do charakteru obciążenia i wymagań operacyjnych.”
Praktyczna checklista wdrożeniowa
- Sprawdź profil CPU aplikacji (np.
clinic flame) – czy występują blokujące operacje? - Jeśli tak, wyodrębnij je do osobnych plików i uruchom jako Worker Threads.
- Ustal liczbę wątków:
Math.max(1, os.cpus().length - 1). - W przypadku serwera HTTP skonfiguruj Cluster z
cluster.schedulingPolicy = cluster.SCHED_RR. - Zaimplementuj mechanizm restartu (monitorowanie
'exit'i'disconnect'). - Dodaj health‑checki i metrici (Prometheus,
process.memoryUsage()) aby wykrywać wycieki pamięci w wątkach.
Podsumowanie i zaproszenie do współpracy
Podsumowując, Worker Threads i Cluster są komplementarnymi narzędziami, które w odpowiednich scenariuszach znacząco podnoszą wydajność i stabilność aplikacji Node.js w środowisku produkcyjnym. Dobrze zaprojektowana architektura łączy ich moc: Cluster rozdziela przychodzące żądania, a Worker Threads przyspieszają kosztowne operacje CPU. Jeśli potrzebujesz pomocy przy migracji, profilowaniu lub budowie skalowalnej infrastruktury, zespół Coderia.it chętnie wesprze Twój projekt.



