Что происходит с новыми Task, если все потоки в ThreadPool заняты?

«Что происходит с новыми Task, если все потоки в ThreadPool заняты?» — вопрос из категории Многопоточность, который задают на 25% собеседований C# Разработчик. Ниже — развёрнутый ответ с разбором ключевых моментов.

Ответ

Планировщик задач (TaskScheduler) и ThreadPool в .NET имеют встроенные механизмы для обработки такой ситуации. Новые задачи не блокируются и не теряются, но их выполнение может быть отложено.

Механизм работы ThreadPool:

  1. Очередь задач: Все новые Task попадают в глобальную очередь ThreadPool.
  2. Работа воркеров: Свободные потоки из пула забирают задачи из этой очереди и выполняют их.
  3. Регулировка (Thread Injection): Если очередь задач постоянно растет (потоки не успевают их разбирать), ThreadPool запускает алгоритм «впрыскивания» (injecting) новых потоков. Он делает это не мгновенно, а с задержками (обычно ~0.5 секунды), чтобы избежать создания избыточного количества потоков для кратковременных всплесков нагрузки.

Пример, демонстрирующий очередь и регулировку:

using System.Diagnostics;

// Сначала искусственно займем почти все потоки долгими задачами
int workerThreads, completionPortThreads;
ThreadPool.GetAvailableThreads(out workerThreads, out completionPortThreads);
Console.WriteLine($"Доступно потоков: {workerThreads}");

// Запускаем задачи, которые надолго займут потоки
for (int i = 0; i < workerThreads - 2; i++) // Оставляем пару потоков "про запас"
{
    Task.Run(() =>
    {
        Thread.Sleep(TimeSpan.FromSeconds(10)); // Долгая операция
    });
}

Thread.Sleep(500); // Даем время потокам запуститься

// Теперь запускаем новые задачи, когда пул почти полон
var sw = Stopwatch.StartNew();
var newTasks = new List<Task>();
for (int i = 0; i < 50; i++)
{
    int taskId = i;
    newTasks.Add(Task.Run(() =>
    {
        Console.WriteLine($"Задача {taskId} начала выполнение через {sw.ElapsedMilliseconds} мс");
        Thread.Sleep(100); // Короткая работа
    }));
}

Task.WaitAll(newTasks.ToArray());
Console.WriteLine($"Все задачи завершены за {sw.ElapsedMilliseconds} мс");

На что обратить внимание:

  • Long-Running задачи: Для операций, которые по своей природе долгие (обработка файлов, сетевые вызовы с большим таймаутом), используйте TaskCreationOptions.LongRunning. Это создаст для задачи отдельный поток, не из пула, предотвращая его истощение.
    Task.Factory.StartNew(() =>
    {
        // Долгая операция (например, мониторинг)
    }, TaskCreationOptions.LongRunning);
  • Настройка пула: Вы можете задать минимальное и максимальное количество потоков вручную с помощью ThreadPool.SetMinThreads() и ThreadPool.SetMaxThreads(). Увеличение SetMinThreads может уменьшить начальную задержку при всплеске нагрузки, но создаст больше фоновых потоков.
  • Асинхронный код: Использование async/await для I/O-операций (чтение файлов, сетевые запросы) — лучший способ не блокировать потоки пула. Поток освобождается на время ожидания операции и может выполнять другие задачи.