Запропонувати правкуПокращити цю статтюДопрацюйте відповідь до «Яка різниця між process i thread в Node.js?». Ваші зміни проходять модерацію перед публікацією.Потрібне підтвердженняКонтентЩо ви змінюєте🇺🇸EN🇺🇦UAПереглядЗаголовок (UA)Коротка відповідь (UA)**Process vs thread в Node.js**: process (процес) - ізольований екземпляр програми з власним heap; thread (потік) - одиниця виконання всередині процесу, яка розділяє цей heap. ```javascript console.log(process.pid); // однаковий для всіх потоків процесу const { Worker } = require('worker_threads'); new Worker('./task.js'); // новий потік, ~2мс, той самий процес ``` **Ключове:** Node запускає один JS-потік на процес. `worker_threads` для CPU-задач, `child_process` для ізоляції.Показується над повною відповіддю для швидкого нагадування.Відповідь (UA)Зображення**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 / 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-задачею:** ```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.Для рев’юераПримітка для модератора (необов’язково)Бачить лише модератор. Прискорює рев’ю.