Перейти к содержимому
Поиск
Разработка

Piscina: потоки Node.js — мастерская и 7 практических опытов

Piscina на практике: тяжёлые вычисления Node.js, пул потоков, ограниченная очередь, отмена и передача данных. Семь опытов на реальных записях, конструктор и исходники Windows/Linux/VPS.

Александр Некрасов
Piscina — тяжёлые задачи Node.js в пуле рабочих потоков

Piscina на практике: тяжёлые вычисления Node.js, пул потоков, ограниченная очередь, отмена и передача данных. Семь опытов на реальных записях, конструктор и исходники Windows/Linux/VPS.

Piscina: тяжёлая работа без зависшего интерфейса

Пользователь запускает отчёт, а приложение неожиданно перестаёт быстро отвечать даже на простые запросы. Причина может находиться не в базе и не в сети: синхронный расчёт занял основной поток Node.js. Пока он выполняется, другие обработчики ждут своей очереди. Piscina помогает вынести такую работу в управляемый пул worker_threads и сохранить отзывчивость основного процесса.

В этой статье мы разберём семь практических ситуаций: сравним основной поток и пул, ограничим очередь, передадим большой буфер, обработаем ошибку и отмену, а затем соберём небольшой HTTP-сервис. Мини-мастерская показывает сохранённые измерения настоящих запусков. Полный проект позволяет повторить эксперименты на Windows, Linux и VPS, изменять нагрузку и проверять результат на своей машине.

Читатель получит конкретную основу для внедрения: отдельную функцию вычисления, общий пул, ограниченный вход API, пример завершения работы и команды эксплуатации. При этом мы будем различать скорость отдельного расчёта, время ожидания задачи и задержку ответа соседнего маршрута. Эти показатели отвечают на разные вопросы и не должны заменять друг друга.

Где это полезно: отчёты, преобразования и вычисления

Пул потоков уместен для расчёта большого набора показателей, обработки массивов, некоторых преобразований документов и других CPU-задач, написанных на JavaScript. Представьте CRM, где сотрудник строит сводку за год: работа с подготовленными данными может продолжаться отдельно, а карточки клиентов и короткие запросы останутся доступными. Аналогичная проблема встречается в сервисе импорта, который проверяет тысячи строк.

Сначала измерьте реальную причину задержки. Если большую часть времени занимает ожидание HTTP, запрос базы или чтение файла через асинхронное API, перенос в worker может добавить расходы на передачу данных без заметной пользы. Некоторые нативные библиотеки уже используют свои потоки. Дополнительный пул поверх них способен увеличить конкуренцию за CPU и память.

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

Piscina, worker_threads и BullMQ: какую задачу решает каждый

worker_threads — механизм Node.js, который создаёт отдельные JavaScript-потоки внутри процесса. Piscina организует их повторное использование, распределение задач, ограничение очереди и возврат результата через Promise. Это удобнее, чем для каждого запроса вручную создавать Worker, слушать сообщения, следить за завершением и обрабатывать множество одинаковых ошибок.

BullMQ решает другую задачу: переносит очередь в Redis и помогает разделять процессы, повторять работу и восстанавливать её после перезапуска. Очередь Piscina хранится в памяти одного процесса. Если процесс завершается аварийно, незаконченные задания не превращаются автоматически в долговечные записи. Для отправки важных уведомлений или фоновой обработки с обязательным восстановлением нужна соответствующая архитектура.

В большом сервисе инструменты могут сочетаться: BullMQ выдаёт задание отдельному Node-процессу, а тот распределяет CPU-часть между потоками. Начинайте с минимальной достаточной схемы. Несколько уровней очередей усложняют отмену, таймауты и наблюдение, поэтому каждый уровень должен иметь понятную причину существования.

HTTP → Node.js → Piscina → worker_threads
                   ↓
            очередь в памяти процесса

Долговечная фоновая работа:
приложение → BullMQ / Redis → отдельный обработчик

Для долговечных задач смотрите материал BullMQ: очереди и повторы.

Версии и важная особенность timing API

Проект закрепляет Piscina 5.3.2, Bootstrap 5.3.8 и esbuild 0.28.2. Для нашей сборки используется поддерживаемая ветка Node.js 24 версии не ниже 24.15. Сам пакет Piscina допускает Node.js 20 и новее, однако это не означает, что все дополнительные средства проекта проверены на каждой старой версии. Устанавливайте зависимости командой npm ci из включённого lock-файла.

