Compartilhar via


Depurar privação do ThreadPool

Este artigo se aplica a: ✔️ .NET 9.0 e versões posteriores

Neste tutorial, você aprenderá a depurar um cenário de esgotamento do ThreadPool. A escassez de ThreadPool ocorre quando o pool não tem threads disponíveis para desempenhar novos itens de trabalho e geralmente faz com que os aplicativos respondam lentamente. Usando o exemplo fornecido aplicativo web ASP.NET Core, você pode causar intencionalmente a exaustão do pool de threads e aprender como diagnosticá-la.

Neste tutorial, você irá:

  • Investigar um aplicativo que está respondendo às solicitações lentamente
  • Use a ferramenta dotnet-counters para identificar se provavelmente está ocorrendo saturação do ThreadPool.
  • Use as ferramentas dotnet-stack e dotnet-trace para determinar qual trabalho está mantendo ocupados os threads do ThreadPool.

Pré-requisitos

O tutorial usa:

Executar o aplicativo de exemplo

Baixe o código do aplicativo de exemplo e execute-o usando o SDK do .NET:

E:\demo\DiagnosticScenarios>dotnet run
Using launch settings from E:\demo\DiagnosticScenarios\Properties\launchSettings.json...
info: Microsoft.Hosting.Lifetime[14]
      Now listening on: https://localhost:5001
info: Microsoft.Hosting.Lifetime[14]
      Now listening on: http://localhost:5000
info: Microsoft.Hosting.Lifetime[0]
      Application started. Press Ctrl+C to shut down.
info: Microsoft.Hosting.Lifetime[0]
      Hosting environment: Development
info: Microsoft.Hosting.Lifetime[0]
      Content root path: E:\demo\DiagnosticScenarios

Se você utilizar um navegador da web e enviar solicitações para https://localhost:5001/api/diagscenario/taskwait, deverá ver a resposta success:taskwait retornada após cerca de 500 ms. Isso mostra que o servidor Web está atendendo ao tráfego conforme o esperado.

Observe o desempenho lento

O servidor Web de demonstração tem vários pontos de extremidade que simulam a realização de uma solicitação de banco de dados e, em seguida, retornam uma resposta ao usuário. Cada um desses pontos de extremidade tem um atraso de aproximadamente 500 ms ao atender solicitações uma de cada vez, mas o desempenho é muito pior quando o servidor Web é submetido a alguma carga. Baixe a ferramenta de teste de carga Bombardier e observe a diferença de latência quando 125 solicitações simultâneas são enviadas para cada ponto de extremidade.

bombardier-windows-amd64.exe https://localhost:5001/api/diagscenario/taskwait
Bombarding https://localhost:5001/api/diagscenario/taskwait for 10s using 125 connection(s)
[=============================================================================================] 10s
Done!
Statistics        Avg      Stdev        Max
  Reqs/sec        33.06     234.67    3313.54
  Latency         3.48s      1.39s     10.79s
  HTTP codes:
    1xx - 0, 2xx - 454, 3xx - 0, 4xx - 0, 5xx - 0
    others - 0
  Throughput:    75.37KB/s

Este segundo endpoint usa um padrão de código que tem um desempenho ainda pior:

bombardier-windows-amd64.exe https://localhost:5001/api/diagscenario/tasksleepwait
Bombarding https://localhost:5001/api/diagscenario/tasksleepwait for 10s using 125 connection(s)
[=============================================================================================] 10s
Done!
Statistics        Avg      Stdev        Max
  Reqs/sec         1.61      35.25     788.91
  Latency        15.42s      2.18s     18.30s
  HTTP codes:
    1xx - 0, 2xx - 140, 3xx - 0, 4xx - 0, 5xx - 0
    others - 0
  Throughput:    36.57KB/s

Ambos os pontos de extremidade mostram significativamente mais do que a latência média de 500 ms quando a carga é alta (3,48 s e 15,42 s, respectivamente). Se você executar este exemplo em uma versão mais antiga do .NET Core, é provável que ambos os exemplos sejam executados igualmente mal. O .NET 6 atualizou a heurística do ThreadPool que reduz o impacto no desempenho do padrão de codificação incorreto usado no primeiro exemplo.

