Перейти к содержанию

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 много потоков заняты блокирующей работой
Очередь пула пуста растёт
Лечится само никогда когда нагрузка спадёт

Лабораторная воспроизводит оба случая. Начнём с дедлока.

final/Hangs.cs (фрагмент): дедлок
    // ---- 1. Дедлок: sync-over-async на однопоточном SynchronizationContext (глава 6) ----
    public static void Deadlock(int seconds)
    {
        var ui = new SingleThreadContext();
        ui.Post(_ =>
        {
            Console.WriteLine("[UI] обработчик начал");
            string result = LoadReportAsync("sales").Result;   // блокируем единственный поток контекста
            Console.WriteLine($"[UI] обработчик закончил: {result}");  // сюда не дойдём
        }, null);
        Console.WriteLine($"PID {Environment.ProcessId}. Приложение «зависло»; само завершится через {seconds} с.");
        Thread.Sleep(TimeSpan.FromSeconds(seconds));
    }

    static async Task<string> LoadReportAsync(string name)
    {
        string raw = await FetchAsync(name);       // продолжение обязано вернуться в UI-контекст
        return raw.ToUpperInvariant();
    }

    static async Task<string> FetchAsync(string name)
    {
        await Task.Delay(100);                     // ждём «сеть»
        return $"отчёт {name}";
    }

Строка 14 вешает «UI»-поток: .Result блокирует единственный поток контекста, а продолжение FetchAsync после Task.Delay (строка 29) обязано вернуться именно в него (строка 23).

15.2. dotnet-stack: стеки потоков без дампа

dotnet tool run dotnet-stack report -p <PID> печатает управляемые стеки всех потоков живого процесса.

.NET 10.0.12, дедлок (сокращено)
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()
.NET 10.0.12, дедлок (сокращено)
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 и боксы машин состояний и связывает их по продолжениям в «стеки»: это асинхронный стек вызовов, которого в обычных стеках потоков нет.

dumpasync (сокращено), дедлок
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 и раз в секунду замеряет, сколько ждёт самая простая работа в очереди пула.

final -- starve 12: нагрузка растёт быстрее, чем пул добавляет потоки
PID 80728. Нагрузка из блокирующих обработчиков; завершится через 12 с.
  t= 0.0с  задержка пула      6 мс  потоков   4  в очереди   17
  t=13.5с  задержка пула  11510 мс  потоков   9  в очереди  141
final -- starve 40: та же нагрузка, окно длиннее
PID 20032. Нагрузка из блокирующих обработчиков; завершится через 40 с.
  t= 0.0с  задержка пула      4 мс  потоков   8  в очереди   13
  t= 4.0с  задержка пула   3008 мс  потоков  11  в очереди   60
  t=21.1с  задержка пула  15478 мс  потоков  16  в очереди  219

Вторая проба вернулась только через 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» — приращение за секунду:

collect, по секундам (сокращено)
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

Workers Total:    11
Workers Running:  11
Workers Idle:     0
Worker Min Limit: 4

Hill Climbing Log:
      Time Transition            #New Threads
    -18.51 Initializing                     4
    -17.51 Starvation                       5
    -16.51 Starvation                       6
    -14.00 Starvation                       7
    ...
      0.00 Warmup                          11
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:

final/Inside.cs (фрагмент)
        protected override void OnEventSourceCreated(EventSource source)
        {
            if (source.Name == "Microsoft-Windows-DotNETRuntime")
                EnableEvents(source, EventLevel.Informational, (EventKeywords)0x10000);   // ThreadingKeyword
        }

        protected override void OnEventWritten(EventWrittenEventArgs e)
        {
            _counts.AddOrUpdate(e.EventName ?? $"#{e.EventId}", 1, (_, n) => n + 1);
            if (e.EventName is not null && e.EventName.StartsWith("ThreadPoolWorkerThreadAdjustment") && e.Payload is not null)
                _adjustments.Enqueue(e.EventName + ": " + string.Join(", ", e.PayloadNames!.Zip(e.Payload, (n, v) => $"{n}={v}")));
        }
