Thread и ThreadPool

↑ 2.3 Асинхронность и многопоточность · → Следующая

Обязательно~5 мин чтенияУровень: middle

Зачем это на собесе

Основа основ конкурентности: чем поток отличается от задачи, как работает пул потоков и почему при блокирующих вызовах сервис «встаёт» при низкой нагрузке на CPU. Без этого не объяснить ни async/await, ни thread pool starvation.

Объяснение

Thread

Поток ОС с собственным стеком (~1 МБ по умолчанию) и состоянием выполнения. Создание и переключение дороги.

var t = new Thread(() => Console.WriteLine($"thread {Environment.CurrentManagedThreadId}"))
{
    IsBackground = true,     // не мешает завершению процесса
    Name = "worker-1",
};
t.Start();
t.Join();                    // дождаться завершения
  • Foreground-потоки держат процесс живым, background — нет.
  • Прямое создание Thread нужно редко: долгие выделенные циклы (LongRunning), работа с нативным кодом или COM (STA), особые приоритеты.
  • Данные потока: [ThreadStatic], ThreadLocal<T>, а в async-коде — AsyncLocal<T> (значение «течёт» вместе с логическим потоком выполнения).

ThreadPool

Пул готовых потоков, который выполняет короткие рабочие элементы: Task.Run, продолжения await, обработчики запросов Kestrel, таймеры.

ThreadPool.QueueUserWorkItem(_ => DoWork());       // низкоуровневый вариант
await Task.Run(() => DoWork());                    // обычный способ
ThreadPool.GetMinThreads(out var minWorker, out var minIo);
ThreadPool.GetAvailableThreads(out var freeWorker, out var freeIo);

Устройство:

  • Число потоков подстраивается под нагрузку: минимум ≈ числу ядер, дальше пул медленно добавляет потоки (примерно 1–2 в секунду сверх минимума, алгоритм hill climbing).
  • Очереди: глобальная и локальные (work stealing) для каждого потока.
  • Отдельные потоки для I/O-завершений (порты завершения на Windows, epoll на Linux) — I/O-операция не занимает поток, пока ждёт.

Thread pool starvation

Все потоки пула заняты блокирующими вызовами (.Result, .Wait(), Thread.Sleep, синхронный I/O), новые запросы стоят в очереди, а пул добавляет потоки медленно. Симптомы:

  • резкий рост латентности при низкой загрузке CPU;
  • растёт очередь пула; таймауты; «замирание» сервиса под нагрузкой.
dotnet-counters monitor -p <pid> System.Runtime
# threadpool-thread-count, threadpool-queue-length, threadpool-completed-items-count

Лечение: убрать блокировки, писать async до конца, ограничивать параллелизм, в крайнем случае поднять ThreadPool.SetMinThreads (заплатка, не решение).

Когда что использовать

ЗадачаИнструмент
Ожидание I/O (БД, HTTP, файлы)async/await, поток не нужен
Короткая CPU-работаTask.Run / Parallel.For (пул)
Очень долгая фоновая работаBackgroundService, Task.Factory.StartNew(..., LongRunning) (отдельный поток)
Периодическая работаPeriodicTimer, BackgroundService
Работа с STA/COMвыделенный Thread с SetApartmentState

Нюансы и подводные камни

  • Потоков много ≠ быстро: переключения контекста и память стеков. Для CPU-bound оптимум около числа ядер.
  • Блокирующий вызов внутри Task.Run в веб-запросе занимает ещё один поток пула: нагрузка растёт, пропускная способность нет.
  • Thread.Sleep в пуле блокирует поток; используйте await Task.Delay.
  • Исключение в потоке без обработки роняет процесс.
  • Приоритеты потоков почти не влияют на поведение современных ОС; не полагайтесь на них.
  • Число потоков в контейнере привязано к CPU-лимиту: при 0.5 CPU пул считает 1 ядро и стартует медленнее.

Практика

  • Воспроизвести starvation: 200 параллельных запросов с Thread.Sleep(1000) в обработчике, наблюдать очередь в dotnet-counters.
  • Заменить Thread.Sleep на await Task.Delay и сравнить.
  • Сравнить время выполнения CPU-задачи на 1, числе ядер и 100 потоках.

Вопросы с ответами

Связанные темы