В проверенной версии статистика читается через pool.histogram.runTime и pool.histogram.waitTime. В старых примерах можно встретить обращение напрямую к pool.runTime. Не переносите такой код без проверки фактического API установленной версии: красивый пример из поиска может относиться к другому выпуску. В нашем архиве этот доступ выполняется во время настоящей записи.

Записанные гистограммы включают короткий прогрев worker. Для отдельного производственного измерения можно сбросить накопленную статистику перед исследуемым интервалом. Указывайте, входит ли запуск потоков в результат: холодный старт и работа прогретого пула являются разными экспериментами.

console.log(pool.histogram.runTime);
console.log(pool.histogram.waitTime);
pool.histogram.resetRunTime();
pool.histogram.resetWaitTime();

Версии и API: исходники Piscina 5.3.2, Node.js worker_threads.

Мини-мастерская: семь опытов и собственная конфигурация

Начните с первого сценария и сравните задержку контрольного HTTP-запроса при выполнении CPU-функции в основном потоке и в Piscina. Затем выберите один и два worker: одинаковая партия из шести задач покажет полную длительность и моменты завершения. В опыте с очередью видны принятые и отклонённые задания, а в передаче буфера — изменившееся владение памятью.

Курсор и кнопка проигрывания перемещаются по сохранённым измерениям. Они не запускают новый расчёт и не создают нагрузку на сайт. Таблица сохраняет исходные числа, а JSON позволяет проверить структуру ответа. Для опытов без временного ряда проигрывание недоступно: там смысл находится в возвращённых данных, ошибке или состоянии буфера.

Ниже расположен конструктор. Задайте число потоков, размер очереди и объём работы, скачайте конфигурацию либо готовый Node.js-файл. Изменение этих параметров не пересчитывает прежние результаты. Чтобы получить свои измерения, скачайте полный проект и выполните отдельный запуск. Так можно честно сравнивать один и тот же код на ноутбуке, контейнере и сервере.

Публичная мастерская воспроизводит реальные измерения Node.js/Piscina. В браузере не работает Piscina и не запускается нагрузка на сайт. Конструктор создаёт код для отдельного локального запуска.

Windows: запустить мастерскую из архива

Установите Node.js 24 LTS, распакуйте проект в удобный каталог и откройте PowerShell. Команда node –version показывает, какую версию действительно видит текущий терминал. Если в системе несколько установок Node, сначала проверьте PATH. В командах используется npm.cmd: это позволяет не зависеть от политики выполнения PowerShell-скрипта npm.ps1.

Готовая папка public уже лежит в архиве. Установка зависимостей нужна для сборки, тестов и настоящих экспериментов. npm ci устанавливает закреплённые версии. Сборка собирает интерфейс и копирует запись, локальный Bootstrap и лицензии. Просмотр работает через HTTP: открытие index.html напрямую с диска может ограничить fetch записи политикой браузера.

После команды serve откройте адрес из консоли. Для остановки нажмите Ctrl+C. Не оставляйте несколько копий сервера на одном порту: второй процесс сообщит о занятом адресе. Исходные файлы не требуют API-ключей или подключения к внешней CRM; все данные учебные.

Set-Location 'C:/Projects/piscina-project'
node --version
npm.cmd ci --ignore-scripts
npm.cmd run build
npm.cmd test
npm.cmd run serve
# http://127.0.0.1:8311

Linux: тот же проект и проверка локального API

На Linux порядок работы практически тот же. Установите поддерживаемый Node.js для вашей системы, проверьте версию и распакуйте проект в каталог пользователя. Для первого запуска не нужны root-права: HTTP API слушает локальный адрес и непривилегированный порт. Такой подход удобен на выделенном сервере, где рядом уже работают другие приложения.

Команда start запускает настоящий сервис. Во втором терминале можно запросить health и выполнить ограниченный расчёт. Кавычки вокруг URL важны: символ амперсанда в shell иначе имеет собственное значение. Статус 400 означает некорректный вход, а 503 сообщает, что работу сейчас нельзя принять либо выполнить. Проверяйте тело ответа вместе с кодом HTTP.

