Как ограничить количество одновременно выполняемых потоков или задач в C#?

Ответ

Ограничение параллелизма — ключевая задача для предотвращения истощения ресурсов (потоков, соединений с БД) и контроля нагрузки. В C# есть несколько подходов в зависимости от сценария.

1. Для асинхронных операций (I/O-bound задачи) — SemaphoreSlim

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

// Ограничиваем обработку до 5 параллельных HTTP-запросов или операций с файлами
private static readonly SemaphoreSlim _throttler = new SemaphoreSlim(5);

public async Task ProcessItemsAsync(IEnumerable<Uri> urls)
{
    var tasks = urls.Select(async url =>
    {
        // Ждем, пока не освободится слот (максимум 5 слотов)
        await _throttler.WaitAsync();
        try
        {
            // Выполняем ресурсоемкую операцию (например, HTTP-запрос)
            return await _httpClient.GetStringAsync(url);
        }
        finally
        {
            // Освобождаем слот для следующей задачи
            _throttler.Release();
        }
    });

    await Task.WhenAll(tasks);
}

2. Для CPU-bound задач — ParallelOptions с MaxDegreeOfParallelism

Используется для параллельной обработки данных, где работа ведется в пуле потоков.

var data = Enumerable.Range(1, 1000).ToList();
var options = new ParallelOptions { MaxDegreeOfParallelism = Environment.ProcessorCount }; // Ограничиваем числом ядер

Parallel.ForEach(data, options, item =>
{
    // Интенсивные вычисления (например, обработка изображений)
    var result = ComputeHeavyOperation(item);
    // ...
});

3. Для задач, планируемых через TaskActionBlock<T> (TPL Dataflow)

Библиотека System.Threading.Tasks.Dataflow предоставляет мощные примитивы для конвейерной обработки.

// Создаем блок, который выполняет не более 3 задач одновременно
var actionBlock = new ActionBlock<string>(async url =>
{
    await ProcessUrlAsync(url);
}, new ExecutionDataflowBlockOptions { MaxDegreeOfParallelism = 3 });

// Отправляем данные на обработку
foreach (var url in urlList)
{
    actionBlock.Post(url);
}

// Сообщаем блоку, что данные закончились, и ждем завершения обработки
actionBlock.Complete();
await actionBlock.Completion;

4. Глобальное ограничение пула потоков (используйте с осторожностью!)`

Меняет настройки для всего домена приложения. Может негативно повлиять на работу библиотек.

// Устанавливаем максимум 10 рабочих потоков и 10 потоков ввода-вывода
ThreadPool.SetMaxThreads(10, 10);
// Получить текущие лимиты можно через ThreadPool.GetMaxThreads(...)

Выбор подхода:

  • SemaphoreSlim — лучший выбор для большинства асинхронных сценариев (запросы к API, БД, файлам).
  • ParallelOptions — для параллельных вычислений (циклы Parallel.ForEach).
  • ActionBlock<T> — для сложных конвейеров обработки данных с четким контролем параллелизма.
  • Глобальный ThreadPool — применяйте только в специфичных случаях, когда нужно жестко ограничить ресурсы всего приложения.

Ответ 18+ 🔞

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

Смотри, вариантов несколько, и каждый для своей ситуации.

1. Для всяких асинхронных дел (запросы, файлы, базы) — SemaphoreSlim

Это, блядь, твой лучший друг. Он не тупо жрёт поток, пока ждёт, а по-человечески асинхронно блокируется. Представь, что у тебя турникет в метро — больше пяти человек одновременно не пролезет.

// Допустим, больше 5 запросов одновременно — уже пиздец серверу
private static readonly SemaphoreSlim _турникет = new SemaphoreSlim(5);

public async Task ОбработатьВсёAsync(IEnumerable<Uri> ссылки)
{
    var задачи = ссылки.Select(async url =>
    {
        // Стоим в очереди, ждём свободного слота
        await _турникет.WaitAsync();
        try
        {
            // Вот тут уже делаем свою грязную работу
            return await _httpClient.GetStringAsync(url);
        }
        finally
        {
            // Важно! Вышли — освободили место для следующего, а то все встанут
            _турникет.Release();
        }
    });

    await Task.WhenAll(задачи);
}

2. Для тяжёлых вычислений (CPU-bound) — ParallelOptions

Тут уже SemaphoreSlim не катит, потому что работа идёт в пуле потоков. Используй Parallel.ForEach с настройкой.

var данные = Enumerable.Range(1, 10000).ToList();
var настройки = new ParallelOptions { MaxDegreeOfParallelism = Environment.ProcessorCount }; // Не умничай, больше ядер — не значит быстрее

Parallel.ForEach(данные, настройки, число =>
{
    // Что-то ебёт твой процессор, например, шифрует или картинки масштабирует
    var результат = ВычислитьЧтоТоОченьТяжёлое(число);
    // ...
});

3. Для конвейерной обработки — ActionBlock<T> (библиотека Dataflow)

Штука мощная, но чуть посложнее. Представь конвейер на заводе: детали едут, а рабочих мест для их обработки — всего три.

// Создаём блок, который не более 3 URL'ов одновременно обрабатывает
var рабочийБлок = new ActionBlock<string>(async url =>
{
    await ОбработатьСсылкуAsync(url);
}, new ExecutionDataflowBlockOptions { MaxDegreeOfParallelism = 3 });

// Кидаем в него работу
foreach (var url in списокСсылок)
{
    рабочийБлок.Post(url);
}

// Говорим: "Всё, мужики, новых деталей не будет, доделывайте что есть"
рабочийБлок.Complete();
await рабочийБлок.Completion; // Ждём, пока последняя деталь не уедет с конвейера

4. Глобальный контроль пула потоков (ОСТОРОЖНО, СТРЕЛЯНОЕ ОРУЖИЕ!)

Вот это — уже ядрёна вошь. Меняешь настройки на всё приложение разом. Сделаешь не так — половина библиотек встанет колом, потому что им потоки не выделяются.

// Говорим всему пулу: "Больше 10 рабочих потоков и 10 потоков ввода-вывода — ни-ни"
ThreadPool.SetMaxThreads(10, 10);
// Текущие лимиты можно посмотреть через ThreadPool.GetMaxThreads(...)

Короче, итог:

  • Делаешь запросы к API, БД, файлам?SemaphoreSlim, идиотская ошибка.
  • Гоняешь циклы с вычислениями?ParallelOptions в Parallel.ForEach.
  • Строишь сложный конвейер, где данные текут этапами?ActionBlock<T> из Dataflow.
  • Хочешь глобально придушить всё приложение, потому что знаешь, что делаешь?ThreadPool.SetMaxThreads. Но если не уверен — даже не трогай, а то будет тебе хиросима.