15. Диагностика async-кода¶
О главе
Цель: разбирать зависший или медленный async-код в живом процессе и по дампу: видеть, кто кого ждёт, и отличать дедлок от голодания пула.
Лабораторная: start/ — дедлок на однопоточном контексте (подключайтесь инструментами). final/ — плюс голодание пула (starve) и диагностика изнутри процесса (events: EventListener и встроенные метрики). Запуск: dotnet run -c Release --project final -- deadlock|starve|events [секунд].
Статус: ✅ проверено на стенде Ubuntu 26.04, 2 ядра, .NET 10.0.12, инструменты dotnet-* версии 10.0.750501. Перепроверено на Windows 11 (8 ядер): те же инструменты и те же выводы (стеки, dumpasync, счётчики, threadpool -ti, Reason=6, topN); различия — во вкладках и в абзаце про Windows ниже. Инструменты лежат в манифесте chapters/.config/dotnet-tools.json: dotnet tool restore, затем dotnet tool run dotnet-stack … (или dotnet dotnet-stack …). Окна Visual Studio (Parallel Stacks, Tasks) я не проверял: см. §15.6.
15.1. Два вида «зависло»¶
| Признак | Дедлок (глава 6) | Голодание пула (глава 13) |
|---|---|---|
| CPU | около нуля | около нуля или низкий |
| Потоки | один-два заблокированы в Wait/Result |
много потоков заняты блокирующей работой |
| Очередь пула | пуста | растёт |
| Лечится само | никогда | когда нагрузка спадёт |
Лабораторная воспроизводит оба случая. Начнём с дедлока.
Строка 14 вешает «UI»-поток: .Result блокирует единственный поток контекста, а продолжение FetchAsync после Task.Delay (строка 29) обязано вернуться именно в него (строка 23).
15.2. dotnet-stack: стеки потоков без дампа¶
dotnet tool run dotnet-stack report -p <PID> печатает управляемые стеки всех потоков живого процесса.
Thread (0x13BA1):
[Native Frames]
System.Private.CoreLib!System.Threading.Monitor.Wait(class System.Object,int32)
System.Private.CoreLib!System.Threading.ManualResetEventSlim.Wait(int32,value class System.Threading.CancellationToken)
System.Private.CoreLib!System.Threading.Tasks.Task.SpinThenBlockingWait(int32,value class System.Threading.CancellationToken)
System.Private.CoreLib!System.Threading.Tasks.Task.InternalWaitCore(int32,value class System.Threading.CancellationToken)
System.Private.CoreLib!System.Threading.Tasks.Task.InternalWait(int32,value class System.Threading.CancellationToken)
System.Private.CoreLib!System.Threading.Tasks.Task`1[System.__Canon].GetResultCore(bool)
Ch15.Final!Hangs+<>c.<Deadlock>b__0_0(class System.Object)
Ch15.Final!Hangs+SingleThreadContext.<.ctor>b__1_0()
...
Thread (0x13BA4):
System.Private.CoreLib!System.Threading.LowLevelLifoSemaphore.WaitNative(...)
System.Private.CoreLib!System.Threading.PortableThreadPool+WorkerThread.WorkerThreadStart()
Thread (0x2894):
[Native Frames]
System.Private.CoreLib.il!System.Threading.Monitor.Wait(class System.Object,int32)
System.Private.CoreLib.il!System.Threading.ManualResetEventSlim.Wait(int32,value class System.Threading.CancellationToken)
System.Private.CoreLib.il!System.Threading.Tasks.Task.SpinThenBlockingWait(int32,value class System.Threading.CancellationToken)
System.Private.CoreLib.il!System.Threading.Tasks.Task.InternalWaitCore(int32,value class System.Threading.CancellationToken)
System.Private.CoreLib.il!System.Threading.Tasks.Task.InternalWait(int32,value class System.Threading.CancellationToken)
System.Private.CoreLib.il!System.Threading.Tasks.Task`1[System.__Canon].GetResultCore(bool)
Ch15.Final!Hangs+<>c.<Deadlock>b__0_0(class System.Object)
Ch15.Final!Hangs+SingleThreadContext.<.ctor>b__1_0()
...
Thread (0x6B38):
[Native Frames]
System.Private.CoreLib.il!Interop+Kernel32.GetQueuedCompletionStatus(int,unsigned int32&,unsigned int&,int&,int32)
System.Private.CoreLib.il!System.Threading.LowLevelLifoSemaphore.WaitForSignal(int32)
System.Private.CoreLib.il!System.Threading.PortableThreadPool+WorkerThread.WorkerThreadStart()
Факт: по стеку видно, что поток ждёт .Result, но не кого
Поток «UI» стоит в Task.GetResultCore → SpinThenBlockingWait → Monitor.Wait внутри нашего обработчика. Это приговор sync-over-async, но на какую именно задачу он смотрит и почему она не завершается, стек не говорит. Рабочие потоки пула простаивают (WorkerThreadStart → семафор): пул ни при чём. Ответ лежит на куче, в дампе.
15.3. dotnet-dump: дамп и dumpasync¶
dotnet tool run dotnet-dump collect -p <PID> -o hang.dmp
dotnet tool run dotnet-dump analyze hang.dmp -c "dumpasync --tasks --completed --fields" -c "exit"
dumpasync собирает по куче все Task и боксы машин состояний и связывает их по продолжениям в «стеки»: это асинхронный стек вызовов, которого в обычных стеках потоков нет.
STACK 2
000075d8c640cf98 ... (RanToCompletion|CompletionReserved) System.Threading.Tasks.Task+DelayPromise
... m_continuationObject 000075d8c640cbe0 (TaskCompletionSentinel)
STACK 3
<< Awaiting: 000075d8c640da28 ... System.Runtime.CompilerServices.TaskAwaiter >>
000075d8c640d9d0 ... (0) Hangs+<FetchAsync>d__2
<>1__state 0
name "sales"
000075d8c640daa8 ... (0) Hangs+<LoadReportAsync>d__1
000075d8c640dba0 ... () System.Threading.Tasks.Task+SetOnInvokeMres
Как это читать:
- Стек 3 — цепочка ожидания:
FetchAsyncждёт (<< Awaiting >>), её ждётLoadReportAsync, её ждётSetOnInvokeMres— это и есть блокирующий.Result(сигнал, которымWaitбудится). FetchAsyncв состоянии 0, то есть всё ещё стоит наawait Task.Delay.- Стек 2: но сама
DelayPromiseужеRanToCompletion, а еёm_continuationObject—TaskCompletionSentinel(продолжения уже отданы на исполнение).
Факт: правило чтения дампа — «машина ждёт уже завершённую задачу»
Если dumpasync показывает машину состояний, ждущую задачу в RanToCompletion, значит, продолжение запланировано, но не выполняется: оно стоит в очереди контекста (SynchronizationContext, TaskScheduler) или пула, который занят. Дальше смотрим, кто занимает этот контекст: в dotnet-stack это поток «UI» в Wait. Дедлок найден: поток ждёт задачу, чьё продолжение ждёт этот же поток. Без ключа --completed стек 2 не покажется, и картина будет неполной.
15.4. Голодание пула: счётчики, дамп, события¶
Режим starve создаёт 20 блокирующих обработчиков в секунду (по 3 с Thread.Sleep) при MinThreads = 4 и раз в секунду замеряет, сколько ждёт самая простая работа в очереди пула.
Вторая проба вернулась только через 11,5 секунды: «простая» работа стояла в очереди за блокирующей. Это и есть главный симптом голодания: задержка пула, а не CPU.
dotnet-counters¶
dotnet tool run dotnet-counters monitor -p <PID> --counters System.Runtime
dotnet tool run dotnet-counters collect -p <PID> --format csv -o c.csv --counters System.Runtime --duration 00:00:06
В .NET 10 метрики пула называются dotnet.thread_pool.thread.count, dotnet.thread_pool.queue.length, dotnet.thread_pool.work_item.count. В CSV они приходят как «Rate» — приращение за секунду:
dotnet.thread_pool.work_item.count 6 3 2 6 4
dotnet.thread_pool.queue.length 15 17 -1 14 17
dotnet.thread_pool.thread.count 0 1 0 0 0
Приращение числа потоков — 0 или 1 в секунду, а очередь пополняется на 14–17 элементов в секунду. Работы приходит больше, чем пул завершает, а потоки добавляются по одному: голодание.
dotnet-dump analyze … threadpool -ti¶
Using the Portable thread pool.
CPU utilization: 10%
Workers Total: 13
Workers Running: 13
Workers Idle: 0
Worker Min Limit: 4
Worker Max Limit: 32767
Hill Climbing Log:
Time Transition #New Threads #Samples Throughput
-5.59 Initializing 8 0 0.00
-5.08 Starvation 9 0 0.00
-4.56 Starvation 10 0 0.00
-3.55 Starvation 11 0 0.00
-1.02 Starvation 12 0 0.00
0.00 Starvation 13 0 0.00
Все рабочие потоки заняты (Idle: 0; на Linux 11, на Windows 13), а журнал Hill Climbing показывает, что новые потоки появлялись по причине Starvation, примерно по одному в секунду, как в главе 13. Разные числа потоков — следствие числа ядер: пул стартует с MinThreads = числу ядер (в журнале Windows первая строка — 8), остальное добавляется по одному. В dotnet-stack те же потоки стоят в Thread.Sleep внутри Hangs.Handler — это и есть блокировка, которую нужно убирать.
Изнутри процесса: EventListener и MeterListener¶
Режим events делает то же без внешних инструментов. Читаются события рантайма с ключевым словом ThreadingKeyword (0x10000) и встроенные метрики System.Runtime:
ThreadPoolWorkerThreadAdjustmentAdjustment: AverageThroughput=0, NewWorkerThreadCount=4, Reason=1
ThreadPoolWorkerThreadAdjustmentAdjustment: ..., NewWorkerThreadCount=5, Reason=6
ThreadPoolWorkerThreadAdjustmentAdjustment: ..., NewWorkerThreadCount=6, Reason=6
...
[метрика] dotnet.thread_pool.thread.count = 9
[метрика] dotnet.thread_pool.queue.length = 67
Факт: Reason=6 — это Starvation
Причина подстройки пула — числовое значение перечисления StateOrTransition из PortableThreadPool.HillClimbing (CoreLib 10.0.12): 0 Warmup, 1 Initializing, 2 RandomMove, 3 ClimbingMove, 4 ChangePoint, 5 Stabilizing, 6 Starvation, 7 ThreadTimedOut, 8 CooperativeBlocking. Серия событий с Reason=6 и растущим NewWorkerThreadCount — прямое доказательство голодания; Reason=8 — быстрая реакция пула на Task.Wait() (глава 13). События можно отправлять в свою телеметрию.
15.5. dotnet-trace: запись событий¶
dotnet tool run dotnet-trace collect -p <PID> --providers "Microsoft-Windows-DotNETRuntime:0x10000:4,System.Threading.Tasks.TplEventSource:0x1:4" --duration 00:00:05 -o t.nettrace
Команда на стенде отработала и записала файл около 1 МБ за 5 секунд (режим starve). Разобрать его можно без внешних программ:
dotnet dotnet-trace report t.nettrace topN
dotnet dotnet-trace convert t.nettrace --format Speedscope -o t # t.speedscope.json для speedscope.app
1. EventPipeEventProvider.EventWriteTransfer(...) 100% 100%
2. EventSource.WriteEventWithRelatedActivityIdCore(...) 100% 0%
3. TplEventSource.TaskWaitBegin(int32,int32,int32,...) 100% 0%
4. TaskAwaiter.OutputWaitEtwEvents(Task, Action) 100% 0%
5. ...AwaitUnsafeOnCompleted(...) 100% 0%
Факт: провайдер TPL пишет событие на каждое await, и это видно в самой трассе
Во всех пяти самых частых стеках внутри трассы — TplEventSource.TaskWaitBegin, вызванный из TaskAwaiter.OutputWaitEtwEvents в AwaitUnsafeOnCompleted: когда подписан слушатель TplEventSource, каждый незавершённый await пишет событие. Это и есть та информация, которую провайдер даёт (кто ждёт какую задачу), но и цена: включайте его на короткое время. Для голодания достаточно провайдера рантайма с ключевым словом ThreadingKeyword (события подстройки пула, §15.4). Полный разбор .nettrace с активностями задач — в PerfView или Visual Studio; здесь он не проверялся.
15.6. Visual Studio и Rider¶
Окна Parallel Stacks (режим Tasks) и Tasks в Visual Studio показывают то же, что dumpasync: дерево ожидающих задач и состояние машин. Для зависшего процесса: Debug → Break All, затем окно Tasks. Я проверял только инструменты командной строки, поведение окон отладчика в этой главе не проверялось.
Факт: на Windows то же самое, отличаются имена нативных кадров и цифры
Проверено на Windows 11 (8 ядер, .NET 10.0.12, те же версии инструментов):
- Одинаково:
dotnet-stackпоказывает дедлок какTask.GetResultCore → SpinThenBlockingWait → Monitor.Wait;dumpasyncдаёт те же три стека (DelayPromiseвRanToCompletionсTaskCompletionSentinel, цепочкаFetchAsync → LoadReportAsync → SetOnInvokeMres, состояние 0); метрики называются так же (dotnet.thread_pool.queue.length,...work_item.count,...thread.count) и приходят как приращения за секунду; серия событий сReason=6;topN— те же пять кадровTplEventSource.TaskWaitBegin → OutputWaitEtwEvents. - Отличается: простаивающий рабочий поток на Windows стоит в
Interop+Kernel32.GetQueuedCompletionStatusвнутриLowLevelLifoSemaphore.WaitForSignal(семафор пула построен на порту завершения), на Linux — вLowLevelLifoSemaphore.WaitNative. Имя модуля в стеках —System.Private.CoreLib.il, на Linux —System.Private.CoreLib. Начальные числа потоков и очереди зависят от ядер (8 против 2), см. вкладки. - Не проверялось: окна Visual Studio (см. выше).
15.7. Порядок разбора¶
dotnet-counters: растёт лиqueue.length, растёт лиthread.count, высок ли CPU.dotnet-stack report: у кого висят потоки (Wait/Result/Sleep/Monitor.Enter), простаивает ли пул.- Простаивающий пул и поток в
Wait→ дедлок: дамп иdumpasync --tasks --completed; ищем машину в ожидании уже завершённой задачи. - Занятый пул, растущая очередь → голодание:
threadpool -ti, событияReason=6; ищем блокирующие вызовы в стеках потоков. - Лечение: убрать блокировку (
.Result,.Wait(),Thread.Sleep, синхронный ввод-вывод) из горячего пути (главы 6 и 13).
Итоги¶
- Стек потока говорит, где поток стоит; кого он ждёт, говорит
dumpasync. - Признак дедлока на контексте: машина состояний ждёт уже завершённую задачу, а нужный поток заблокирован.
- Признак голодания: задержка пула растёт,
Idle: 0, подстройки сReason=Starvation. - Диагностику можно встроить в процесс:
EventListenerиMeterListener. - Те же инструменты лежат в манифесте курса и ставятся
dotnet tool restore.
Код лабораторной¶
Запуск из папки главы: dotnet run -c Release --project start или --project final -- starve.