13. Пул потоков и «настоящая» асинхронность¶
О главе
Цель: понимать устройство пула потоков (очереди, кража работы, когда пул добавляет потоки), видеть голодание своими глазами и знать, почему Task.Wait() пул «чинит» лучше, чем Thread.Sleep.
Лабораторная: start/ — блокирующая нагрузка на пул (32 × Thread.Sleep(1000)). final/ — три способа «ждать секунду» (starve), выключение кооперативной блокировки (coop-off), очереди пула (queues) и ввод-вывод при занятом пуле (io). Запуск: dotnet run -c Release --project final -- <режим>.
Визуализация: Очереди пула — Симуляция Dequeue и график роста числа потоков из опытов главы.
Статус: ✅ проверено на стенде Ubuntu 26.04 (2 ядра, рантайм 10.0.12) и перепроверено на Windows 11 (8 ядер; опыты также с DOTNET_PROCESSOR_COUNT=2). Принципы одинаковы, числа зависят от числа ядер; различия — во вкладках.
13.1. Очереди пула¶
В главе 5 мы видели, что продолжение «уходит в пул». Теперь посмотрим, что это значит. Из CoreLib 10.0.12 (ThreadPoolWorkQueue):
- Поставка из рабочего потока пула (и без
forceGlobal) идёт в локальную очередь этого потока. Так работаютTask.Runизнутри пула, продолженияawait,ThreadPool.UnsafeQueueUserWorkItem(..., preferLocal: true). - Поставка из любого другого потока (основной, таймер, epoll) или с
preferLocal: false— в глобальную очередь,ConcurrentQueue, то есть FIFO. - Если рабочих потоков для этой работы может не хватить, пул просит ещё один (подробнее в §13.2).
- Свободный рабочий поток сначала смотрит в свою локальную очередь, и забирает оттуда
LocalPop— последний положенный элемент (LIFO). Это даёт локальность кеша: свежая работа работает с тем же, что только что трогал поток. - Если кто-то ждал на
Task.Wait()из потока пула, его локальные задачи перекладываются в очередь высокого приоритета (TransferAllLocalWorkItemsToHighPriorityGlobalQueue), чтобы они не застряли за заблокированным потоком. - Потом общая очередь, FIFO.
- И в последнюю очередь — кража работы: берём из чужой локальной очереди
TrySteal— с другого конца (самый старый элемент).
Опыт: порядок выполнения (dotnet run -c Release --project final -- queues)¶
Рабочий поток W кладёт 5 элементов в локальную очередь (L0…L4, preferLocal: true) и 5 в глобальную (G0…G4), каждый элемент спит 30 мс.
Поток 4 (владелец) выбирает из своей очереди L4, L3, L2, L1 — с конца. Поток 6 (второй, разбуженный EnsureThreadRequested) берёт общие G0, G1, G2, G3 по порядку и крадёт самый старый локальный L0. Остальные запуски дают ту же картину с небольшой перестановкой, кто какой G берёт.
На Windows принцип тот же: владелец берёт L4, L3, … с конца своей очереди, остальные потоки берут G по порядку и крадут L0. Отличается только число участников. На 8 ядрах (вторая вкладка) на пять глобальных элементов нашлось пять разных потоков (7–11), потому что пул сразу разбудил больше потоков, а локальные L1–L3 растащили чужие потоки. На 2 ядрах (третья вкладка) картина как на Linux: два потока, владелец доедает локальные с конца. Крали ли L0 или владелец взял его последним — зависит от гонки и от запуска к запуску.
Факт: локальная очередь — стек владельца, а не FIFO
Если из рабочего потока поставить в пул A, B, C (например, Task.Run три раза), владелец выполнит их в порядке C, B, A, а другие потоки станут забирать с начала (A). Порядок постановки не гарантирует порядок выполнения; из не-пулового потока (глобальная очередь) порядок FIFO. Не закладывайтесь ни на один из них.
Интерактивная схема¶
Симуляция Dequeue и график роста числа потоков из опытов главы. Открыть на весь экран.
13.2. Сколько потоков и когда пул добавляет новые¶
- Число потоков адаптивное: от
MinThreads(по умолчанию число логических ядер) доMaxThreads(очень большое). До минимума потоки создаются сразу по необходимости. - Выше минимума добавляют потоки три механизма: hill climbing (меняет цель по измерениям пропускной способности), обнаружение голодания и кооперативная блокировка. Последние два — в
PortableThreadPoolниже.
Обнаружение голодания: «+1 поток не чаще раза в полсекунды»¶
- В очереди есть работа, на которую просили поток.
- И давно никто ничего из очереди не брал (
lastDequeueTime). - Тогда цель — на один поток больше, чем сейчас работает.
- Если процессор загружен (≥ 80 %), ждать нужно
цель × 1000мс: пул не хочет добавлять потоки, когда ядра и так заняты. - Больше 500 мс без единого взятия из очереди — голодание.
Похоже, именно из-за пункта 5 нагрузка из блокирующих задач по секунде разгоняется плохо: каждые ~секунду кто-то из потоков заканчивает работу и берёт следующую из очереди (lastDequeueTime обновляется), так что счётчик «давно ничего не брали» почти не набегает.
Кооперативная блокировка: Task.Wait() предупреждает пул¶
- Локальную очередь освобождаем (см. пункт 5 листинга очередей).
- Сообщаем пулу, что этот поток заблокирован. Работает только на потоке пула и только если включено.
- Когда ожидание закончилось, сообщаем об этом.
- Цель пула:
MinThreads + число заблокированных в Wait. То есть «заблокированные» потоки из счёта выбывают, пул добавляет столько же свободных. - Первые
число ядерпотоков сверх минимума добавляются сразу. - Остальные — пачками по
число ядерпотоков с задержкой, растущей на 25 мс за шаг (до 250 мс). Переключатели:System.Threading.ThreadPool.Blocking.CooperativeBlocking(по умолчаниюtrue),...ThreadsToAddWithoutDelay_ProcCountFactor,...ThreadsPerDelayStep_ProcCountFactor,...DelayStepMs,...MaxDelayMs.
13.3. Голодание пула потоков: эксперимент¶
Предскажите время (и число потоков). Затем dotnet run -c Release --project final: там та же нагрузка выполняется тремя способами (Starve, строки 30–38 final/Program.cs).
MinThreads=4, процессоров=2
t=1.0s потоков=4, в очереди=28
t=2.0s потоков=6, в очереди=22
...
БЛОКИРУЮЩИЕ 32 x 1с : 6.0 с, потоков в пуле 6
t=1.0s потоков=17, в очереди=15
t=2.0s потоков=22, в очереди=12
...
SYNC-OVER-ASYNC 32 x 1с : 5.3 с, потоков в пуле 33
АСИНХРОННЫЕ 32 x 1с : 1.0 с, потоков в пуле 33
MinThreads=4, процессоров=8
t=1.0s потоков=8, в очереди=24
t=2.0s потоков=9, в очереди=15
...
БЛОКИРУЮЩИЕ 32 x 1с : 4.1 с, потоков в пуле 9
t=1.0s потоков=31, в очереди=1
t=2.0s потоков=33, в очереди=0
SYNC-OVER-ASYNC 32 x 1с : 2.1 с, потоков в пуле 33
АСИНХРОННЫЕ 32 x 1с : 1.0 с, потоков в пуле 33
MinThreads=4, процессоров=2
t=1.0s потоков=5, в очереди=27
t=2.0s потоков=5, в очереди=22
...
БЛОКИРУЮЩИЕ 32 x 1с : 7.1 с, потоков в пуле 5
t=1.0s потоков=17, в очереди=15
t=2.0s потоков=22, в очереди=12
...
SYNC-OVER-ASYNC 32 x 1с : 5.5 с, потоков в пуле 33
АСИНХРОННЫЕ 32 x 1с : 1.0 с, потоков в пуле 33
| Способ «ждать секунду» | Linux, 2 ядра | Windows, 8 ядер | Windows, 2 ядра |
|---|---|---|---|
Thread.Sleep(1000) |
6–8 с (start: 7,0, 7,0; final: 6,0, 6,0, 7,0), потоков 5–6 |
4,1 с, потоков 9 | 7,1 с, потоков 5 |
Task.Delay(1000).Wait() (кооперативная блокировка включена) |
5,3 с (два запуска подряд), потоков 33 | 2,1 с, потоков 33 | 5,5 с, потоков 33 |
Task.Delay(1000).Wait(), CooperativeBlocking = false (coop-off) |
25,5 с, потоков 33 | 18,3 с, потоков 33 | 22,9 с, потоков 33 |
await Task.Delay(1000) |
1,0 с | 1,0 с | 1,0 с |
Факт: разница между Linux и Windows здесь — это разница в числе ядер, а не в ОС
На Windows с 8 ядрами пул справляется заметно быстрее (4,1 против 6–8 с). Чтобы отделить ОС от ядер, тот же final запущен на Windows с DOTNET_PROCESSOR_COUNT=2: получилось 7,1 / 5,5 / 22,9 с и 5 / 33 / 33 потока, то есть то же, что на Linux с 2 ядрами. Значит, на этих опытах пул ведёт себя на обеих ОС одинаково, а числа определяются Environment.ProcessorCount: по листингу выше первые «число ядер» потоков сверх минимума добавляются сразу, остальные — пачками такого же размера. На 8 ядрах первая пачка закрывает почти всю нагрузку (с MinThreads = 4 в первую секунду уже 8–9 потоков, при блокирующем Task.Wait — 31). Скорость роста в coop-off — примерно один поток в секунду на обеих ОС: на Linux 4 → 33 за 25,5 с, на Windows 9 → 33 за 18,3 с (≈ 1,3 в секунду).
Факт: Thread.Sleep и Task.Wait для пула — разные вещи
С Thread.Sleep пул «не знает», что поток заблокирован: за 6–8 секунд добавляется один-два потока (в первой версии курса было 8 секунд и 5 потоков). С Task.Delay().Wait() пул получает уведомление и быстро (в первую секунду 4 → 17 потоков) доводит число потоков до числа заблокированных. Вывод coop-off: один поток в секунду — настолько медленно работает обнаружение голодания, и именно на этом медленном пути работает всё, что блокируется иначе (Thread.Sleep, ManualResetEvent.WaitOne, синхронный ввод-вывод, lock под нагрузкой).
Для читателя это значит: sync-over-async (.Result, .Wait() на потоке пула) в .NET 10 деградирует мягче, чем раньше, но не бесплатно: 33 потока вместо 4, у каждого свой стек, плюс переключения контекста. Чинить нужно блокировку, а не компенсацию.
Факт: картина с Thread.Sleep нестабильна
Время блокирующей нагрузки колебалось от 6 до 8 секунд, число потоков в конце — от 5 до 6, в зависимости от того, успел ли Gate-поток заметить «голодание» между волнами. Если кто-то говорит «пул добавляет один поток в полсекунды» — это верхняя граница, а не обещание.
Признаки голодания в проде:
- латентность растёт, а CPU низкий;
ThreadPool.PendingWorkItemCount(метрикаthreadpool-queue-length) большой и растёт;ThreadPool.ThreadCountрастёт ступеньками;- таймауты вызовов зависимостей (потому что продолжения не получают потока).
Диагностика: dotnet-counters monitor (ThreadPool Queue Length, Thread Count), dotnet-stack/dotnet-dump (много потоков, стоящих в Wait/Result/Monitor.Enter), трассировка событий пула (подробно — глава 15).
Лечение: убрать синхронные блокировки (.Result, .Wait(), Thread.Sleep, синхронный I/O) из горячего пути; не поднимать MinThreads как первое лекарство (это маскирует причину, но допустимо как временная мера).
13.4. «Пока ждём ввод-вывод, потока нет»¶
Это верно только для по-настоящему асинхронного ввода-вывода: сокеты, HTTP, БД-драйверы с async-API. Операция передаётся ОС, поток освобождается, по завершении ОС уведомляет рантайм, и продолжение берёт свободный поток пула.
Это не верно для:
Task.Run(() => File.ReadAllText(...))— «async» просто занимает другой поток на всё время блокирующего вызова;- драйверов, у которых
async-методы внутри синхронные.
Но есть и вторая сторона: ожидание бесплатно, а завершение — нет. Режим io (dotnet run -c Release --project final -- io) занимает все потоки пула (SetMaxThreads(n, n), где n = Environment.ProcessorCount — меньше числа ядер значение не принимается: на 8 ядрах SetMaxThreads(2, 2) вернёт false и пул не ограничится) и смотрит, завершится ли ввод-вывод.
Факт: завершение ввода-вывода тоже требует потока пула
Данные для сокета уже лежали в ядре, файл читался с диска, и ни одно чтение не завершилось, пока пул был занят. Как только пул освободили, оба завершились. На Linux поток epoll только узнаёт «пришли данные» и ставит обработку в пул; чтение файла (FileStream с useAsync: true) тоже выполняется рабочим потоком пула: useAsync: true на Linux не делает чтение «настоящим». Следствие: голодание пула тормозит любые await на вводе-выводе, а не только Task.Run. Это одна из причин, почему таймауты зависимостей возникают при забитом пуле. На Windows результат тот же (вывод выше), хотя механика другая: сокет и файл с useAsync: true там действительно читает ядро через перекрывающийся ввод-вывод (overlapped I/O), а не рабочий поток, но завершение всё равно обрабатывает рабочий поток пула. Поток .NET ThreadPool IO в PortableThreadPool.IOCompletionPoller только вызывает GetQueuedCompletionStatusEx и ставит события в очередь (ThreadPoolTypedWorkItemQueue.BatchEnqueue), а вызывает завершающие обратные вызовы уже рабочий поток пула (проверено декомпиляцией CoreLib 10.0.12). Поэтому «IOCP-потоки спасают от голодания рабочих потоков» в .NET 10 неверно ни на Windows, ни на Linux.
Итоги¶
- Продолжения и
Task.Runизнутри пула идут в локальную очередь потока (LIFO для владельца), остальное — в глобальную (FIFO); свободные потоки крадут работу с «старого» конца чужих очередей. - Пул добавляет потоки выше
MinThreadsмедленно: голодание обнаруживается раз в ~500 мс. Исключение — кооперативная блокировка:Task.Wait()/.Resultна потоке пула предупреждает пул, и он быстро растёт (за секунду 4 → 17 потоков на нашем стенде). Thread.Sleepи синхронный ввод-вывод пулу не сообщают о блокировке: восстановление идёт по одному потоку.- Асинхронное ожидание не занимает потоков, но завершение ввода-вывода (и на Linux, и на Windows) всё равно требует свободного потока пула.
- Лечение голодания — убрать блокировки, а не поднять
MinThreads.
Код лабораторной¶
Запуск из папки главы: dotnet run -c Release --project start или --project final.
| final/Program.cs | |
|---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 | |