Що таке 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
Швидкий приклад
// 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); // відправляємо результат головному потоку// 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
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 в пулі обов'язковою частиною реалізації.
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
// Неправильно - запускає цілий потік лише щоб прочитати файл
const worker = new Worker('./readFile.js');
// Правильно - event loop сам впорається
const data = await fs.promises.readFile('./file.txt');I/O за своєю природою асинхронний. Worker тут додає overhead без жодного виграшу.
2. Відсутність Atomics при роботі з SharedArrayBuffer
// Race condition - читання і запис можуть перетинатись
shared[0]++;
// Потокобезпечно
Atomics.add(shared, 0, 1);Два потоки, що одночасно збільшують одне значення без Atomics, губитимуть частину оновлень.
3. Створення нового worker на кожен запит
// Дорого - нова V8 isolate на кожен запит
app.get('/compute', (req, res) => {
const w = new Worker('./compute.js');
w.on('message', result => res.json({ result }));
});Використовуй пул. Під навантаженням 100 мс на запуск worker - це серйозна проблема.
4. Копіювання великих буферів через postMessage
// Копіює 50 МБ на кожен виклик
worker.postMessage({ data: hugeBuffer });
// Передай права власності (zero-copy)
worker.postMessage({ data: hugeBuffer }, [hugeBuffer]);
// hugeBuffer тепер відключений у головному потоці5. Відсутність обробки помилок
// Якщо 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-навантажена задача поза головним потоком
// 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);// 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
// 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); // потокобезпечне збільшення
}// 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 зображень
// 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, без запуску нових процесів кожного разу.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.