Параллелизм в сборке с использованием esbuild определяется сочетанием внутреннего пула потоков Go-рантайма и тем, как JavaScript-код инициирует задачи сборки или трансформации. Несмотря на высокую производительность инструмента, неконтролируемое распараллеливание на уровне приложения способно привести к деградации времени сборки, росту потребления памяти и перегрузке дисковой подсистемы.
esbuild реализован на Go и использует собственный планировщик горутин. При запуске сборки создаётся набор worker-потоков, количество которых обычно соответствует доступным ядрам процессора. Внутри одного процесса esbuild:
Ключевой момент заключается в том, что один процесс esbuild уже является высокопараллельным. Поэтому внешнее распараллеливание через множественные вызовы API часто становится избыточным.
При увеличении количества одновременных задач сборки возникают характерные проблемы:
Особенно заметно это в сценариях CI/CD, где одновременно запускаются десятки сборок, или в монорепозиториях с независимыми пакетами.
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 нагрузка масштабируется линейно.
await Promise.all(
files.map(file =>
esbuild.transform(code[file], { loader: "ts" })
)
);
Хотя transform легче, чем build, массовый
запуск всё равно приводит к всплескам нагрузки.
Каждый запуск 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();
}
Начиная с контекстного режима, esbuild позволяет переиспользовать граф и уменьшать стоимость повторных сборок:
const ctx = await esbuild.context({
entryPoints: ["src/index.js"],
bundle: true,
sourcemap: true
});
await ctx.rebuild();
await ctx.rebuild();
Контекст снижает нагрузку за счёт:
Однако даже при использовании context параллельные вызовы
rebuild() всё равно требуют ограничения на уровне
приложения.
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"
});
Один процесс сборки заменяет несколько параллельных.
Архитектурный подход предполагает последовательные этапы:
Каждый этап выполняется строго последовательно, даже если внутри него есть внутренняя параллельность esbuild.
При большом количестве модулей эффективнее использовать потоковую подачу задач с ограничением:
async function process(files) {
for (const file of files) {
await esbuild.transform(file.code, { loader: "tsx" });
}
}
Хотя это снижает параллелизм, оно стабилизирует нагрузку и уменьшает пики потребления ресурсов.
При превышении оптимального уровня параллелизма наблюдается эффект обратного масштабирования:
В результате увеличение числа задач не ускоряет сборку, а замедляет её.
В CI системах часто запускаются параллельные job’ы, каждый из которых вызывает esbuild. Без ограничений это приводит к перегрузке runner’ов.
Типовая стратегия:
Наиболее стабильная модель:
const service = await esbuild.startService();
const lim it = pLimit(3);
await Promise.all(
jobs.map(job =>
limit(() =>
service.build(job)
)
)
);
Плагины esbuild могут добавлять асинхронные операции через
onResolve и onLoad. При массовом параллельном
запуске сборок эти хуки становятся дополнительным источником
нагрузки:
Даже при ограничении уровня build-процессов плагины способны создавать скрытый параллелизм, который требует отдельного контроля через собственные очереди внутри плагина.
Эффективное использование esbuild достигается не максимальным параллелизмом, а контролируемым:
Такой подход устраняет перегрузку системы и стабилизирует время сборки даже при увеличении размера проекта.