Запропонувати правкуПокращити цю статтюДопрацюйте відповідь до «Що таке worker threads у Node.js?». Ваші зміни проходять модерацію перед публікацією.Потрібне підтвердженняКонтентЩо ви змінюєте🇺🇸EN🇺🇦UAПереглядЗаголовок (UA)Коротка відповідь (UA)**Worker threads** у Node.js запускають JavaScript у паралельних потоках всередині одного процесу, для CPU-навантажених задач, які інакше блокували б event loop. ```js const { Worker } = require('worker_threads'); const worker = new Worker('./compute.js', { workerData: { n: 1e8 } }); worker.on('message', result => console.log(result)); console.log('Головний потік вільний'); // виконується одразу ``` **Ключове:** на відміну від Cluster, який запускає окремі процеси, worker threads можуть ділити пам'ять через `SharedArrayBuffer` і спілкуються через `MessagePort` без накладних витрат на процеси.Показується над повною відповіддю для швидкого нагадування.Відповідь (UA)Зображення**Worker threads** у Node.js запускають JavaScript у паралельних потоках всередині одного процесу, для CPU-навантажених задач, які інакше блокували б [event loop](/questions/nodejs-event-loop). ## Теорія ### TL;DR - Worker threads живуть в одному процесі, але виконуються на окремих потоках ОС - Вони можуть ділити пам'ять через `SharedArrayBuffer`, на відміну від [Cluster](/questions/nodejs-cluster), який запускає окремі процеси - Підходять для CPU-bound задач: обробка зображень, криптографія, важкий парсинг - Для I/O задач (HTTP-запити, читання файлів) event loop справляється без потоків - Стали стабільними починаючи з Node.js 12 ### Швидкий приклад ```js // worker.js const { workerData, parentPort } = require('worker_threads'); let result = 0; for (let i = 0; i < workerData.n; i++) result += Math.sqrt(i); parentPort.postMessage(result); // відправляємо результат головному потоку ``` ```js // main.js const { Worker } = require('worker_threads'); const worker = new Worker('./worker.js', { workerData: { n: 1e8 } }); worker.on('message', result => console.log('Результат:', result)); console.log('Головний потік продовжує роботу'); // виводиться одразу ``` Головний потік не чекає. Поки worker рахує `Math.sqrt` 100 мільйонів разів, головний потік лишається вільним для запитів та інших подій. ### Чому event loop не достатній для CPU-роботи Node.js виконує JavaScript в одному потоці. Це свідоме рішення. Event loop ефективний для I/O, бо більшість очікування відбувається в thread pool libuv або на рівні ОС. JS-потік просто обробляє колбеки після завершення роботи. CPU-навантаження інше. Якщо запустити 4-секундний підрахунок хешу в головному потоці, кожен вхідний запит чекатиме ці 4 секунди. Worker threads дають такій роботі власний потік, щоб головний потік лишався вільним. ### Як worker threads працюють зсередини Кожен екземпляр `Worker` створює нову V8 isolate (окремий контекст JavaScript-рушія) з власним event loop. Worker працює повністю незалежно. У нього своя система модулів, глобальні змінні та купа пам'яті. Обмін даними між потоками відбувається через `MessagePort`. За замовчуванням дані клонуються через алгоритм structured clone. Передача об'єкта на 10 МБ копіює 10 МБ. Ця вартість накопичується під навантаженням. Щоб уникнути копіювання, можна передавати права власності на об'єкти через `{ transfer: [...] }`, або використовувати `SharedArrayBuffer`, який дає обом потокам доступ до однієї ділянки пам'яті. ### Спільна пам'ять через SharedArrayBuffer ```js const { Worker } = require('worker_threads'); const buffer = new SharedArrayBuffer(4); // 4 байти, спільна пам'ять const view = new Int32Array(buffer); view[0] = 0; const worker = new Worker(` const { workerData } = require('worker_threads'); const arr = new Int32Array(workerData.buffer); Atomics.store(arr, 0, 42); // потокобезпечний запис `, { eval: true, workerData: { buffer } }); worker.on('exit', () => { console.log(view[0]); // 42 }); ``` Зверни увагу на `Atomics.store`. Без нього два потоки, що одночасно пишуть в одну комірку пам'яті, дадуть race condition. `Atomics` забезпечує потокобезпечні операції над `SharedArrayBuffer`. ### Патерн Worker Pool Створювати новий worker на кожен запит дорого. V8 потрібно ініціалізувати нову isolate щоразу, а це 50-100 мс. У продакшені workers створюють заздалегідь і використовують повторно. Після розбору продакшн-інциденту, де відсутні обробники помилок залишали Promise зависати назавжди без жодного сигналу, я почав вважати error handlers в пулі обов'язковою частиною реалізації. ```js const { Worker } = require('worker_threads'); const os = require('os'); class WorkerPool { constructor(workerFile, size = os.cpus().length) { this.workers = Array.from({ length: size }, () => ({ worker: new Worker(workerFile), busy: false })); this.queue = []; } run(data) { return new Promise((resolve, reject) => { const free = this.workers.find(w => !w.busy); if (free) { this._execute(free, data, resolve, reject); } else { this.queue.push({ data, resolve, reject }); } }); } _execute(entry, data, resolve, reject) { entry.busy = true; entry.worker.postMessage(data); entry.worker.once('message', result => { entry.busy = false; resolve(result); if (this.queue.length > 0) { const next = this.queue.shift(); this._execute(entry, next.data, next.resolve, next.reject); } }); entry.worker.once('error', reject); } } ``` Розмір пулу за замовчуванням дорівнює `os.cpus().length`, бо саме стільки потоків CPU виконує паралельно. Більше workers, ніж ядер, додає overhead від перемикання контексту без виграшу в швидкодії. ### Worker threads vs Cluster | | Worker Threads | Cluster | |---|---|---| | Модель процесів | Один і той самий процес | Окремі процеси | | Пам'ять | Спільна через `SharedArrayBuffer` | Ізольована | | Комунікація | `MessagePort` (швидка, structured clone) | IPC (повільніша) | | Ізоляція від збоїв | Низька (збій worker може вплинути на процес) | Висока (збій процесу не поширюється) | | Підходить для | CPU-навантажених обчислень | Масштабування HTTP-серверів по ядрах | Cluster потрібний, коли кілька процесів мають обробляти вхідні з'єднання. Worker threads потрібні, коли одне завдання вимагає великих обчислень без блокування всього іншого. ### Часті помилки **1. Використання worker threads для I/O** ```js // Неправильно - запускає цілий потік лише щоб прочитати файл const worker = new Worker('./readFile.js'); // Правильно - event loop сам впорається const data = await fs.promises.readFile('./file.txt'); ``` I/O за своєю природою асинхронний. Worker тут додає overhead без жодного виграшу. **2. Відсутність Atomics при роботі з SharedArrayBuffer** ```js // Race condition - читання і запис можуть перетинатись shared[0]++; // Потокобезпечно Atomics.add(shared, 0, 1); ``` Два потоки, що одночасно збільшують одне значення без `Atomics`, губитимуть частину оновлень. **3. Створення нового worker на кожен запит** ```js // Дорого - нова V8 isolate на кожен запит app.get('/compute', (req, res) => { const w = new Worker('./compute.js'); w.on('message', result => res.json({ result })); }); ``` Використовуй пул. Під навантаженням 100 мс на запуск worker - це серйозна проблема. **4. Копіювання великих буферів через postMessage** ```js // Копіює 50 МБ на кожен виклик worker.postMessage({ data: hugeBuffer }); // Передай права власності (zero-copy) worker.postMessage({ data: hugeBuffer }, [hugeBuffer]); // hugeBuffer тепер відключений у головному потоці ``` **5. Відсутність обробки помилок** ```js // Якщо worker падає, цей Promise зависне назавжди const worker = new Worker('./task.js'); worker.on('message', resolve); // Завжди додавай обидва: worker.on('error', reject); worker.on('exit', code => { if (code !== 0) reject(new Error(`Worker завершився з кодом ${code}`)); }); ``` ### Де зустрічається в реальних проектах - `sharp` (обробка зображень) використовує workers для resize та конвертації форматів - Хешування через `bcrypt` в auth-сервісах переносять у workers, щоб перевірка пароля не затримувала інші запити - Webpack та esbuild використовують пули workers для паралельної трансформації модулів - `piscina` - популярна бібліотека для пулів worker threads, яка замінює саморобні реалізації - Jest запускає тестові файли у worker threads для паралельного виконання ### Follow-up питання **Q:** Яка різниця між `workerData` і `postMessage`? **A:** `workerData` - дані лише для читання, що передаються при створенні worker. `postMessage` надсилає повідомлення в обидва боки після запуску worker. `workerData` для початкової конфігурації, `postMessage` для поточної комунікації. **Q:** Чи можуть worker threads ділити спільний кеш `require`? **A:** Ні. Кожен worker має власну V8 isolate і кеш модулів. `require('lodash')` у worker завантажує lodash окремо від головного потоку. Саме тому запуск worker має свою вартість. **Q:** Що відбудеться, якщо worker кине необроблену помилку? **A:** Worker генерує подію `'error'` і завершує роботу. Якщо не слухати цю подію, помилка поширюється в головний потік як необроблений виняток і аварійно завершує процес. **Q:** Чим `SharedArrayBuffer` відрізняється від `postMessage` для передачі даних? **A:** `postMessage` копіює дані через structured clone. Буфер на 5 МБ потребує 5 МБ додаткової пам'яті і часу на клонування. `SharedArrayBuffer` дає обом потокам доступ до однієї фізичної пам'яті без копіювання, але вимагає `Atomics` для безпечного одночасного доступу. **Q:** Коли обирати worker thread, а коли child process? **A:** Worker threads - коли задача CPU-навантажена і потрібна комунікація з малими витратами або спільна пам'ять. Child process - коли потрібна повна ізоляція (збій в `child_process` не впливає на батьківський процес) або запуск зовнішніх програм. ## Приклади ### Базова CPU-навантажена задача поза головним потоком ```js // hash-worker.js const { workerData, parentPort } = require('worker_threads'); const crypto = require('crypto'); // Синхронне хешування - заблокувало б event loop у головному потоці const hash = crypto .createHash('sha256') .update(workerData.payload.repeat(10000)) .digest('hex'); parentPort.postMessage(hash); ``` ```js // server.js const { Worker } = require('worker_threads'); const http = require('http'); function hashInWorker(payload) { return new Promise((resolve, reject) => { const worker = new Worker('./hash-worker.js', { workerData: { payload } }); worker.on('message', resolve); worker.on('error', reject); worker.on('exit', code => { if (code !== 0) reject(new Error(`Worker завершився: ${code}`)); }); }); } http.createServer(async (req, res) => { const hash = await hashInWorker('user-password'); res.end(hash); }).listen(3000); ``` HTTP-сервер обробляє кожен запит без блокування. Кожне хешування виконується у власному потоці, головний потік лишається вільним. ### Потокобезпечний лічильник з SharedArrayBuffer та Atomics ```js // counter-worker.js const { workerData } = require('worker_threads'); const counter = new Int32Array(workerData.buffer); for (let i = 0; i < 1000; i++) { Atomics.add(counter, 0, 1); // потокобезпечне збільшення } ``` ```js // main.js const { Worker } = require('worker_threads'); const buffer = new SharedArrayBuffer(4); const counter = new Int32Array(buffer); const workers = Array.from({ length: 4 }, () => new Worker('./counter-worker.js', { workerData: { buffer } }) ); Promise.all( workers.map(w => new Promise(resolve => w.on('exit', resolve))) ).then(() => { console.log(counter[0]); // рівно 4000 }); ``` Чотири workers збільшують лічильник по 1000 разів кожен. `Atomics.add` гарантує, що жодне оновлення не загубиться через race condition. ### Worker pool для обробки запитів на resize зображень ```js // image-pool.js const { Worker } = require('worker_threads'); const os = require('os'); const path = require('path'); class ImagePool { constructor() { this.pool = Array.from({ length: os.cpus().length }, () => ({ worker: new Worker(path.join(__dirname, 'image-worker.js')), busy: false })); this.pending = []; } resize(imagePath, width, height) { return new Promise((resolve, reject) => { const free = this.pool.find(p => !p.busy); if (free) { this._dispatch(free, { imagePath, width, height }, resolve, reject); } else { this.pending.push({ data: { imagePath, width, height }, resolve, reject }); } }); } _dispatch(entry, data, resolve, reject) { entry.busy = true; entry.worker.postMessage(data); entry.worker.once('message', result => { entry.busy = false; resolve(result); if (this.pending.length > 0) { const next = this.pending.shift(); this._dispatch(entry, next.data, next.resolve, next.reject); } }); entry.worker.once('error', err => { entry.busy = false; reject(err); }); } } module.exports = new ImagePool(); ``` Один екземпляр пулу ділиться між усім застосунком. Запити на resize стають у чергу і обробляються в міру звільнення workers, без запуску нових процесів кожного разу.Для рев’юераПримітка для модератора (необов’язково)Бачить лише модератор. Прискорює рев’ю.