Skip to main content

Що таке worker threads у Node.js?

Worker threads у Node.js запускають JavaScript у паралельних потоках всередині одного процесу, для CPU-навантажених задач, які інакше блокували б event loop.

Теорія

TL;DR

  • Worker threads живуть в одному процесі, але виконуються на окремих потоках ОС
  • Вони можуть ділити пам'ять через SharedArrayBuffer, на відміну від 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 ThreadsCluster
Модель процесівОдин і той самий процесОкремі процеси
Пам'ятьСпільна через 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, без запуску нових процесів кожного разу.

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

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

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

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