Detectar o esgotamento do ThreadPool

Se você observasse o comportamento acima em um serviço do mundo real, saberia que ele está respondendo lentamente sob carga, mas não saberia a causa. dotnet-counters é uma ferramenta que pode mostrar contadores de desempenho ao vivo. Esses contadores podem fornecer pistas sobre certos problemas e muitas vezes são fáceis de obter. Em ambientes de produção, você pode ter contadores semelhantes fornecidos por ferramentas de monitoramento remoto e painéis da Web. Instale contadores dotnet e comece a monitorar o serviço Web:

dotnet-counters monitor -n DiagnosticScenarios
Press p to pause, r to resume, q to quit.
    Status: Running

Name                                                       Current Value
[System.Runtime]
    dotnet.assembly.count ({assembly})                               115
    dotnet.gc.collections ({collection})
        gc.heap.generation
        gen0                                                           2
        gen1                                                           1
        gen2                                                           1
    dotnet.gc.heap.total_allocated (By)                       64,329,632
    dotnet.gc.last_collection.heap.fragmentation.size (By)
        gc.heap.generation
        gen0                                                     199,920
        gen1                                                      29,208
        gen2                                                           0
        loh                                                           32
        poh                                                            0
    dotnet.gc.last_collection.heap.size (By)
        gc.heap.generation
        gen0                                                     208,712
        gen1                                                   3,456,000
        gen2                                                   5,065,600
        loh                                                       98,384
        poh                                                    3,147,488
    dotnet.gc.last_collection.memory.committed_size (By)      31,096,832
    dotnet.gc.pause.time (s)                                           0.024
    dotnet.jit.compilation.time (s)                                    1.285
    dotnet.jit.compiled_il.size (By)                             565,249
    dotnet.jit.compiled_methods ({method})                         5,831
    dotnet.monitor.lock_contentions ({contention})                   148
    dotnet.process.cpu.count ({cpu})                                  16
    dotnet.process.cpu.time (s)
        cpu.mode
        system                                                         2.156
        user                                                           2.734
    dotnet.process.memory.working_set (By)                             1.3217e+08
    dotnet.thread_pool.queue.length ({work_item})                      0
    dotnet.thread_pool.thread.count ({thread})                         0
    dotnet.thread_pool.work_item.count ({work_item})              32,267
    dotnet.timer.count ({timer})                                       0

Os contadores anteriores são um exemplo enquanto o servidor Web não estava atendendo solicitações. Execute o Bombardier novamente com o endpoint api/diagscenario/tasksleepwait e carga sustentada por 2 minutos, permitindo tempo suficiente para observar o que acontece com os contadores de desempenho.

bombardier-windows-amd64.exe https://localhost:5001/api/diagscenario/tasksleepwait -d 120s

O esgotamento do ThreadPool ocorre quando não há threads livres para lidar com os itens de trabalho enfileirados e o runtime reage aumentando o número de threads do ThreadPool. O valor dotnet.thread_pool.thread.count aumenta rapidamente para 2-3 vezes o número de núcleos de processador no seu computador e, em seguida, threads adicionais são adicionados à taxa de 1 a 2 por segundo até estabilizar acima de 125. Os principais sinais de que a fome do ThreadPool é atualmente um gargalo de desempenho são o aumento lento e constante dos threads do ThreadPool e do uso da CPU muito menos de 100%. O aumento da contagem de threads continuará até que o pool atinja o número máximo de threads, sejam criadas threads suficientes para satisfazer todos os itens de trabalho de entrada, ou a CPU seja saturada. Muitas vezes, mas nem sempre, a saturação do ThreadPool também mostrará valores grandes para dotnet.thread_pool.queue.length e valores baixos para dotnet.thread_pool.work_item.count, o que significa que há uma grande quantidade de trabalho pendente e pouco trabalho sendo concluído. Aqui está um exemplo dos contadores enquanto a contagem de threads ainda está aumentando:

[System.Runtime]
    dotnet.assembly.count ({assembly})                               115
    dotnet.gc.collections ({collection})
        gc.heap.generation
        gen0                                                           5
        gen1                                                           1
        gen2                                                           1
    dotnet.gc.heap.total_allocated (By)                       1.6947e+08
    dotnet.gc.last_collection.heap.fragmentation.size (By)
        gc.heap.generation
        gen0                                                           0
        gen1                                                     348,248
        gen2                                                           0
        loh                                                           32
        poh                                                            0
    dotnet.gc.last_collection.heap.size (By)
        gc.heap.generation
        gen0                                                           0
        gen1                                                  18,010,920
        gen2                                                   5,065,600
        loh                                                       98,384
        poh                                                    3,407,048
    dotnet.gc.last_collection.memory.committed_size (By)      66,842,624
    dotnet.gc.pause.time (s)                                           0.05
    dotnet.jit.compilation.time (s)                                    1.317
    dotnet.jit.compiled_il.size (By)                             574,886
    dotnet.jit.compiled_methods ({method})                         6,008
    dotnet.monitor.lock_contentions ({contention})                   194
    dotnet.process.cpu.count ({cpu})                                  16
    dotnet.process.cpu.time (s)
        cpu.mode
        system                                                         4.953
        user                                                           6.266
    dotnet.process.memory.working_set (By)                             1.3217e+08
    dotnet.thread_pool.queue.length ({work_item})                      0
    dotnet.thread_pool.thread.count ({thread})                       133
    dotnet.thread_pool.work_item.count ({work_item})              71,188
    dotnet.timer.count ({timer})                                     124

Depois que a contagem de threads do ThreadPool se estabilizar, o pool não estará mais sobrecarregado. Mas, se ele se estabilizar em um valor alto (mais de três vezes o número de núcleos do processador), isso geralmente indica que o código do aplicativo está bloqueando alguns threads do ThreadPool e que o ThreadPool está compensando executando com mais threads. Operar de forma constante com altos números de threads não necessariamente terá grandes impactos na latência da solicitação, mas se a carga variar drasticamente ao longo do tempo ou se o aplicativo for reiniciado periodicamente, cada vez que o ThreadPool provavelmente entrará em um período de esgotamento, durante o qual aumentará lentamente o número de threads e resultará em alta latência nas solicitações. Cada thread também consome memória, portanto, reduzir o número total de threads necessários oferece outro benefício.

A partir do .NET 6, a heurística do ThreadPool foi modificada para aumentar o número de threads do ThreadPool muito mais rapidamente em resposta a determinadas APIs de tarefa de bloqueio. O esgotamento do ThreadPool ainda pode ocorrer com essas APIs, mas a duração é muito mais breve do que era com versões mais antigas do .NET, porque o ambiente de execução responde mais rapidamente. Execute o Bombardier novamente com o api/diagscenario/taskwait endpoint:

bombardier-windows-amd64.exe https://localhost:5001/api/diagscenario/taskwait -d 120s

No .NET 6, você deve observar que o pool aumenta a contagem de threads mais rapidamente do que antes e, em seguida, estabiliza em um alto número de threads. A fome do ThreadPool está ocorrendo enquanto a contagem de threads está subindo.

Resolver a insuficiência de recursos do ThreadPool

Para eliminar a exaustão do ThreadPool, as threads do ThreadPool precisam permanecer desbloqueadas para que estejam disponíveis para lidar com tarefas de trabalho pendentes. Há várias maneiras de determinar o que cada thread estava fazendo. Se o problema ocorrer apenas ocasionalmente, coletar um rastreamento com o dotnet-trace é a melhor maneira de registrar o comportamento do aplicativo ao longo de um período de tempo. Se o problema ocorrer constantemente, você poderá usar a ferramenta dotnet-stack ou capturar um despejo com dotnet-dump que pode ser exibido no Visual Studio. dotnet-stack pode ser mais rápido porque mostra as pilhas de threads imediatamente no console. Mas a análise de dump do Visual Studio oferece melhores visualizações que mapeiam quadros para a origem, o Just My Code pode filtrar quadros de implementação de runtime, e o recurso Pilhas Paralelas pode ajudar a agrupar um grande volume de threads com pilhas semelhantes. Este tutorial mostra as opções dotnet-stack e dotnet-trace. Para obter um exemplo de investigação das pilhas de threads usando o Visual Studio, consulte o vídeo tutorial de Diagnosticando o esgotamento do ThreadPool.