На удалённом сервере не открывайте учебный compute наружу ради первого знакомства. SSH-туннель даёт доступ с вашего компьютера без изменения публичных портов. Для постоянной интеграции понадобятся авторизация и ограничения, которые относятся к вашему приложению, а не возникают автоматически от установки Piscina.

cd ~/projects/piscina-project
node --version
npm ci --ignore-scripts
npm run build
npm test
npm start
# Во втором терминале:
curl -fsS http://127.0.0.1:8310/health
curl -fsS 'http://127.0.0.1:8310/compute?mode=pool&rounds=10000000'

Как устроены вход, worker и результат

Worker получает обычное сериализуемое значение и возвращает результат либо Promise результата. В примере вход состоит из числа итераций и seed, а ответ содержит контрольную сумму, фактический threadId и длительность самого расчёта. Контрольная сумма помогает убедиться, что два режима выполнили одинаковую работу, а не просто показали разные таймеры.

Внутри worker отсутствуют HTTP-объекты, DOM и обращение к пользовательской сессии. Контекст передаётся явно. Это облегчает тестирование и ограничивает связанность: функцию можно проверить отдельно от маршрутизации. Не пытайтесь передать целый request, подключение базы или функцию-замыкание. Контракт задачи должен оставаться понятной структурой данных.

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

// src/worker.mjs
export function summarize({ values }) {
  return {
    count: values.length,
    sum: values.reduce((total, value) => total + value, 0)
  };
}
// В полной версии дополнительно проверяются типы и размер входа.

Создаём один пул на процесс

Пул создаётся один раз при старте приложения. Если создавать его внутри каждого HTTP-запроса, вы снова будете платить за запуск worker и можете неожиданно получить десятки потоков. Общий экземпляр распределяет работу между заранее установленным количеством исполнителей и удерживает очередь в одном месте.

Путь к worker строится через new URL относительно import.meta.url. Это работает независимо от текущего рабочего каталога и избавляет от ручной сборки Windows- и Linux-путей. В нашем проекте pool.mjs находится рядом с worker.mjs. Если переносите пример в другой каталог, измените именно относительное положение файла, а не подставляйте путь из запроса пользователя.

Для CPU-задачи concurrentTasksPerWorker оставлен равным единице. Несколько одновременно ожидающих Promise внутри одного потока не превращают синхронный JavaScript в параллельный расчёт. Настройки числа worker и очереди задаются сервером. Доступность этих параметров в конструкторе статьи нужна для подготовки локального эксперимента.

import Piscina from 'piscina';
const pool = new Piscina({
  filename: new URL('./worker.mjs', import.meta.url).href,
  minThreads: 2, maxThreads: 2,
  maxQueue: 8,
  concurrentTasksPerWorker: 1,
  idleTimeout: 10000
});

Опыт 1. Основной поток и контрольный HTTP-запрос

Простейшая проверка отзывчивости — запустить тяжёлую задачу и одновременно обращаться к лёгкому маршруту. В capture клиент работает в отдельном процессе от API. Это принципиально: таймер, поставленный в том же заблокированном event loop, сам не сможет своевременно отправить запрос и исказит интерпретацию наблюдения.

В режиме main CPU-функция вызывается напрямую из обработчика. В режиме pool обработчик ожидает Promise, а вычисление выполняет worker. Контрольный health не делает тяжёлой работы: он показывает состояние API и очереди. Разница в его задержке демонстрирует влияние вычисления на соседние запросы.

Не следует обещать, что один расчёт в Piscina обязательно закончится быстрее. Передача задачи, пробуждение потока и обмен результатом добавляют расходы. Основная цель этого опыта — показать, что другой запрос не обязан ждать всю CPU-работу. Для очень коротких функций расходы пула могут оказаться заметнее полезного вычисления.

const result = mode === 'main'
  ? compute({ rounds })
  : await pool.run({ rounds });
// Контрольный клиент отдельно измеряет GET /health.

Опыт 2. Один и два потока: скорость всей партии

Вторая задача — обработать шесть независимых элементов. При одном worker они проходят последовательно; при двух часть работы может идти параллельно. В записи сохраняются моменты постановки и завершения, threadId и общая длительность партии. Это позволяет увидеть распределение, а не только одно итоговое число.

