Вынос хеширования в Worker Threads

Хеширование паролей является одной из самых ресурсоёмких операций в серверных приложениях на Node.js, особенно при использовании алгоритмов с высокой стоимостью вычислений, таких как bcrypt, scrypt или Argon2. При увеличении количества одновременных запросов выполнение этих операций в основном потоке приводит к деградации производительности: блокируется event loop, увеличивается время отклика API и снижается пропускная способность системы.

Архитектурно правильным решением становится вынос криптографических вычислений в отдельные потоки исполнения с использованием Worker Threads. Такой подход позволяет изолировать CPU-bound операции и сохранить отзывчивость основного потока.

Алгоритмы хеширования паролей специально проектируются как вычислительно дорогие. Их цель — усложнить подбор пароля методом перебора. Это достигается за счёт:

  • многократных итераций (key stretching),
  • использования памяти (memory-hard функции),
  • соли (salt) для уникализации результатов,
  • адаптивной сложности (cost factor).

Например, bcrypt с cost 12 выполняет тысячи итераций на каждый вызов, а Argon2 может дополнительно использовать сотни мегабайт памяти.

В Node.js такие операции выполняются синхронно или через libuv thread pool. Однако стандартный пул потоков ограничен (по умолчанию 4 потока), и при высокой нагрузке он становится узким местом. В результате запросы на регистрацию и аутентификацию начинают конкурировать за ресурсы.

Ограничения встроенного thread pool

Многие криптографические функции Node.js (например, crypto.pbkdf2, crypto.scrypt) используют libuv thread pool. Однако:

  • размер пула ограничен переменной UV_THREADPOOL_SIZE,
  • пул общий для всех операций (файлы, DNS, crypto),
  • отсутствует изоляция задач по типам нагрузки,
  • невозможна гибкая балансировка.

При росте нагрузки хеширование начинает блокировать другие подсистемы, включая файловые операции и сетевые запросы.

Worker Threads как изолированная модель исполнения

Модуль worker_threads предоставляет возможность создавать полноценные потоки исполнения JavaScript, работающие параллельно с основным процессом.

Каждый worker:

  • имеет собственный event loop,
  • не разделяет память по умолчанию,
  • может обмениваться данными через message passing,
  • запускается в отдельном V8 isolate.

Это делает его подходящим для CPU-intensive задач, таких как хеширование паролей.

Структура системы с выносом хеширования

Типичная архитектура включает:

  • основной поток (HTTP API, маршрутизация),
  • пул worker-потоков,
  • очередь задач хеширования,
  • механизм передачи результатов.

Схематически:

HTTP Request → Main Thread → Worker Pool → Hash Function → Worker Response → Main Thread → HTTP Response

Реализация worker для хеширования

Файл worker-а, отвечающий за вычисление хеша, изолирует криптографическую логику:

// hash.worker.js
const { parentPort } = require('worker_threads');
const bcrypt = require('bcrypt');

parentPort.on('message', async (data) => {
  const { password, cost } = data;

  try {
    const hash = await bcrypt.hash(password, cost);
    parentPort.postMessage({ hash });
  } catch (err) {
    parentPort.postMessage({ error: err.message });
  }
});

Worker получает данные, выполняет вычисление и возвращает результат через postMessage.

Организация пула worker threads

Создание одного worker на каждый запрос неэффективно. Используется пул потоков, переиспользующий уже созданные экземпляры.

Простейшая реализация менеджера:

const { Worker } = require('worker_threads');

class HashWorkerPool {
  constructor(size, workerFile) {
    this.size = size;
    this.workerFile = workerFile;
    this.workers = [];
    this.queue = [];
    this.active = new Map();

    for (let i = 0; i < size; i++) {
      this.workers.push(this.createWorker());
    }
  }

  createWorker() {
    const worker = new Worker(this.workerFile);

    worker.on('message', (result) => {
      const callback = this.active.get(worker);
      this.active.delete(worker);
      callback.resolve(result);
      this.release(worker);
    });

    worker.on('error', (err) => {
      const callback = this.active.get(worker);
      this.active.delete(worker);
      callback.reject(err);
      this.release(worker);
    });

    return worker;
  }

  execute(data) {
    return new Promise((resolve, reject) => {
      const worker = this.workers.pop();

      if (!worker) {
        this.queue.push({ data, resolve, reject });
        return;
      }

      this.active.set(worker, { resolve, reject });
      worker.postMessage(data);
    });
  }

  release(worker) {
    if (this.queue.length > 0) {
      const task = this.queue.shift();
      this.active.set(worker, task);
      worker.postMessage(task.data);
    } else {
      this.workers.push(worker);
    }
  }
}

module.exports = HashWorkerPool;

Такая модель обеспечивает переиспользование потоков и контроль над конкурентным выполнением задач.

Интеграция с Password-hash логикой

При использовании библиотеки Password-hash (или аналогичного слоя абстракции над bcrypt/argon2) основная задача заключается в делегировании вычисления хеша worker-у.

Сервисный слой:

const HashWorkerPool = require('./hashPool');
const path = require('path');

const pool = new HashWorkerPool(
  4,
  path.resolve(__dirname, 'hash.worker.js')
);

async function hashPassword(password) {
  const result = await pool.execute({
    password,
    cost: 12
  });

  if (result.error) {
    throw new Error(result.error);
  }

  return result.hash;
}

Обработка конкурентных запросов

При высокой нагрузке worker pool начинает аккумулировать задачи. Важным аспектом становится управление очередью:

  • ограничение размера очереди,
  • отказ при переполнении,
  • приоритизация задач (например, login выше registration),
  • backpressure на уровне API.

Пример ограничения:

if (this.queue.length > 1000) {
  throw new Error('Queue overflow');
}

Передача данных между потоками

Передача данных в Worker Threads осуществляется через структурированное клонирование. Это означает:

  • объекты копируются, а не разделяются,
  • большие payload увеличивают overhead,
  • Buffer можно передавать как transferable объект.

Для паролей нагрузка минимальна, но при расширении системы (например, добавлении соли, метаданных пользователя) стоит учитывать стоимость сериализации.

Оптимизация производительности

Использование worker threads требует балансировки:

  • количество worker ≈ количество CPU cores,
  • избегание частого создания/уничтожения потоков,
  • минимизация передачи данных,
  • кэширование параметров хеширования.

Дополнительно можно использовать:

  • предварительную генерацию соли,
  • асинхронную очередь задач,
  • разделение worker pool на отдельные типы задач.

Ошибки и обработка отказов

Worker может завершиться аварийно. Поэтому необходим контроль:

  • перезапуск worker при exit,
  • логирование ошибок,
  • fallback на резервный поток.

Пример восстановления:

worker.on('exit', (code) => {
  if (code !== 0) {
    this.workers.push(this.createWorker());
  }
});

Влияние на архитектуру системы

Вынос хеширования в Worker Threads изменяет поведение системы:

  • уменьшается latency HTTP запросов при нагрузке,
  • повышается устойчивость event loop,
  • увеличивается потребление памяти (на каждый worker),
  • появляется необходимость управления пулом.

При этом система становится предсказуемой под нагрузкой, так как CPU-bound операции перестают блокировать основной поток.

Масштабирование подхода

Такая модель легко расширяется:

  • распределение worker pool по процессам (cluster),
  • вынос хеширования в отдельный сервис,
  • использование очередей (RabbitMQ, Redis streams),
  • гибридная модель: local workers + remote hashing service.

Worker Threads становятся промежуточным уровнем между синхронной криптографией и распределённой системой обработки задач.