Skip to main content

Яка різниця між process i thread в Node.js?

Process та thread в Node.js - process (процес) це ізольований екземпляр програми з власним heap пам'яті; thread (потік) це одиниця виконання всередині процесу, яка розділяє цей heap.

Теорія

TL;DR

  • Process = окрема квартира з власною кухнею; thread = сусіди всередині, що ділять одну кухню
  • Node.js запускає один головний JS-потік на процес плюс пул із 4 C++ потоків libuv для блокуючого I/O
  • Процеси не ділять пам'ять і спілкуються через JSON по IPC (pipes); worker threads можуть ділити пам'ять через SharedArrayBuffer
  • CPU-задача: використовуй worker_threads (Node 10.5+); потрібна ізоляція або масштабування: запускай процеси
  • cluster.fork() коштує ~20мс (копіює V8 heap); new Worker() ~2мс (новий V8 isolate)

Швидкий приклад

javascript
// Node запускає один JS-потік за замовчуванням console.log('PID процесу:', process.pid); // наприклад, 12345 // Дочірній процес - ізольована пам'ять, окремий V8 heap const { fork } = require('child_process'); const child = fork('worker.js'); // ~20мс на старт child.send({ task: 'compute' }); child.on('message', result => console.log('Результат:', result)); // Worker thread - той самий процес, спільна пам'ять const { Worker } = require('worker_threads'); const worker = new Worker('./worker.js'); // ~2мс на старт worker.postMessage({ task: 'compute' });

Обидва варіанти дають паралельне виконання. Але вартість запуску і модель пам'яті принципово різні.

Головна відмінність

Node.js виконує JavaScript в одному головному потоці всередині OS-процесу. Event loop живе там само. Всі callbacks, promise-и та async/await крутяться на цьому одному потоці. libuv передає блокуючі I/O операції (читання файлів, DNS-запити) в пул із 4 C++ потоків у фоні. Коли ті закінчують роботу, libuv сигналізує event loop, і callback виконується на головному потоці. Цей пул не чіпає JS-стан.

Я особисто бачив цю помилку кілька разів у продакшені: розробники вважають, що Node обробляє CPU-роботу паралельно за замовчуванням, а потім один важкий цикл завішує весь сервер.

Для справжнього паралелізму в JS є два шляхи. worker_threads створює новий V8 isolate всередині того ж процесу. child_process запускає повноцінний окремий OS-процес із власним heap.

Коли що використовувати

  • CPU-важкі задачі (крипто, resize зображень, генерація PDF): worker_threads. Залишається в процесі, стартує за ~2мс, може ділити пам'ять через SharedArrayBuffer.
  • Горизонтальне масштабування на кілька ядер: модуль cluster. Запускає один процес на ядро CPU, всі слухають той самий порт.
  • Ізоляція від збоїв: child_process.fork(). Крах дочірнього процесу не вбиває батьківський.
  • I/O-важкий API-сервер (більшість Express-додатків): залишайся на головному потоці, libuv впорається сам.
  • Налаштування UV_THREADPOOL_SIZE: якщо активно використовуєш fs.readFile на 16-ядерній машині, дефолтні 4 потоки стають вузьким місцем. Встанови розмір рівним кількості ядер до будь-яких I/O операцій.

Таблиця порівняння

АспектProcess (child_process)Thread (worker_threads / libuv)
Пам'ятьІзольований heapСпільна в межах процесу (SharedArrayBuffer)
КомунікаціяIPC через JSON / pipespostMessage або спільна пам'ять
Вартість запуску~20мс (копія V8 heap)~2мс (новий V8 isolate)
Наслідки крашаДочірній крашається, батько виживаєНеоброблена помилка може вбити весь процес
Паралелізм JSТак, справжній multi-coreТак (workers); libuv пул тільки для C++
СценарійІзоляція, горизонтальне масштабуванняCPU-задачі, спільні дані всередині процесу

Як це працює всередині

V8 запускає JavaScript на одному потоці з одним call stack. Коли викликаєш fs.readFile, libuv кладе задачу в пул потоків через uv_queue_work(). Один із 4 потоків пулу виконує системний виклик. Коли він завершується, libuv сигналізує event loop, і callback виконується на фазі poll. JS-потік жодного разу не блокувався.

Worker threads влаштовані інакше: Node створює новий V8 isolate з власним event loop і власним call stack всередині того самого OS-процесу. Тому postMessage є основним каналом комунікації, а SharedArrayBuffer із Atomics дозволяє ділити пам'ять між потоками без копіювання даних.

Типові помилки

Блокування event loop CPU-задачею:

javascript
// Неправильно: заморожує всі запити на 10+ секунд app.get('/hash', (req, res) => { for (let i = 0; i < 1e9; i++) {} // один JS-потік, більше нічого не проходить res.send('done'); }); // Правильно: передай worker thread const { Worker } = require('worker_threads'); app.get('/hash', (req, res) => { const worker = new Worker('./hash-worker.js', { workerData: req.body }); worker.on('message', result => res.send(result)); });

Передача некерованих об'єктів через IPC:

javascript
// Неправильно: child.send() серіалізує в JSON - циклічні посилання кидають помилку const data = { user: req.user }; data.self = data; // циклічне посилання child.send(data); // Error: Converting circular structure to JSON // Правильно: передавай тільки прості серіалізовані дані child.send({ userId: req.user.id, action: 'compute' });

Відсутність перезапуску worker у cluster:

javascript
// Неправильно: worker падає при OOM без заміни, кластер зменшується під навантаженням cluster.fork(); // Правильно: слухай exit і перезапускай cluster.on('exit', (worker) => { console.log(`Worker ${worker.process.pid} впав, перезапускаємо`); cluster.fork(); });