Число потоков не равно гарантированному ускорению. На VPS два логических CPU могут делить физическое ядро, а контейнер может иметь меньшую квоту, чем видит хост. Другие процессы тоже конкурируют за ресурсы. Память и стоимость копирования способны ограничить результат раньше вычислительной мощности. Поэтому сравнивайте несколько запусков в похожих условиях.

Гистограмма waitTime описывает ожидание, runTime — работу задачи в трактовке пула. В нашей записи отдельно сохранены эти структуры, причём короткий warmup включён в статистику. Если хотите измерить только исследуемую партию, сбросьте гистограммы после прогрева и перед отправкой заданий.

const jobs = Array.from({ length: 6 }, (_, index) =>
  pool.run({ rounds: 20_000_000, seed: index + 1 })
);
const results = await Promise.all(jobs);
console.log(results.map(x => x.threadId));

Опыт 3. MaxQueue и понятный отказ при перегрузке

Неограниченная очередь иногда выглядит удобной: приложение принимает всё и обещает выполнить позже. Но в памяти остаются входные данные и Promise, а пользователи продолжают ждать. В результате единичный всплеск превращается в длинный хвост задержек. maxQueue ограничивает число задач, ожидающих свободного worker; уже исполняемые задачи учитываются отдельно.

В опыте используется один worker и одно место очереди. Почти одновременно отправляются шесть задач. Каждая получает явный исход: fulfilled или rejected. Ошибка переполнения перехватывается для каждого Promise, поэтому эксперимент не заканчивается необработанным исключением. Отказ — часть контракта, а не скрытая потеря задания.

В HTTP-сервисе ограничение дополняется собственным счётчиком принятых запросов. При заполнении возвращается 503 с понятным кодом BUSY_RETRY_LATER. Клиент может предложить повтор, но не должен бесконечно повторять каждую операцию без задержки. Для необратимых действий сначала требуется согласовать идемпотентность и допустимость повторного выполнения.

const pool = makePool({ threads: 1, maxQueue: 1 });
const outcomes = await Promise.allSettled(
  Array.from({ length: 6 }, () =>
    pool.run({ rounds: 50_000_000 })
  )
);
console.log(outcomes.map(x => x.status));

Опыт 4. Именованные функции вместо произвольного кода

Один worker-модуль может содержать несколько типов обработки. В нашем архиве есть compute, summarize, sumBuffer и намеренно ошибочная функция fail. Вызов выбирает name из заранее известного списка. Это полезно для сервиса, который строит разные отчёты, но использует общий механизм распределения работы.

Принципиальная граница проходит между выбором бизнес-операции и исполнением произвольного кода. Сервер может разрешить action=summary, затем сам сопоставить его фиксированной функции. Не передавайте полученный от посетителя путь в filename и не принимайте содержимое JavaScript. Поток не является песочницей для недоверенных программ: он работает с правами процесса.

Результат также должен иметь стабильную форму. Для сводки возвращаются count и sum; для расчёта — checksum и служебные измерения. В настоящем API служебные поля можно убрать из публичного ответа и оставить в диагностике. Так изменение внутренней архитектуры не ломает клиентский контракт.

const result = await pool.run(
  { values: [120, 80, 30, 45] },
  { name: 'summarize' }
);
console.log(result.sum); // 275

Опыт 5. ArrayBuffer: копирование и transferList

Для больших массивов стоимость передачи может оказаться сравнимой с вычислением. Обычный обмен структурно клонирует данные. transferList позволяет передать владение определённым объектом worker. После передачи ArrayBuffer у отправителя становится detached: byteLength равен нулю, и прежнее представление больше нельзя использовать как доступ к тем же данным.

В записи создаётся отдельный ArrayBuffer на восемь мебибайт, заполненный значением три. Оба варианта вычисляют одинаковую сумму. После обычной передачи исходный размер сохраняется, после transferList становится нулевым. Это наблюдаемое изменение владения важнее одной случайной разницы времени на конкретном компьютере.

Не применяйте этот приём механически к любому Buffer. У буферов могут быть особенности выделения и совместного владения памятью. Для ясного контракта создавайте отдельный ArrayBuffer и документируйте, кто имеет право читать его после отправки. Если данные нужны обоим участникам, выбирайте копию или специально спроектированную общую память с синхронизацией.

