Ограничение параллелизма

Параллелизм в сборке с использованием esbuild определяется сочетанием внутреннего пула потоков Go-рантайма и тем, как JavaScript-код инициирует задачи сборки или трансформации. Несмотря на высокую производительность инструмента, неконтролируемое распараллеливание на уровне приложения способно привести к деградации времени сборки, росту потребления памяти и перегрузке дисковой подсистемы.

esbuild реализован на Go и использует собственный планировщик горутин. При запуске сборки создаётся набор worker-потоков, количество которых обычно соответствует доступным ядрам процессора. Внутри одного процесса esbuild:

  • компиляция модулей распределяется между worker’ами;
  • файловые операции выполняются конкурентно;
  • граф зависимостей обрабатывается параллельно;
  • результаты агрегации синхронизируются в финальной фазе.

Ключевой момент заключается в том, что один процесс esbuild уже является высокопараллельным. Поэтому внешнее распараллеливание через множественные вызовы API часто становится избыточным.

Причины необходимости ограничения параллелизма

При увеличении количества одновременных задач сборки возникают характерные проблемы:

  • резкий рост потребления CPU из-за конкуренции процессов;
  • усиленная нагрузка на файловую систему;
  • увеличение latency отдельных сборок;
  • деградация кеширования;
  • рост давления на память из-за дублирования графов модулей;
  • перегрузка Node.js event loop при множественных промисах.

Особенно заметно это в сценариях CI/CD, где одновременно запускаются десятки сборок, или в монорепозиториях с независимыми пакетами.

Типовые источники неконтролируемого параллелизма

Множественные независимые вызовы build

import esbuild fr om "esbuild";

Promise.all([
  esbuild.build({ entryPoints: ["src/a.js"], bundle: true }),
  esbuild.build({ entryPoints: ["src/b.js"], bundle: true }),
  esbuild.build({ entryPoints: ["src/c.js"], bundle: true })
]);

Каждый вызов инициирует отдельную сборку со своим графом зависимостей. Даже при использовании одного и того же процесса Node.js нагрузка масштабируется линейно.

Параллельные transform-операции

await Promise.all(
  files.map(file =>
    esbuild.transform(code[file], { loader: "ts" })
  )
);

Хотя transform легче, чем build, массовый запуск всё равно приводит к всплескам нагрузки.

Повторный запуск esbuild service

Каждый запуск esbuild.startService() поднимает отдельный инстанс Go-процесса:

const serviceA = await esbuild.startService();
const serviceB = await esbuild.startService();

Это фактически удваивает количество worker-пулов и резко увеличивает конкуренцию за ресурсы.

Базовый принцип ограничения: один сервис — один пул

Наиболее стабильная модель заключается в повторном использовании одного сервиса esbuild:

import * as esbuild fr om "esbuild";

const service = await esbuild.startService();

await service.build({
  entryPoints: ["src/index.js"],
  bundle: true
});

await service.build({
  entryPoints: ["src/admin.js"],
  bundle: true
});

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

Очереди задач на уровне приложения

Ограничение параллелизма чаще всего реализуется вне esbuild — на уровне Node.js через очередь.

Ограничение через семафор

import pLimit fr om "p-lim it";
import * as esbuild fr om "esbuild";

const lim it = pLimit(2);

const tasks = files.map(file =>
  lim it(() =>
    esbuild.transform(code[file], { loader: "ts" })
  )
);

await Promise.all(tasks);

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

Ручная реализация очереди

async function runQueue(items, worker, concurrency = 2) {
  const queue = [...items];
  const active = new Set();

  async function next() {
    if (queue.length === 0) return;

    const item = queue.shift();
    const p = worker(item).finally(() => {
      active.delete(p);
    });

    active.add(p);

    if (active.size < concurrency) {
      await next();
    }

    await Promise.race(active);
    return next();
  }

  await next();
}

Контекстный API и контроль повторных сборок