Завищення UV_THREADPOOL_SIZE:

javascript
// Неправильно: 128 потоків на 4-ядерній машині вбиває продуктивність через context switching process.env.UV_THREADPOOL_SIZE = '128'; // Правильно: відповідай кількості ядер (встанови до будь-якого require) process.env.UV_THREADPOOL_SIZE = String(require('os').cpus().length);

Де зустрічається в реальних проектах

  • PM2: за замовчуванням запускає один процес на ядро CPU для zero-downtime деплою
  • NestJS microservices: child_process.fork() для ізольованих CPU-модулів (ML inference тощо)
  • Sharp (обробка зображень): використовує worker_threads всередині для паралельного resize
  • Express + cluster: стандартний патерн для обробки навантаження на multi-core серверах
  • workerpool: пул workers для динамічних CPU-задач на кшталт генерації PDF

Питання для поглиблення

Q: Чому fs.readFile використовує тільки 4 потоки навіть на 16-ядерній машині?
A: UV_THREADPOOL_SIZE за замовчуванням дорівнює 4 незалежно від кількості ядер. Встанови його рівним require('os').cpus().length (максимум 128) на початку процесу, до будь-яких I/O операцій. Після ініціалізації пулу змінити значення неможливо.

Q: Яка різниця у вартості між cluster.fork() і new Worker()?
A: fork копіює V8 heap (~20мс, більше пам'яті). Worker створює новий V8 isolate (~2мс, менше overhead). Для короткочасних паралельних задач workers дешевші.

Q: Чи можуть два worker threads ділити один event loop?
A: Ні. Кожен worker має власний event loop. Вони спілкуються через postMessage, що не блокує жодну зі сторін.

Q: Навіщо sticky mode у cluster для WebSocket?
A: За замовчуванням master кластера розподіляє з'єднання по-черзі (round-robin). WebSocket-сесії мають потрапляти до того ж worker process кожного разу. Sticky mode маршрутизує за IP клієнта для збереження стану.

Q: Senior killer: бенчмарк показує 16 ядер, але fs.readFile використовує тільки 4 потоки. Чому, і як це виправити?
A: UV_THREADPOOL_SIZE захардкоджено в 4 на рівні libuv і має бути перевизначений через змінну оточення до старту Node. Встанови UV_THREADPOOL_SIZE=16 в оточенні або як найперший рядок у файлі запуску, до будь-яких require. Після ініціалізації пулу зміна значення не має ефекту.

Приклади

Базовий: однаковий PID, різний threadId

javascript
const { Worker, isMainThread, threadId } = require('worker_threads'); if (isMainThread) { console.log('Головний потік ID:', threadId); // 0 console.log('PID процесу:', process.pid); // наприклад, 12345 const w = new Worker(__filename); w.on('message', msg => console.log(msg)); } else { // Виконується у worker - той самий PID, інший threadId const { parentPort } = require('worker_threads'); parentPort.postMessage(`Worker threadId: ${threadId}, PID: ${process.pid}`); // Виведе: Worker threadId: 1, PID: 12345 (той самий процес!) }

Головний потік і worker мають однаковий process.pid. Вони живуть в одному OS-процесі. Відрізняється тільки threadId. Це найпростіший спосіб побачити зв'язок між процесом і потоком.

Середній: Express-сервер із cluster для масштабування на кілька ядер

javascript
const cluster = require('cluster'); const os = require('os'); const express = require('express'); if (cluster.isPrimary) { const numCPUs = os.cpus().length; console.log(`Primary PID: ${process.pid}, форкаємо ${numCPUs} workers`); for (let i = 0; i < numCPUs; i++) { cluster.fork(); } // Без цього впалий worker не замінюється cluster.on('exit', (worker) => { console.log(`Worker ${worker.process.pid} впав, перезапускаємо`); cluster.fork(); }); } else { const app = express(); app.get('/', (req, res) => res.send(`Worker PID: ${process.pid}`)); app.listen(3000); console.log(`Worker ${process.pid} запущено`); }

Кожен worker - повноцінний Node.js процес із власним V8 heap. Всі вони слухають порт 3000. OS розподіляє вхідні з'єднання між ними. Якщо один worker падає, primary запускає новий.

Просунутий: спільна пам'ять між потоками через Atomics

javascript
const { Worker, isMainThread, workerData, parentPort } = require('worker_threads'); if (isMainThread) { const sharedBuffer = new SharedArrayBuffer(4); // 4 байти для одного Int32 const counter = new Int32Array(sharedBuffer); Atomics.store(counter, 0, 0); const worker = new Worker(__filename, { workerData: sharedBuffer }); let i = 0; const interval = setInterval(() => { Atomics.add(counter, 0, 1); // атомарний інкремент із головного потоку if (++i >= 10000) clearInterval(interval); }, 0); worker.on('message', final => { console.log('Фінальний лічильник:', final); // ~20000 (по 10000 з кожного боку) }); } else { const counter = new Int32Array(workerData); let i = 0; const interval = setInterval(() => { Atomics.add(counter, 0, 1); // атомарний інкремент із worker thread if (++i >= 10000) { clearInterval(interval); parentPort.postMessage(Atomics.load(counter, 0)); } }, 0); }

Без Atomics.add виникає race condition: два потоки читають і пишуть в одну позицію пам'яті одночасно, і фінальний результат буде менше 20000. Операції Atomics гарантовано атомарні на рівні процесора. Це єдиний безпечний спосіб змінювати спільну пам'ять між потоками в Node.js.

Коротка відповідь

Для співбесіди
Premium

Коротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.

Дочитали статтю?