const buffer = new ArrayBuffer(8 * 1024 * 1024);
new Uint8Array(buffer).fill(3);
const result = await pool.run(
  { buffer },
  { name: 'sumBuffer', transferList: [buffer] }
);
console.log(result.sum, buffer.byteLength); // сумма, 0

Опыт 6. Исключение, AbortSignal и следующая задача

Ошибка рабочей функции отклоняет Promise конкретной задачи. Её нужно обработать в месте, где определяются пользовательский ответ и дальнейшее поведение. В опыте fail намеренно выбрасывает DEMO_TASK_ERROR, после чего отдельно проверяется отмена длительного расчёта и успешный запуск небольшой задачи. Это показывает восстановление, а не только красивое сообщение об ошибке.

Если задача ещё ожидает, её можно убрать из очереди. Если она уже выполняется, отмена через AbortSignal завершает соответствующий worker. Из этого не следует откат совершённых внешних эффектов: записанный файл, отправленное письмо или запрос другой системе могли уже произойти. Наш worker только считает в памяти, поэтому этот риск не скрыт сложной предметной логикой.

В UI стоит различать отмену ожидания и подтверждённую отмену вычисления. Например, закрытие вкладки не всегда означает, что сервер уже прекратил работу. В скачанном API обрыв HTTP-ответа инициирует AbortController для pool-задачи; синхронный режим main не может мгновенно обработать событие, пока занят расчётом.

const controller = new AbortController();
const task = pool.run(
  { rounds: 100_000_000 },
  { signal: controller.signal }
);
controller.abort();
try { await task; }
catch (error) { console.log(error.name); }

Опыт 7. Прогресс партии и аккуратная остановка

Когда работа состоит из независимых элементов, полезный прогресс можно считать по завершённым задачам. Шесть завершений дают понятное отношение completed/total. Это не оценка процента внутри одного непрозрачного CPU-вызова. Если первый элемент намного тяжелее остальных, линейный процент не означает линейное оставшееся время.

При остановке сервиса сначала прекращается новый приём, затем ожидаются уже принятые HTTP-обработчики, и только после этого закрывается пул. В отдельном опыте партии мы явно ждём Promise всех задач и проверяем, что после close не осталось worker. Такой порядок не зависит от предположения, что закрытие мгновенно и безопасно завершит любую ещё не распределённую работу.

Аварийное завершение и превышение общего таймаута остаются отдельными случаями. Для важных задач нужно сохранять состояние вне памяти процесса. В интерфейсе административной панели разумно показать, что сервис завершает работу и новые задания временно не принимаются. Сам пользовательский запрос не должен бесконечно висеть без объяснения.

let completed = 0;
const jobs = items.map(item =>
  pool.run(item).then(result => {
    completed += 1;
    reportProgress(completed, items.length);
    return result;
  })
);
await Promise.all(jobs);
await pool.close();

HTTP-сервис: ограничить вход до передачи в worker

Валидацию делаем до тяжёлой работы. Учебный API принимает только GET /health и GET /compute, фиксированные режимы main/pool и целое число итераций в заданном диапазоне. Внутри worker ограничения повторяются, чтобы прямой вызов из CLI не обходил контракт функции. Это не дублирование пользовательского интерфейса, а две разные границы доверия.

Наши пределы выбраны для учебного стенда и не являются универсальной настройкой бизнеса. В production вместо числа итераций появится ID отчёта либо ограниченный набор данных. Проверяйте права на исходные записи, количество элементов и допустимость операции. Не передавайте в worker всё тело запроса только потому, что оно успешно разобралось как JSON.

Ответы разделены по смыслу: некорректный вход получает 400, временная невозможность принять работу — 503, успешный расчёт — 200. Подробный stack trace не требуется посетителю. Для диагностики записывайте requestId и внутреннюю причину в журнал, сохраняя публичный код ошибки стабильным.

const rounds = Number(url.searchParams.get('rounds'));
if (!Number.isSafeInteger(rounds) ||
    rounds < 1 || rounds > 100_000_000) {
  return send(response, 400, { error: 'INVALID_INPUT' });
}
if (active >= threads + maxQueue) {
  return send(response, 503, { error: 'BUSY_RETRY_LATER' });
}

Какие измерения смотреть рядом с красивым графиком