final -- events 12 (сокращено)
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
report topN (сокращено), трасса режима starve; на Ubuntu 26.04 и на Windows 11 те же пять кадров
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. Порядок разбора

  1. dotnet-counters: растёт ли queue.length, растёт ли thread.count, высок ли CPU.
  2. dotnet-stack report: у кого висят потоки (Wait/Result/Sleep/Monitor.Enter), простаивает ли пул.
  3. Простаивающий пул и поток в Wait → дедлок: дамп и dumpasync --tasks --completed; ищем машину в ожидании уже завершённой задачи.
  4. Занятый пул, растущая очередь → голодание: threadpool -ti, события Reason=6; ищем блокирующие вызовы в стеках потоков.
  5. Лечение: убрать блокировку (.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.

start/Program.cs
// Глава 15. Зависшее приложение: найдите причину снаружи.
// Запуск: dotnet run -c Release --project start
// PREDICT: что покажет dotnet-stack для потока "UI"? Хватит ли его, чтобы понять, кого этот поток ждёт?
// TODO 1: пока программа «висит», выполните dotnet tool run dotnet-stack report -p <PID> (PID печатается).
// TODO 2: снимите дамп (dotnet-dump collect) и найдите в dumpasync машину состояний в состоянии 0, ждущую уже завершённую задачу.
using System.Collections.Concurrent;

var ui = new SingleThreadContext();
ui.Post(_ =>
{
    Console.WriteLine("[UI] обработчик начал");
    string result = LoadReportAsync("sales").Result;          // блокируем единственный поток контекста
    Console.WriteLine($"[UI] обработчик закончил: {result}");   // сюда не дойдём
}, null);
Console.WriteLine($"PID {Environment.ProcessId}. Приложение «зависло»; завершится через 120 с.");
Thread.Sleep(TimeSpan.FromSeconds(120));

static async Task<string> LoadReportAsync(string name)
{
    string raw = await FetchAsync(name);    // продолжение обязано вернуться в UI-контекст
    return raw.ToUpperInvariant();
}

static async Task<string> FetchAsync(string name)
{
    await Task.Delay(100);
    return $"отчёт {name}";
}

sealed class SingleThreadContext : SynchronizationContext
{
    private readonly BlockingCollection<(SendOrPostCallback, object?)> _queue = new();
    public SingleThreadContext()
    {
        new Thread(() =>
        {
            SetSynchronizationContext(this);
            foreach (var (cb, state) in _queue.GetConsumingEnumerable()) cb(state);
        }) { Name = "UI", IsBackground = true }.Start();
    }
    public override void Post(SendOrPostCallback d, object? state) => _queue.Add((d, state));
}
final/Program.cs
// Глава 15. Разбор зависшего async-кода снаружи и изнутри.
//   dotnet run -c Release --project final -- deadlock [секунд]   — дедлок sync-over-async на «UI»-контексте
//   dotnet run -c Release --project final -- starve   [секунд]   — голодание пула потоков
//   dotnet run -c Release --project final -- events   [секунд]   — то же голодание, но с EventListener и счётчиками внутри процесса
// Пока программа «висит», подключайтесь инструментами: см. §15.2–15.5.
int seconds = args.Length > 1 ? int.Parse(args[1]) : 120;
switch (args.FirstOrDefault() ?? "deadlock")
{
    case "deadlock": Hangs.Deadlock(seconds); break;
    case "starve": await Hangs.Starvation(seconds); break;
    case "events": await Inside.RunAsync(seconds); break;
}
final/Hangs.cs
using System.Collections.Concurrent;
using System.Diagnostics;

// Два «зависания», которые мы потом разбираем снаружи инструментами.
static class Hangs
{
    // ---- 1. Дедлок: sync-over-async на однопоточном SynchronizationContext (глава 6) ----
    public static void Deadlock(int seconds)
    {
        var ui = new SingleThreadContext();
        ui.Post(_ =>
        {
            Console.WriteLine("[UI] обработчик начал");
            string result = LoadReportAsync("sales").Result;   // блокируем единственный поток контекста
            Console.WriteLine($"[UI] обработчик закончил: {result}");  // сюда не дойдём
        }, null);
        Console.WriteLine($"PID {Environment.ProcessId}. Приложение «зависло»; само завершится через {seconds} с.");
        Thread.Sleep(TimeSpan.FromSeconds(seconds));
    }

    static async Task<string> LoadReportAsync(string name)
    {
        string raw = await FetchAsync(name);       // продолжение обязано вернуться в UI-контекст
        return raw.ToUpperInvariant();
    }

    static async Task<string> FetchAsync(string name)
    {
        await Task.Delay(100);                     // ждём «сеть»
        return $"отчёт {name}";
    }

    // ---- 2. Голодание пула потоков: блокирующие обработчики + «проба задержки» (глава 13) ----
    public static async Task Starvation(int seconds)
    {
        ThreadPool.SetMinThreads(4, 4);
        Console.WriteLine($"PID {Environment.ProcessId}. Нагрузка из блокирующих обработчиков; завершится через {seconds} с.");
        var end = Stopwatch.StartNew();

        _ = Task.Run(async () =>                   // «входящие запросы»: по 20 штук в секунду
        {
            while (end.Elapsed.TotalSeconds < seconds)
            {
                for (int i = 0; i < 20; i++) ThreadPool.QueueUserWorkItem(_ => Handler());
                await Task.Delay(1000);
            }
        });

        while (end.Elapsed.TotalSeconds < seconds)  // проба: сколько ждёт самая простая работа в очереди пула
        {
            long queued = Stopwatch.GetTimestamp();
            var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
            ThreadPool.QueueUserWorkItem(_ => tcs.SetResult());
            await tcs.Task;
            Console.WriteLine($"  t={end.Elapsed.TotalSeconds,4:F1}с  задержка пула {Stopwatch.GetElapsedTime(queued).TotalMilliseconds,6:F0} мс  потоков {ThreadPool.ThreadCount,3}  в очереди {ThreadPool.PendingWorkItemCount,4}");
            await Task.Delay(1000);
        }
    }

    static void Handler() => Thread.Sleep(3000);   // «синхронный вызов БД» внутри обработчика

    sealed class SingleThreadContext : SynchronizationContext
    {
        private readonly BlockingCollection<(SendOrPostCallback, object?)> _queue = new();
        public SingleThreadContext()
        {
            new Thread(() =>
            {
                SetSynchronizationContext(this);
                foreach (var (cb, state) in _queue.GetConsumingEnumerable()) cb(state);
            }) { Name = "UI", IsBackground = true }.Start();
        }
        public override void Post(SendOrPostCallback d, object? state) => _queue.Add((d, state));
    }
}
final/Inside.cs
using System.Collections.Concurrent;
using System.Diagnostics.Metrics;
using System.Diagnostics.Tracing;

// То же голодание, но диагностика встроена в процесс: события рантайма и встроенные счётчики.
static class Inside
{
    public static async Task RunAsync(int seconds)
    {
        using var events = new PoolEventListener();
        using var meters = StartMeters();

        await Hangs.Starvation(seconds);

        Console.WriteLine();
        Console.WriteLine("События пула за время работы:");
        foreach (var line in events.Summary()) Console.WriteLine("  " + line);
    }

    static MeterListener StartMeters()
    {
        var listener = new MeterListener();
        listener.InstrumentPublished = (instrument, l) =>
        {
            if (instrument.Meter.Name == "System.Runtime" && instrument.Name.StartsWith("dotnet.thread_pool"))
                l.EnableMeasurementEvents(instrument);
        };
        listener.SetMeasurementEventCallback<long>((inst, value, tags, state) =>
            Console.WriteLine($"    [метрика] {inst.Name} = {value}"));
        listener.SetMeasurementEventCallback<int>((inst, value, tags, state) =>
            Console.WriteLine($"    [метрика] {inst.Name} = {value}"));
        listener.Start();
        _ = Task.Run(async () =>                   // метрики-«наблюдатели» читаются по запросу
        {
            while (true) { await Task.Delay(5000); listener.RecordObservableInstruments(); }
        });
        return listener;
    }

    sealed class PoolEventListener : EventListener
    {
        private readonly ConcurrentDictionary<string, int> _counts = new();
        private readonly ConcurrentQueue<string> _adjustments = new();

        protected override void OnEventSourceCreated(EventSource source)
        {
            if (source.Name == "Microsoft-Windows-DotNETRuntime")
                EnableEvents(source, EventLevel.Informational, (EventKeywords)0x10000);   // ThreadingKeyword
        }

        protected override void OnEventWritten(EventWrittenEventArgs e)
        {
            _counts.AddOrUpdate(e.EventName ?? $"#{e.EventId}", 1, (_, n) => n + 1);
            if (e.EventName is not null && e.EventName.StartsWith("ThreadPoolWorkerThreadAdjustment") && e.Payload is not null)
                _adjustments.Enqueue(e.EventName + ": " + string.Join(", ", e.PayloadNames!.Zip(e.Payload, (n, v) => $"{n}={v}")));
        }

        public IEnumerable<string> Summary()
        {
            foreach (var kv in _counts.OrderByDescending(kv => kv.Value)) yield return $"{kv.Key,-50} {kv.Value}";
            yield return "--- первые события подстройки пула ---";
            foreach (var a in _adjustments.Take(12)) yield return a;
        }
    }
}