Сегодня разберёмся с Bulkhead-паттерном в коде .NET. Bulkhead, своего рода защитная клетка для ваших ресурсов. Суть в том, чтобы изолировать группы запросов или операций так, чтобы отказ или перегрузка в одной ячейке не утопила всю систему. Без разделения лёгкий запрос может ждать, пока тратится весь пул потоков тяжёлой задачей.
Благодаря Bulkhead каждый поток или потребитель получает выделенный пул ресурсов и может работать даже при сбое или загрузке других. Это помогает предотвратить большое и ненужное расходование ресурсов, если один сервис начинает есть всё CPU или соединения, его проблемы останутся в его же сегменте.

Bulkhead можно реализовать разными способами. Один из простых — использовать SemaphoreSlim для ограничения параллелизма. Например:
// Базовая реализация Bulkhead с помощью семафора
public class Bulkhead
{
private readonly SemaphoreSlim _semaphore;
public Bulkhead(int maxConcurrent)
{
_semaphore = new SemaphoreSlim(maxConcurrent, maxConcurrent);
}
public async Task<TResult> ExecuteAsync<TResult>(Func<Task<TResult>> action)
{
await _semaphore.WaitAsync();
try
{
return await action();
}
finally
{
_semaphore.Release();
}
}
}
Конструктор класса Bulkhead принимает maxConcurrent — максимально разрешённое число одновременно выполняемых задач. При вызове ExecuteAsync метод ждёт, пока семафор разрешит выполнение, запускает переданную задачу и после её завершения отпускает ресурс. Если потоков больше, чем maxConcurrent, лишние ждут своей очереди, не захватывая нужные ресурсы. При спаме исходящих запросов к внешнему API или базе можно выделить около 5 тяжёлых каналов. Остальные запросы будут стоять в очереди, не забивая весь пул, а значит, быстрые задачи смогут выполняться без задержек.
В .NET есть более интересные библиотеки. Одна из популярных — Polly. Её BulkheadPolicy позволяет в гибком формате настроить лимиты и очередь. Полли-шаблон упакован в готовый класс:
using Polly;
using Polly.Bulkhead;
var bulkheadPolicy = Policy.BulkheadAsync(
maxParallelization: 5, // максимум 5 параллельных задач
maxQueuingActions: 20, // максимум 20 задач в очереди
onBulkheadRejectedAsync: context => {
// Обработчик ситуации, когда семафор исчерпан
Console.WriteLine("Bulkhead is full, rejecting request");
return Task.CompletedTask;
});
// Пример защищённого метода
async Task<string> FetchDataAsync(string url)
{
return await bulkheadPolicy.ExecuteAsync(async () =>
{
using var httpClient = new HttpClient();
var response = await httpClient.GetStringAsync(url);
return response;
});
}
Создаём bulkheadPolicy с ограничением 5 одновременных задач и очередью на 20. Если придёт больше запросов, лишние будут ждать или получат исключение BulkheadRejectedException. Код внутри ExecuteAsync — это любая асинхронная операция (например, HTTP‑запрос). Полли гарантирует, что не более 5 таких операций з��пустится одновременно, остальные буду ждать места. Кстати, использование неподлинного new HttpClient() внутри — не лучшая практика, лучше использовать один HttpClient на всё приложение, но принцип Bulkhead тут ясен.
Помимо семафоров и Polly, Bulkhead можно строить на отдельных пулах потоков или даже на отдельных процессах, например, запустить часть службы в одном процессе/контейнере, а часть в другом). Для более тонкой настройки в.NET есть класс ConcurrentExclusiveSchedulerPair:
var scheduler = new ConcurrentExclusiveSchedulerPair(
TaskScheduler.Default,
maxConcurrencyLevel: 5
);
// Здесь создаём планировщик с уровнем параллелизма 5 для «тяжёлых» задач
var heavyTaskFactory = new TaskFactory(scheduler.ConcurrentScheduler);
// Пример запуска тяжёлой задачи
heavyTaskFactory.StartNew(() => {
// тяжелая операция
});
Но чаще для Bulkhead удобнее семафоры или готовые политики. Bulkhead не мешает добавлять остальные паттерны, с ним будет логично комбинировать Circuit Breaker, Retry и прочее.
Суммируя, Bulkhead‑паттерн в.NET можно описать так — изолируйте ресурсы (через семафоры, пулы потоков или специальные библиотеки) на уровне «пул‑задач», чтобы сбой или перегрузка одного потока не парализовали остальные.
Допустим, у нас есть метод, который вызывает внешний API, и мы хотим ограничить эти вызовы:
// Определяем политику Bulkhead в Polly один раз при старте приложения
static AsyncBulkheadPolicy _bulkheadPolicy = Policy.BulkheadAsync(5, 100);
public async Task<string> GetDataWithBulkheadAsync(string url)
{
// Выполняем защищённый запрос
return await _bulkheadPolicy.ExecuteAsync(async () =>
{
using var httpClient = new HttpClient();
var data = await httpClient.GetStringAsync(url);
return data;
});
}
В коде до 5 запросов к url будут выполняться одновременно. Шестой запрос при дожде сразу станет в очередь. Если и очередь заполнится, новые запросы будут выбрасывать исключение. Так точно не позволим API задираться сверх меры и сохраним остальные функции приложения живыми.
Где применять Bulkhead: полезно для сервисов, которые общаются со множеством внешних ресурсов или обрабатывают разнотипные запросы. Например, веб‑API, где одни эндпоинты делают тяжёлые вычисления, а другие простые запросы к БД. Разделив их между шлюзами, вы не дадите одним неисправным запросам убить всю систему. При этом вы можете настроить разные приоритеты, например, резервный пул для VIP‑пользователей или для важных задач.

Если вы читали про Bulkhead, значит уже сталкивались с классическим набором болей: один «тяжёлый» участок выедает лимиты, метрики начинают врать, инциденты разрастаются, а инфраструктура оказывается уникальной снежинкой, которую никто не хочет трогать. Чтобы это не оставалось абстрактным паттерном «в вакууме», вот три бесплатных демо-урока, где такие проблемы будуь разбираться на уровне архитектуры, наблюдаемости и процессов:
22 декабря, 20:00 — Проектирование высоконагруженного мониторинга в распределенных системах. Записаться
24 декабря, 20:00 — Паттерны микросервисной архитектуры: как системному аналитику говорить с архитектором на одном языке. Записаться
25 декабря, 20:00 — Фениксы и снежинки: как строить инфраструктуру, которая не ломается. Записаться