Минимальный набор включает очередь, число активных задач, скорость завершения, ожидание, длительность вычисления и задержку обычного HTTP-маршрута. Если очередь растёт, а worker заняты, система принимает работу быстрее, чем успевает выполнять. Если worker простаивают, причина может находиться в загрузке данных, блокировке общего ресурса или неверной организации подачи заданий.

Среднее значение удобно для общего обзора, но может скрывать медленный хвост. Вместе с ним смотрите распределение и высокие перцентили. Сравнивайте одинаковые интервалы и учитывайте прогрев. Одно измерение на свободном ноутбуке не доказывает пропускную способность сервера под несколькими пользователями.

На сайте уже есть материалы о Prometheus и Pino. Их можно использовать для наблюдения рабочего сервиса: метрики показывают рост очереди и задержек, журнал помогает связать ошибку с конкретным запросом. Не превращайте ID каждой задачи в label метрики — так количество временных рядов начнёт расти вместе с трафиком.

console.log({
  queued: pool.queueSize,
  completed: pool.completed,
  threads: pool.threads.length,
  runP99: pool.histogram.runTime.p99,
  waitP99: pool.histogram.waitTime.p99
});

Связанные руководства: Prometheus и Pino. Измерительные API описаны в документации perf_hooks.

Почему больше потоков не всегда лучше

У каждого worker собственный JavaScript-контекст и расходы памяти. Если процессу разрешено использовать два CPU, создание восьми постоянно занятых потоков не даёт восемь полноценных вычислительных ресурсов. Они будут делить квоту, а переключения и конкуренция способны ухудшить задержки. Основному потоку тоже нужны ресурсы для HTTP и управления очередью.

API os.availableParallelism полезен как сигнал, но не отменяет проверку реальных ограничений окружения и измерение. В контейнере, виртуальной машине и общем хостинге доступная производительность может отличаться от числа логических процессоров. Записывайте настройки сервиса, квоту и конкурирующую нагрузку вместе с результатом.

Начните с малого пула, ограниченной очереди и представительной задачи. Увеличивайте число потоков пошагово, одновременно наблюдая длительность партии, p95 обычных запросов и RSS. Если выигрыш исчез, не продолжайте увеличивать параметр только ради более впечатляющего числа в конфигурации.

import { availableParallelism } from 'node:os';
console.log({ reportedParallelism: availableParallelism() });
// Это вход для настройки и измерений, а не обещание скорости.

ResourceLimits и общая память процесса

resourceLimits задаёт ограничения V8 для отдельного worker. maxOldGenerationSizeMb относится к старому поколению heap, а не ко всей памяти сервиса. Буферы, нативные библиотеки, основной поток и другие структуры требуют отдельного учёта. Поэтому значение 64 не означает, что каждый worker гарантированно потребляет максимум 64 мегабайта RSS.

Слишком низкий предел способен завершать worker даже на нормальных данных. Слишком высокий предел при большом числе потоков оставляет процесс без защиты от общей нагрузки. На Linux дополнительно применяют ограничения всего сервиса через systemd либо контейнер. При этом лимит должен учитывать разумный запас для запуска, сборщика мусора и обмена данными.

Не проверяйте предел намеренным исчерпанием памяти на рабочем сервере. Используйте изолированный стенд и реалистичные входы. В нашем проекте исходные данные малы, а resourceLimits приведён как часть архитектуры. Авторские тесты проверяют контракт задач, но не являются стресс-тестом памяти вашего приложения.

const pool = new Piscina({
  filename: new URL('./worker.mjs', import.meta.url).href,
  minThreads: 2, maxThreads: 2, maxQueue: 8,
  resourceLimits: { maxOldGenerationSizeMb: 64 }
});

Асинхронная работа внутри worker: дождаться всех действий

Рабочая функция должна завершаться тогда, когда закончена относящаяся к ней работа. Если запустить асинхронное действие и вернуть результат раньше, дальнейшее поведение может стать неожиданным: выполнение внутри worker зависит от режима управления потоком. Незавершённые таймеры и соединения затрудняют повторное использование и остановку.

Для CPU-обработки удобнее держать функцию чистой: входные данные уже подготовлены, результат возвращается явно. Если нужно сначала прочитать файл или получить данные из базы, решите, где это эффективнее делать. Не переносите произвольные сетевые ожидания в worker только потому, что рядом есть тяжёлый расчёт.