Начиная с контекстного режима, esbuild позволяет переиспользовать граф и уменьшать стоимость повторных сборок:

const ctx = await esbuild.context({
  entryPoints: ["src/index.js"],
  bundle: true,
  sourcemap: true
});

await ctx.rebuild();
await ctx.rebuild();

Контекст снижает нагрузку за счёт:

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

Однако даже при использовании context параллельные вызовы rebuild() всё равно требуют ограничения на уровне приложения.

Параллелизм в watch-режиме

Watch-режим уже содержит встроенную оптимизацию событий файловой системы. При изменениях:

  • события дебаунсятся;
  • пересчитывается минимальный подграф;
  • повторная сборка запускается после стабилизации изменений.

Проблема возникает при одновременном запуске нескольких watch-инстансов:

esbuild.context({ entryPoints: ["src/a.js"] }).then(ctx => ctx.watch());
esbuild.context({ entryPoints: ["src/b.js"] }).then(ctx => ctx.watch());
esbuild.context({ entryPoints: ["src/c.js"] }).then(ctx => ctx.watch());

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

Ограничение параллелизма через архитектуру сборки

Группировка входных точек

Вместо запуска множества сборок выгоднее объединять входные точки:

esbuild.build({
  entryPoints: [
    "src/app.js",
    "src/admin.js",
    "src/dashboard.js"
  ],
  outdir: "dist",
  bundle: true,
  splitting: true,
  format: "esm"
});

Один процесс сборки заменяет несколько параллельных.

Разделение по фазам

Архитектурный подход предполагает последовательные этапы:

  1. трансформация TypeScript;
  2. бандлинг;
  3. минификация.

Каждый этап выполняется строго последовательно, даже если внутри него есть внутренняя параллельность esbuild.

Потоковая модель обработки задач

При большом количестве модулей эффективнее использовать потоковую подачу задач с ограничением:

async function process(files) {
  for (const file of files) {
    await esbuild.transform(file.code, { loader: "tsx" });
  }
}

Хотя это снижает параллелизм, оно стабилизирует нагрузку и уменьшает пики потребления ресурсов.

Конкуренция CPU и деградация производительности

При превышении оптимального уровня параллелизма наблюдается эффект обратного масштабирования:

  • рост context switching в OS;
  • падение эффективности кеша L1/L2 CPU;
  • увеличение времени GC в Node.js;
  • блокировки файловой системы;
  • очереди внутри Go scheduler.

В результате увеличение числа задач не ускоряет сборку, а замедляет её.

Контроль параллелизма в CI окружениях

В CI системах часто запускаются параллельные job’ы, каждый из которых вызывает esbuild. Без ограничений это приводит к перегрузке runner’ов.

Типовая стратегия:

  • ограничение concurrency на уровне CI (matrix size);
  • последовательный запуск build-стадий;
  • кеширование output между job’ами;
  • использование shared cache директорий.

Паттерн «один процесс — много задач»

Наиболее стабильная модель:

  • один Node.js процесс;
  • один esbuild service или context;
  • очередь задач;
  • ограниченный concurrency pool.
const service = await esbuild.startService();

const lim it = pLimit(3);

await Promise.all(
  jobs.map(job =>
    limit(() =>
      service.build(job)
    )
  )
);

Влияние плагинов на параллелизм

Плагины esbuild могут добавлять асинхронные операции через onResolve и onLoad. При массовом параллельном запуске сборок эти хуки становятся дополнительным источником нагрузки:

  • параллельные HTTP-запросы;
  • чтение файлов;
  • доступ к кешам;
  • обращение к внешним API.

Даже при ограничении уровня build-процессов плагины способны создавать скрытый параллелизм, который требует отдельного контроля через собственные очереди внутри плагина.

Баланс между скоростью и стабильностью

Эффективное использование esbuild достигается не максимальным параллелизмом, а контролируемым:

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

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