Learning Book

Паттерны и решения

Worker Pool

Создание воркера — дорого (~5ms для worker_threads, ~30-100ms для процесса). Создавать воркер на каждую задачу — антипаттерн. Worker pool — N предсозданных воркеров с очередью задач.

class WorkerPool {
  #workers = []
  #queue = []
  #free = []

  constructor(workerFile, size) {
    for (let i = 0; i < size; i++) {
      const worker = new Worker(workerFile)
      this.#workers.push(worker)
      this.#free.push(worker)
    }
  }

  exec(data) {
    return new Promise((resolve, reject) => {
      const task = { data, resolve, reject }
      const worker = this.#free.pop()
      if (worker) {
        this.#run(worker, task)
      } else {
        this.#queue.push(task) // все заняты — в очередь
      }
    })
  }

  #run(worker, task) {
    const onMessage = (result) => {
      worker.removeListener('message', onMessage)
      worker.removeListener('error', onError)
      task.resolve(result)
      this.#release(worker)
    }
    const onError = (err) => {
      worker.removeListener('message', onMessage)
      worker.removeListener('error', onError)
      task.reject(err)
      this.#release(worker)
    }
    worker.on('message', onMessage)
    worker.on('error', onError)
    worker.postMessage(task.data)
  }

  #release(worker) {
    const next = this.#queue.shift()
    if (next) {
      this.#run(worker, next) // есть задача в очереди — сразу берём
    } else {
      this.#free.push(worker) // очередь пуста — воркер свободен
    }
  }

  destroy() {
    this.#workers.forEach(w => w.terminate())
  }
}

Использование:

const pool = new WorkerPool('./compute.js', 4)

const results = await Promise.all([
  pool.exec({ n: 40 }),
  pool.exec({ n: 41 }),
  pool.exec({ n: 42 }),
  pool.exec({ n: 43 }),
  pool.exec({ n: 44 }), // 5-я задача ждёт в очереди (пул из 4)
])

pool.destroy()

Чеклист: нужен ли воркер?

1. Задача CPU-bound?
   Нет → async/await достаточно
   Да → продолжай ↓

2. Задача занимает > 16ms?
   Нет → не стоит (overhead > выигрыш)
   Да → продолжай ↓

3. Задача выполняется часто?
   Нет → одиночный воркер
   Да → worker pool

4. Нужна общая память?
   Нет → postMessage / Transferable
   Да → SharedArrayBuffer + Atomics

5. Нужна полная изоляция (crash safety)?
   Нет → worker_threads
   Да → child_process

Правило 16ms: один кадр при 60fps = ~16ms. Если вычисление занимает меньше — воркер не нужен, overhead создания и передачи данных съест выигрыш.

Готовые решения

piscina (Node.js)

Production-ready worker pool для Node.js. Используется в Node.js core, Jest, webpack.

import Piscina from 'piscina'

const pool = new Piscina({
  filename: './worker.js',
  minThreads: 2,
  maxThreads: 8,
  idleTimeout: 30000, // убить неактивный поток через 30с
})

const result = await pool.run({ data: [1, 2, 3] })
// worker.js
export default function({ data }) {
  return data.reduce((a, b) => a + b, 0)
}

Преимущества piscina:

  • Автоматическое управление пулом (min/max threads)
  • Очередь задач с приоритетами
  • Поддержка Transferable и SAB
  • Cancellation через AbortController
  • Backpressure — не даёт переполнить очередь

Comlink (браузер)

Библиотека от Google Chrome Labs. Превращает postMessage API в обычные вызовы функций через Proxy:

// worker.js
import * as Comlink from 'comlink'

const api = {
  fibonacci(n) {
    if (n <= 1) return n
    return api.fibonacci(n - 1) + api.fibonacci(n - 2)
  },

  processImage(imageData) {
    // тяжёлая обработка
    return result
  }
}

Comlink.expose(api)
// main.js
import * as Comlink from 'comlink'

const api = Comlink.wrap(new Worker('worker.js'))

// Вызываем как обычную async-функцию
const result = await api.fibonacci(42)
const processed = await api.processImage(data)

Comlink убирает весь boilerplate с postMessage, id-отслеживанием и сериализацией.

workerpool (универсальный)

Работает и в Node.js, и в браузере:

import workerpool from 'workerpool'

const pool = workerpool.pool('./worker.js', { maxWorkers: 4 })

const result = await pool.exec('fibonacci', [42])
pool.terminate()

Сравнение библиотек

БиблиотекаСредаPoolTransferableSABTypeScript
piscinaNode.js
ComlinkБраузер
workerpoolОба
tinypoolNode.js

Антипаттерны

1. Воркер на каждый запрос

// ❌ Создаёт и убивает воркер на каждый вызов
async function compute(data) {
  const worker = new Worker('./compute.js')
  return new Promise((resolve) => {
    worker.onmessage = (e) => { resolve(e.data); worker.terminate() }
    worker.postMessage(data)
  })
}

// ✅ Worker pool
const pool = new WorkerPool('./compute.js', 4)
const result = await pool.exec(data)

2. Передача огромных данных без Transferable

// ❌ Копирование 100MB
worker.postMessage(hugeArrayBuffer)

// ✅ Transfer (zero-copy)
worker.postMessage(hugeArrayBuffer, [hugeArrayBuffer])

3. Atomics.wait в main thread

// ❌ Deadlock: main thread заблокирован, воркер не может отправить notify
Atomics.wait(view, 0, 0) // TypeError в браузере, зависание в Node.js main

// ✅ Только в воркерах
// Или используй Atomics.waitAsync (не блокирует):
Atomics.waitAsync(view, 0, 0).value.then(() => { /* ... */ })

4. Воркер для тривиальных задач

// ❌ Overhead > выигрыш
worker.postMessage([1, 2, 3]) // сложить 3 числа через воркер

// ✅ Просто посчитай в main thread
const sum = [1, 2, 3].reduce((a, b) => a + b)

Выбор подхода: итоговая таблица

ЗадачаПодход
Сетевой запросasync/await (I/O-bound)
Парсинг JSON 1KBMain thread (быстро)
Парсинг JSON 100MBWeb Worker + Transferable
Хеширование пароляworker_threads
Обработка изображенийWeb Worker
HTTP-сервер на все ядраcluster / PM2
Запуск ffmpegchild_process.spawn
Много CPU-задач параллельноWorker pool (piscina)
Общий счётчик между потокамиSharedArrayBuffer + Atomics

Итого

ФактОписание
Worker poolПредсоздание N воркеров + очередь задач
Правило 16msЕсли задача < 16ms, воркер не нужен
piscinaProduction worker pool для Node.js
ComlinkПрозрачные вызовы воркера как async-функций
АнтипаттерныВоркер на запрос, передача без Transfer, wait в main thread
ВыборI/O → async/await, CPU < 16ms → main thread, CPU > 16ms → Worker