Яка різниця між 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)
Швидкий приклад
// 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 / pipes | postMessage або спільна пам'ять |
| Вартість запуску | ~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-задачею:
// Неправильно: заморожує всі запити на 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:
// Неправильно: 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:
// Неправильно: worker падає при OOM без заміни, кластер зменшується під навантаженням
cluster.fork();
// Правильно: слухай exit і перезапускай
cluster.on('exit', (worker) => {
console.log(`Worker ${worker.process.pid} впав, перезапускаємо`);
cluster.fork();
});Завищення UV_THREADPOOL_SIZE:
// Неправильно: 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
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 для масштабування на кілька ядер
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
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.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.