В примере ниже await относится к действию, результат которого входит в ответ задачи. В реальном коде также нужны таймауты и обработка ошибок. Не оставляйте фоновую отправку события незавершённой в надежде, что процесс когда-нибудь сам её доделает; для надёжной доставки существует отдельная задача и подходящий механизм очереди.

export default async function task(input) {
  const prepared = await prepare(input);
  const result = calculate(prepared);
  await persistResult(result);
  return result;
}
// prepare/calculate/persistResult — функции вашего проекта.

Конструктор: от выбранных параметров к рабочему файлу

Конструктор мастерской проверяет целые значения и не позволяет скачать код с пустыми или выходящими за учебные пределы параметрами. Сгенерированный run-example.mjs импортирует Piscina, создаёт пул с выбранными настройками, выполняет одну задачу и закрывает пул в finally. Файл рассчитан на запуск из корня скачанного проекта, где существует src/worker.mjs.

JSON-конфигурация является описанием выбранного эксперимента. Сам по себе он не запускает сервер и не меняет ранее записанные измерения. Поле task отделено от параметров пула. Если используете JSON в собственном загрузчике, валидируйте его и преобразуйте относительный filename в разрешённый абсолютный путь; не считайте любой конфигурационный файл доверенным.

Для полезного сравнения меняйте один параметр за раз. Например, сначала сохраните одинаковый объём задачи и сравните один и два потока на партии. Затем измените очередь и наблюдайте долю отказов. Одновременная смена всех параметров даёт число, но затрудняет объяснение причины результата.

# Из корня распакованного проекта:
node run-example.mjs
# Полный измерительный прогон:
npm run capture
npm run build

Как повторить запись и сохранить проверяемый результат

capture предварительно проверяет свободные порты 8310 и 8311. Затем запускает собственный API на loopback, выполняет фиксированную последовательность задач и сохраняет артефакт. В finally процесс останавливается. Команду не нужно выполнять при каждом открытии мастерской: исходная запись уже включена, а новый capture создаёт ваши измерения вместо прежних.

Файл содержит дату, платформу, архитектуру, версию Node и Piscina, команду запуска, результаты и SHA исходных файлов. Это помогает отличить изменение кода от изменения окружения. Перед новым опытом сохраните прежний recording.json под другим именем. Для публикации нового результата затем перестройте public, чтобы интерфейс читал актуальную копию.

В записанном эксперименте используются настоящие операции, а не вручную подобранные красивые задержки. Однако нагрузка синтетическая и небольшая. Она объясняет механизм и даёт воспроизводимый пример, но не заменяет нагрузочную проверку вашего сервиса с настоящими данными и производственными ограничениями.

# PowerShell
Copy-Item artifacts/recording.json artifacts/recording-before.json
npm.cmd run capture
npm.cmd run build
# Linux: cp artifacts/recording.json artifacts/recording-before.json

VPS: отдельный пользователь и служба systemd

Для постоянного запуска подготовьте отдельного непривилегированного пользователя, каталог релиза и поддерживаемый Node.js. В unit-файле из архива предполагаются User=piscina-demo, WorkingDirectory=/srv/piscina/current и /usr/bin/node. Это шаблон: проверьте путь command -v node и адаптируйте значения под сервер. Команда systemd-analyze verify помогает найти синтаксические ошибки до запуска.

API остаётся на 127.0.0.1. Учебный сервис не содержит полноценной авторизации, поэтому пример не предлагает публиковать compute наружу. Для административного просмотра используйте SSH-туннель. Когда встраиваете расчёт в приложение, запрос должен проходить его обычную проверку пользователя и прав, а pool остаётся внутренней деталью обработки.

CPUQuota=200% и MemoryMax=512M в шаблоне относятся ко всему сервису. Это отправная точка демонстрации, которую следует проверить на своей нагрузке. При обновлении релиза сначала завершайте приём новых задач и учитывайте таймаут остановки. Если задания должны переживать перезапуск, одного systemd Restart недостаточно.

sudo systemd-analyze verify deploy/piscina-demo.service
sudo cp deploy/piscina-demo.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now piscina-demo
sudo systemctl status piscina-demo --no-pager
journalctl -u piscina-demo -n 50 --no-pager

Публикация мастерской через Caddy или Nginx

