Worker Threads: параллелизм

worker_threads — встроенный модуль Node.js (с v12), позволяющий создавать настоящие параллельные потоки в одном процессе для выполнения CPU-интенсивных задач без блокировки Event Loop.

Зачем нужно

Node.js однопоточный: тяжёлые вычисления (криптография, парсинг CSV, ML-инференс, обработка изображений) блокируют Event Loop и делают сервер неотзывчивым. Worker Threads решают эту проблему: каждый воркер — отдельный V8-экземпляр в отдельном потоке, данные передаются через postMessage. В отличие от child_process.fork, воркеры разделяют память через SharedArrayBuffer.

Где используется

  • Тяжёлые вычисления (парсинг больших файлов, SHA/bcrypt)
  • Обработка изображений (resize, watermark)
  • Статический анализ кода, линтинг
  • ML-инференс (ONNX Runtime, TensorFlow.js)
  • Параллельные задачи на CPU в server-side рендеринге

Основной контент

Базовый пример

// worker.js — код, выполняющийся в воркере
const { workerData, parentPort } = require('worker_threads');

function heavyComputation(n) {
  let result = 0;
  for (let i = 0; i < n; i++) result += Math.sqrt(i);
  return result;
}

const result = heavyComputation(workerData.count);
parentPort.postMessage({ result });
// main.js — создание воркера
const { Worker } = require('worker_threads');
const path = require('path');

function runWorker(data) {
  return new Promise((resolve, reject) => {
    const worker = new Worker(path.join(__dirname, 'worker.js'), {
      workerData: data
    });

    worker.on('message', resolve);
    worker.on('error', reject);
    worker.on('exit', (code) => {
      if (code !== 0) reject(new Error(`Worker exited with code ${code}`));
    });
  });
}

// Не блокирует Event Loop
const { result } = await runWorker({ count: 1e9 });
console.log('Result:', result);

Worker Pool — пул воркеров

// workerPool.js — переиспользование воркеров вместо создания новых
const { Worker } = require('worker_threads');
const os = require('os');

class WorkerPool {
  constructor(workerFile, size = os.cpus.length) {
    this.workers = ;
    this.queue = ;

    for (let i = 0; i < size; i++) {
      this._addWorker(workerFile);
    }
  }

  _addWorker(file) {
    const worker = new Worker(file);
    worker.on('message', (result) => {
      const { resolve } = worker._task;
      worker._task = null;
      this.workers.push(worker);
      resolve(result);
      this._processQueue;
    });
    this.workers.push(worker);
  }

  _processQueue {
    if (this.queue.length > 0 && this.workers.length > 0) {
      const { data, resolve, reject } = this.queue.shift();
      const worker = this.workers.pop();
      worker._task = { resolve, reject };
      worker.postMessage(data);
    }
  }

  run(data) {
    return new Promise((resolve, reject) => {
      this.queue.push({ data, resolve, reject });
      this._processQueue;
    });
  }
}

module.exports = WorkerPool;

SharedArrayBuffer — разделяемая память

// Создать разделяемый буфер (без копирования данных)
const sharedBuffer = new SharedArrayBuffer(4 * Int32Array.BYTES_PER_ELEMENT);
const sharedArray = new Int32Array(sharedBuffer);

const worker = new Worker('./worker.js', {
  workerData: { buffer: sharedBuffer }
});

// В worker.js:
// const { workerData } = require('worker_threads');
// const arr = new Int32Array(workerData.buffer);
// arr[0] = 42; // изменение видно в main.js

// Атомарные операции (thread-safe)
Atomics.add(sharedArray, 0, 1);  // атомарный инкремент
Atomics.load(sharedArray, 0);    // атомарное чтение

Inline Workers (без отдельного файла)

const { Worker, isMainThread, parentPort } = require('worker_threads');

if (isMainThread) {
  const worker = new Worker(__filename); // передаём тот же файл
  worker.on('message', console.log);
} else {
  // Код воркера
  parentPort.postMessage('Hello from worker!');
}

Частые ошибки

  • Создавать воркер на каждый запрос — создание Worker дорого; использовать пул воркеров
  • Передавать несериализуемые объектыpostMessage использует structured clone; функции, классы-инстансы — нельзя передать
  • Использовать Worker для I/O задач — для файлов и сети Node.js и так неблокирующий; Worker нужен только для CPU
  • Не обрабатывать 'exit' событие — без обработки падение воркера останется незамеченным

Связанные темы

Ресурсы


🎓 Источник: Atomics, SharedArrayBuffer, worker_threads в Node.js

  • 📅 2019-02-21 · YouTube · zLm8pnbxSII
  • Тезисы:
    • Worker очень похож на child_process/cluster — те же message, error, exit. Worker — наследник EventEmitter
    • isMainThread различает мастер и воркер: можно запускать тот же файл через new Worker(__filename)
    • Передача данных: обычный объект сериализуется V8 (structured clone), SharedArrayBuffer пробрасывается ссылкой без копирования
    • process.exit(code) внутри воркера НЕ завершает процесс — код попадает в worker.on('exit', ...) мастера
    • Воркер «висит» если у него есть подписки (event loop живой); без них — выходит сразу
    • worker.terminate — принудительное убийство, возвращает код выхода
    • Мастер ловит SIGTERM/Ctrl-C и сигналит воркерам выключиться, иначе процессы остаются
    • Внутри воркера parentPort — аналог EventEmitter, но с postMessage/on('message') вместо emit/on
  • Код:
    const { Worker, isMainThread, parentPort, workerData } = require('worker_threads');
    if (isMainThread) {
      const buffer = new SharedArrayBuffer(1024);
      const w = new Worker(__filename, { workerData: { buffer } });
      w.on('message', console.log);
    } else {
      const arr = new Uint8Array(workerData.buffer);
      setInterval( => parentPort.postMessage(arr[0]++), 100);
    }
    

🎓 Источник: Потоки и процессы в JavaScript и Node.js — Опять однопоточный

  • 📅 2026-02-23 · YouTube · pTnV6iNwAO0
  • Тезисы:
    • Различать CPU-bound («числодробилки», много циклов — блокируют) и I/O-bound (Node и так неблокирующий)
    • В Node два типа потоков: libuv worker pool (для I/O, не управляете) и userland worker_threads (создаёте сами для CPU)
    • Создание воркера дорого — нужен task runner / worker pool поверх; в Metarhia есть noeu-runtime и пул внутри Impress
    • В правильном раннере вместо ручного создания пишешь обычный await application.invoke(method, args, exclusive) — низкоуровневая машинерия прячется
    • exclusive: true — монопольный захват потока, обязательно для числодробилок
    • Между V8-рантаймами данные сериализуются (через MessagePort) — это всё равно быстрее межпроцессного обмена (Redis/Bull: данные «лазят 4 раза»)
    • SharedArrayBuffer исключает копирование вообще — но требует mutex/semaphore/Atomics
    • Worker threads — официально стабильны с Node 12, по меркам экосистемы это «сравнительно недавно»
  • Цитата:

    «Не хотелось бы ходить на отдельную машину, передавать туда эти запросы. <...> Тут threads — она вообще не покидает рамок одного процесса операционной системы.»