Thread и ThreadPool
↑ 2.3 Асинхронность и многопоточность · → Следующая
Зачем это на собесе
Основа основ конкурентности: чем поток отличается от задачи, как работает пул потоков и почему при блокирующих вызовах сервис «встаёт» при низкой нагрузке на 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 потоках.
Вопросы с ответами
Чем Thread отличается от ThreadPool?
Thread — отдельный поток ОС, создаётся и управляется вручную (дорого). ThreadPool — набор переиспользуемых потоков, которые выполняют короткие рабочие элементы; создание и число потоков регулирует рантайм.
Что такое thread pool starvation и как его найти?
Все потоки пула заняты блокирующими вызовами, новые задачи ждут в очереди, пул наращивает потоки медленно. Признаки: рост латентности при низком CPU, рост
threadpool-queue-lengthвdotnet-counters.
Почему async/await повышает пропускную способность сервера?
Во время ожидания I/O поток не блокируется и возвращается в пул, поэтому меньше потоков обслуживает больше запросов.
Что такое foreground и background поток?
Foreground-поток удерживает процесс до своего завершения. Background-поток завершается принудительно при выходе последнего foreground-потока.
Как передавать данные «вдоль» асинхронного вызова?
Через
AsyncLocal<T>: значение сохраняется при переходах между потоками пула в рамках логического потока выполнения (используется для корреляции, контекста запроса).
Связанные темы
- Следующая: Task и TPL
- Процессы и потоки в ОС: Процессы и потоки, контекстное переключение
- async/await изнутри: async∕await изнутри: state machine, SynchronizationContext, ConfigureAwait
- Антипаттерны: Антипаттерны: async void, .Result, sync over async