Браузерная часть статическая: HTML, CSS, JavaScript и JSON измерений. Её можно положить на обычный сайт, не запуская Node API на публичном сервере. Document root должен указывать на public. Исходники, конфигурации эксплуатации и node_modules не требуется отдавать посетителю как рабочие каталоги; специально подготовленные примеры лежат в public/source.

В архиве есть небольшие конфигурации Caddy и Nginx для локального просмотра на 8080. В существующем сайте обычно достаточно добавить каталог со статическими файлами и корректные MIME-типы. Браузерный модуль имеет расширение .js, что важно на хостингах, где .mjs по умолчанию отдают как произвольный бинарный файл.

Если публикуете внутри статьи, дайте ссылку на открытие мастерской отдельной страницей. На маленьком экране большой iframe с внутренней прокруткой бывает неудобен для длинных таблиц. В нашей странице присутствуют оба варианта, а результаты и исходники можно скачать без установки отдельного приложения.

:8080 {
  bind 127.0.0.1
  root * /srv/piscina/current/public
  file_server
}

Что тестируется и как читать границы проверки

Тесты запускают настоящие worker: сравнивают контрольные суммы с основным потоком, проверяют threadId, именованную функцию, отклонение некорректного входа и переполнение очереди. Отдельно проверяются передача буфера, ошибка, AbortError и следующая успешная задача. Эти проверки подтверждают поведение кода, а не только форму объектов конфигурации.

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

Отдельно нужно проверять интерфейс: узкий экран, доступность кнопок, смену сценария во время проигрывания, отказ загрузки и повтор. Скриншот красивой страницы не доказывает правильность CPU-расчёта, а успешный Node-тест не доказывает удобство мобильной формы. Поэтому эти слои проверки сохраняются раздельно.

npm test
npm run build
npm audit
# Новый capture запускается отдельно и меняет запись:
npm run capture

Типовые ошибки при первом внедрении

Если worker не найден, проверьте построение URL и фактическое расположение файла. Если задача не может быть клонирована, сократите вход до данных и уберите функции, сокеты и экземпляры с неожиданным состоянием. Если очередь постоянно заполнена, не увеличивайте её автоматически: сначала сравните поступление и завершение задач, затем выберите политику отказа либо другой механизм фоновой обработки.

Если после transferList исчезли данные у отправителя, это ожидаемая передача владения, а не потеря из-за ошибки Piscina. Если после отмены выросла задержка следующей задачи, возможны расходы на восстановление worker. Если измерения отличаются от статьи, проверьте версии, квоту CPU, прогрев, соседнюю нагрузку и объём входа. Одинаковая команда не создаёт одинаковое железо.

При ошибке graceful shutdown выясните, кто ещё принимает задания и какие Promise остаются незавершёнными. Прекращение HTTP-приёма, ожидание активных запросов и закрытие пула должны быть согласованы. Не заменяйте такую последовательность безусловным process.exit сразу после сигнала: он может оборвать полезную работу и диагностические записи.

// При остановке учебного API:
closing = true;
await new Promise(resolve => server.close(resolve));
await pool.close();
// Новые задания отклоняются ещё до обращения к pool.

Как применить подход в своём сервисе

Выберите одну измеримую CPU-задачу, которая сейчас мешает обычным запросам. Опишите её вход и выход, ограничьте размер данных и сделайте функцию независимой от HTTP. Сначала сравните результат с прежней реализацией, затем перенесите её в общий пул и добавьте обработку перегрузки. После этого измерьте задержки соседних маршрутов и полное время операции.

Следующим шагом добавьте отмену там, где она действительно полезна, и договоритесь о поведении внешних эффектов. Для длинной пользовательской операции может быть удобнее принять задачу, вернуть её ID и показывать статус отдельно. Но если нужна долговечность, повтор и восстановление, выберите очередь, которая хранит состояние вне процесса. Piscina отвечает за исполнение CPU-части, а не за все обязанности фоновой системы.

Архив можно использовать как небольшую лабораторию и основу для собственного модуля. В нём есть рабочие файлы, измерения и объяснение границ. Продолжайте по официальным источникам Piscina и Node.js, закрепляйте версии и сохраняйте результаты своих экспериментов. Полезный итог внедрения — понятное поведение сервиса под нагрузкой и предсказуемая реакция на ошибки, а не максимальное число worker в настройках.

Источники: официальный проект Piscina, os.availableParallelism, установка Node.js.