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

«Как ограничить количество одновременно выполняемых потоков или задач в C#?» — вопрос из категории Многопоточность, который задают на 25% собеседований 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 — применяйте только в специфичных случаях, когда нужно жестко ограничить ресурсы всего приложения.