Diagnosticar um problema contínuo com a stack do .NET

Execute o Bombardier novamente para colocar o servidor Web sob carga:

bombardier-windows-amd64.exe https://localhost:5001/api/diagscenario/taskwait -d 120s

Em seguida, execute dotnet-stack para ver os rastreamentos da pilha de threads:

dotnet-stack report -n DiagnosticScenarios

Você deverá ver uma saída longa contendo um grande número de pilhas, muitas das quais têm esta aparência:

Thread (0x25968):
  [Native Frames]
  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`1[System.__Canon].GetResultCore(bool)
  DiagnosticScenarios!testwebapi.Controllers.DiagScenarioController.TaskWait()
  Anonymously Hosted DynamicMethods Assembly!dynamicClass.lambda_method1(pMT: 00007FF7A8CBF658,class System.Object,class System.Object[])
  Microsoft.AspNetCore.Mvc.Core.il!Microsoft.AspNetCore.Mvc.Infrastructure.ActionMethodExecutor+SyncObjectResultExecutor.Execute(class Microsoft.AspNetCore.Mvc.Infrastructure.IActionResultTypeMapper,class Microsoft.Extensions.Internal.ObjectMethodExecutor,class System.Object,class System.Object[])
  Microsoft.AspNetCore.Mvc.Core.il!Microsoft.AspNetCore.Mvc.Infrastructure.ControllerActionInvoker.InvokeActionMethodAsync()
  Microsoft.AspNetCore.Mvc.Core.il!Microsoft.AspNetCore.Mvc.Infrastructure.ControllerActionInvoker.Next(value class State&,value class Scope&,class System.Object&,bool&)
  Microsoft.AspNetCore.Mvc.Core.il!Microsoft.AspNetCore.Mvc.Infrastructure.ControllerActionInvoker.InvokeNextActionFilterAsync()
  Microsoft.AspNetCore.Mvc.Core.il!Microsoft.AspNetCore.Mvc.Infrastructure.ControllerActionInvoker.Next(value class State&,value class Scope&,class System.Object&,bool&)
  Microsoft.AspNetCore.Mvc.Core.il!Microsoft.AspNetCore.Mvc.Infrastructure.ControllerActionInvoker.InvokeNextActionFilterAsync()
  Microsoft.AspNetCore.Mvc.Core.il!Microsoft.AspNetCore.Mvc.Infrastructure.ControllerActionInvoker.Next(value class State&,value class Scope&,class System.Object&,bool&)
  Microsoft.AspNetCore.Mvc.Core.il!Microsoft.AspNetCore.Mvc.Infrastructure.ControllerActionInvoker.InvokeInnerFilterAsync()
  Microsoft.AspNetCore.Mvc.Core.il!Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.Next(value class State&,value class Scope&,class System.Object&,bool&)
  Microsoft.AspNetCore.Mvc.Core.il!Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.InvokeFilterPipelineAsync()
  Microsoft.AspNetCore.Mvc.Core.il!Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.InvokeAsync()
  Microsoft.AspNetCore.Mvc.Core.il!Microsoft.AspNetCore.Mvc.Routing.ControllerRequestDelegateFactory+<>c__DisplayClass10_0.<CreateRequestDelegate>b__0(class Microsoft.AspNetCore.Http.HttpContext)
  Microsoft.AspNetCore.Routing.il!Microsoft.AspNetCore.Routing.EndpointMiddleware.Invoke(class Microsoft.AspNetCore.Http.HttpContext)
  Microsoft.AspNetCore.Authorization.Policy.il!Microsoft.AspNetCore.Authorization.AuthorizationMiddleware+<Invoke>d__6.MoveNext()
  System.Private.CoreLib.il!System.Runtime.CompilerServices.AsyncMethodBuilderCore.Start(!!0&)
  Microsoft.AspNetCore.Authorization.Policy.il!Microsoft.AspNetCore.Authorization.AuthorizationMiddleware.Invoke(class Microsoft.AspNetCore.Http.HttpContext)
  Microsoft.AspNetCore.HttpsPolicy.il!Microsoft.AspNetCore.HttpsPolicy.HttpsRedirectionMiddleware.Invoke(class Microsoft.AspNetCore.Http.HttpContext)
  Microsoft.AspNetCore.HttpsPolicy.il!Microsoft.AspNetCore.HttpsPolicy.HstsMiddleware.Invoke(class Microsoft.AspNetCore.Http.HttpContext)
  Microsoft.AspNetCore.HostFiltering.il!Microsoft.AspNetCore.HostFiltering.HostFilteringMiddleware.Invoke(class Microsoft.AspNetCore.Http.HttpContext)
  Microsoft.AspNetCore.Server.Kestrel.Core.il!Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http.HttpProtocol+<ProcessRequests>d__223`1[System.__Canon].MoveNext()
  System.Private.CoreLib.il!System.Threading.ExecutionContext.RunInternal(class System.Threading.ExecutionContext,class System.Threading.ContextCallback,class System.Object)
  System.Private.CoreLib.il!System.Runtime.CompilerServices.AsyncTaskMethodBuilder`1+AsyncStateMachineBox`1[System.Threading.Tasks.VoidTaskResult,Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http.HttpProtocol+<ProcessRequests>d__223`1[System.__Canon]].MoveNext(class System.Threading.Thread)
  System.Private.CoreLib.il!System.Threading.Tasks.AwaitTaskContinuation.RunOrScheduleAction(class System.Runtime.CompilerServices.IAsyncStateMachineBox,bool)
  System.Private.CoreLib.il!System.Threading.Tasks.Task.RunContinuations(class System.Object)
  System.IO.Pipelines.il!System.IO.Pipelines.StreamPipeReader+<<ReadAsync>g__Core|36_0>d.MoveNext()
  System.Private.CoreLib.il!System.Threading.ExecutionContext.RunInternal(class System.Threading.ExecutionContext,class System.Threading.ContextCallback,class System.Object)
  System.Private.CoreLib.il!System.Runtime.CompilerServices.AsyncTaskMethodBuilder`1+AsyncStateMachineBox`1[System.IO.Pipelines.ReadResult,System.IO.Pipelines.StreamPipeReader+<<ReadAsync>g__Core|36_0>d].MoveNext(class System.Threading.Thread)
  System.Private.CoreLib.il!System.Threading.Tasks.AwaitTaskContinuation.RunOrScheduleAction(class System.Runtime.CompilerServices.IAsyncStateMachineBox,bool)
  System.Private.CoreLib.il!System.Threading.Tasks.Task.RunContinuations(class System.Object)
  System.Private.CoreLib.il!System.Runtime.CompilerServices.AsyncTaskMethodBuilder`1[System.Int32].SetExistingTaskResult(class System.Threading.Tasks.Task`1<!0>,!0)
  System.Net.Security.il!System.Net.Security.SslStream+<ReadAsyncInternal>d__186`1[System.Net.Security.AsyncReadWriteAdapter].MoveNext()
  System.Private.CoreLib.il!System.Threading.ExecutionContext.RunInternal(class System.Threading.ExecutionContext,class System.Threading.ContextCallback,class System.Object)
  System.Private.CoreLib.il!System.Runtime.CompilerServices.AsyncTaskMethodBuilder`1+AsyncStateMachineBox`1[System.Int32,System.Net.Security.SslStream+<ReadAsyncInternal>d__186`1[System.Net.Security.AsyncReadWriteAdapter]].MoveNext(class System.Threading.Thread)
  Microsoft.AspNetCore.Server.Kestrel.Core.il!Microsoft.AspNetCore.Server.Kestrel.Core.Internal.DuplexPipeStream+<ReadAsyncInternal>d__27.MoveNext()
  System.Private.CoreLib.il!System.Threading.ExecutionContext.RunInternal(class System.Threading.ExecutionContext,class System.Threading.ContextCallback,class System.Object)
  System.Private.CoreLib.il!System.Threading.ThreadPoolWorkQueue.Dispatch()
  System.Private.CoreLib.il!System.Threading.PortableThreadPool+WorkerThread.WorkerThreadStart()

Os quadros na parte inferior dessas pilhas indicam que esses threads são threads do ThreadPool:

  System.Private.CoreLib.il!System.Threading.ThreadPoolWorkQueue.Dispatch()
  System.Private.CoreLib.il!System.Threading.PortableThreadPool+WorkerThread.WorkerThreadStart()

E os quadros próximos à parte superior revelam que o thread está bloqueado em uma chamada da função DiagnosticScenarioController.TaskWait() para GetResultCore(bool).

Thread (0x25968):
  [Native Frames]
  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`1[System.__Canon].GetResultCore(bool)
  DiagnosticScenarios!testwebapi.Controllers.DiagScenarioController.TaskWait()

Diagnosticar um problema intermitente usando o dotnet-trace

A abordagem dotnet-stack é eficaz apenas para operações de bloqueio óbvias e consistentes que ocorrem em cada solicitação. Em alguns cenários, o bloqueio ocorre esporadicamente apenas a cada alguns minutos, tornando o dotnet-stack menos útil para diagnosticar o problema. Nesse caso, você pode usar o dotnet-trace para coletar eventos durante um período de tempo e salvá-los em um arquivo nettrace que pode ser analisado posteriormente.

Há um evento específico que ajuda a diagnosticar o esgotamento do pool de threads: o evento WaitHandleWait, que foi introduzido no .NET 9. É emitido quando uma thread é bloqueada por operações como chamadas sync-over-async (por exemplo, Task.Result, Task.Wait, e Task.GetAwaiter().GetResult()) ou por outras operações de bloqueio, como lock, Monitor.Enter, ManualResetEventSlim.Wait e SemaphoreSlim.Wait.

Execute o Bombardier novamente para colocar o servidor Web sob carga:

bombardier-windows-amd64.exe https://localhost:5001/api/diagscenario/taskwait -d 120s

Em seguida, execute dotnet-trace para coletar eventos de espera:

dotnet trace collect -n DiagnosticScenarios --clrevents waithandle --clreventlevel verbose --duration 00:00:30

Isso deve gerar um arquivo nomeado DiagnosticScenarios.exe_yyyyddMM_hhmmss.nettrace contendo os eventos. Esse nettrace pode ser analisado usando duas ferramentas diferentes:

As seções a seguir mostram como usar cada ferramenta para ler o arquivo nettrace.

Analisar um nettrace com o PerfView

  1. Baixe o PerfView e execute-o.

  2. Abra o arquivo nettrace clicando duas vezes nele.

    Captura de tela de um nettrace sendo aberto no PerfView

  3. Clique duas vezes em Grupo Avançado>Qualquer Pilhas. Uma nova janela é aberta.

    Captura de tela da vista de qualquer pilha no PerfView.

  4. Clique duas vezes na linha "Evento Microsoft-Windows-DotNETRuntime/WaitHandleWait/Start".

    Agora você deve ver os rastreamentos de pilha em que os eventos WaitHandleWait foram emitidos. Eles são separados por "WaitSource". Atualmente, há duas fontes: MonitorWait para eventos emitidos por meio de Monitor.Wait e Unknown para todas as outras.

    Imagem da visão geral das pilhas para eventos de espera no PerfView.

  5. Comece com MonitorWait, pois ele representa 64,8% dos eventos. Você pode marcar as caixas de seleção para expandir os rastreamentos de pilha responsáveis pela emissão desse evento.

    Captura de tela da visualização expandida de quaisquer pilhas para eventos de espera no PerfView.

    Este rastreamento de pilha pode ser lido como: Task<T>.Result emitiu um evento WaitHandleWait com WaitSource MonitorWait (Task<T>.Result usa Monitor.Wait para realizar uma espera). Foi chamado por DiagScenarioController.TaskWait, que foi chamado por algum lambda, que foi chamado por algum código ASP.NET

Analisar um nettrace com o Visualizador de Eventos do .NET

  1. Vá para verdie-g.github.io/dotnet-events-viewer.

  2. Arraste e solte o arquivo nettrace.

    Captura de tela da abertura de um nettrace no Visualizador de Eventos do .NET.

  3. Vá para a página Árvore de Eventos , selecione o evento "WaitHandleWaitStart" e selecione Executar consulta.

    Captura de tela de uma consulta de eventos no Visualizador de Eventos do .NET.

  4. Você deve ver os rastreamentos de pilha em que os eventos WaitHandleWait foram emitidos. Clique nas setas para expandir os rastros de pilha responsáveis por emitir esse evento.

    Captura de tela do modo de exibição de árvore no Visualizador de Eventos do .NET.

    Esse rastreamento de pilha pode ser lido como: ManualResetEventSlim.Wait emitiu um evento WaitHandleWait. Foi chamado por Task.SpinThenBlockWait, que foi chamado por Task.InternalWaitCore, que foi chamado por Task<T>.Result, que foi chamado por DiagScenario.TaskWait, que foi chamado por algum lambda, que foi chamado por algum código ASP.NET

Em cenários do mundo real, você pode encontrar muitos eventos de espera gerados por threads fora do grupo de threads. Aqui, você está investigando uma exaustão de pool de threads, portanto, todas as esperas em thread dedicado fora do pool de threads não são relevantes. Você pode identificar se um rastreamento de pilha é proveniente de um pool de threads examinando os primeiros métodos, que devem conter uma menção ao pool de threads (por exemplo, WorkerThread.WorkerThreadStart ou ThreadPoolWorkQueue).

Parte superior de um rastreamento de pilha de threads do pool de threads.

Correção de código

Agora você pode navegar até o código desse controlador no arquivo Controllers/DiagnosticScenarios.cs do aplicativo de exemplo para ver que ele está chamando uma API assíncrona sem usar await. Esse é o padrão de sync-over-async, que é conhecido por bloquear as threads e é a causa mais comum de esgotamento do ThreadPool.

public ActionResult<string> TaskWait()
{
    // ...
    Customer c = PretendQueryCustomerFromDbAsync("Dana").Result;
    return "success:taskwait";
}

Nesse caso, o código pode ser facilmente alterado para usar o async/await, conforme mostrado no TaskAsyncWait() endpoint. O uso de await permite que o thread atual processe outros itens de trabalho enquanto a consulta ao banco de dados está em andamento. Quando a pesquisa de banco de dados for concluída, um thread do ThreadPool retomará a execução. Dessa forma, nenhum thread é bloqueado no código durante cada solicitação.

public async Task<ActionResult<string>> TaskAsyncWait()
{
    // ...
    Customer c = await PretendQueryCustomerFromDbAsync("Dana");
    return "success:taskasyncwait";
}

Executar Bombadier para enviar carga ao endpoint api/diagscenario/taskasyncwait mostra que a contagem de threads do ThreadPool permanece muito menor e que a latência média fica próxima de 500ms ao usar a abordagem de async/await.

>bombardier-windows-amd64.exe https://localhost:5001/api/diagscenario/taskasyncwait
Bombarding https://localhost:5001/api/diagscenario/taskasyncwait for 10s using 125 connection(s)
[=============================================================================================] 10s
Done!
Statistics        Avg      Stdev        Max
  Reqs/sec       227.92     274.27    1263.48
  Latency      532.58ms    58.64ms      1.14s
  HTTP codes:
    1xx - 0, 2xx - 2390, 3xx - 0, 4xx - 0, 5xx - 0
    others - 0
  Throughput:    98.81KB/s