Freigeben über


Debuggen von ThreadPool-Mangel

Dieser Artikel bezieht sich auf: ✔️ .NET 9.0 und höhere Versionen

In diesem Lernprogramm erfahren Sie, wie Sie ein ThreadPool-Starvation-Szenario debuggen. ThreadPool-Starvation tritt auf, wenn der Pool keine verfügbaren Threads zum Verarbeiten neuer Arbeitsaufgaben hat und häufig dazu führt, dass Anwendungen langsam reagieren. Mithilfe des bereitgestellten Beispiels ASP.NET Core Web App können Sie ThreadPool-Verhungern absichtlich verursachen und lernen, wie man sie diagnostiziert.

In diesem Tutorial werden Sie Folgendes lernen:

  • Untersuchen einer App, die langsam auf Anforderungen reagiert
  • Verwenden Sie das Tool dotnet-counters, um zu identifizieren, dass wahrscheinlich eine ThreadPool-Starvation auftritt.
  • Verwenden Sie die dotnet-stack- und dotnet-trace-Tools, um zu bestimmen, welche Arbeit die ThreadPool-Threads beschäftigt.

Voraussetzungen

Das Tutorial verwendet:

Führen Sie die Beispielanwendung aus

Laden Sie den Code für die Beispiel-App herunter, und führen Sie ihn mit dem .NET SDK aus:

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

Wenn Sie einen Webbrowser verwenden und Anforderungen an https://localhost:5001/api/diagscenario/taskwait senden, dann sollte die Antwort success:taskwait nach ca. 500 ms zurückgegeben wird. Dies zeigt, dass der Webserver Datenverkehr erwartungsgemäß bedient.

Langsame Leistung bemerken

Der Demowebserver verfügt über mehrere Endpunkte, die eine Datenbankanforderung simuliert und dann eine Antwort an den Benutzer zurückgeben. Jeder dieser Endpunkte hat eine Verzögerung von ca. 500 ms, wenn Anforderungen einzeln bedient werden, aber die Leistung ist viel schlechter, wenn der Webserver einer Last ausgesetzt ist. Laden Sie das Bombardier Load Testing Tool herunter, und beobachten Sie den Unterschied bei der Latenz, wenn 125 gleichzeitige Anforderungen an jeden Endpunkt gesendet werden.

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

Dieser zweite Endpunkt verwendet ein Codemuster, das noch schlechter abschneidet.

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

Beide Endpunkte zeigen deutlich mehr als die durchschnittliche Latenz von 500 ms, wenn die Last hoch ist (3,48 s bzw. 15,42 s). Wenn Sie dieses Beispiel auf einer älteren Version von .NET Core ausführen, werden wahrscheinlich beide Beispiele gleichermaßen schlecht ausgeführt. .NET 6 hat ThreadPool-Heuristiken aktualisiert, die die Leistungseinbußen des im ersten Beispiel verwendeten fehlerhaften Codierungsmusters verringern.

Erkennung der ThreadPool-Verhungerung

Wenn Sie das oben beschriebene Verhalten in einem Dienst in der realen Welt beobachtet hätten, würden Sie wissen, dass er unter Last langsam reagiert, aber Sie würden die Ursache nicht kennen. dotnet-counters ist ein Tool, mit dem Liveleistungsindikatoren angezeigt werden können. Diese Indikatoren können Hinweise auf bestimmte Probleme liefern und sind oft leicht zugänglich. In Produktionsumgebungen verfügen Sie möglicherweise über ähnliche Zähler, die von Remote-Überwachungstools und Web-Dashboards bereitgestellt werden. Installieren Sie dotnet-Counter, und beginnen Sie mit der Überwachung des Webdiensts:

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

Die nachfolgenden Zähler sind ein Beispiel, während der Webserver keine Anfragen verarbeitet hat. Führen Sie Bombardier erneut mit dem api/diagscenario/tasksleepwait Endpunkt und einer dauerhaften Last für 2 Minuten aus, damit es viel Zeit gibt, zu beobachten, was mit den Leistungsindikatoren passiert.

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

ThreadPool-Starvation tritt auf, wenn keine freien Threads zum Behandeln der in die Warteschlange eingereihten Arbeitsaufgaben vorhanden sind und die Laufzeit reagiert, indem die Anzahl der ThreadPool-Threads erhöht wird. Der dotnet.thread_pool.thread.count Wert erhöht sich schnell auf 2-3x die Anzahl der Prozessorkerne auf Ihrem Computer, und dann werden weitere Threads 1-2 pro Sekunde hinzugefügt, bis sie irgendwo über 125 stabilisiert werden. Die wichtigsten Anzeichen dafür, dass die Ressourcenknappheit des ThreadPools derzeit ein Leistungsengpass ist, sind die langsame und stetige Zunahme der ThreadPool-Threads und eine CPU-Auslastung, die deutlich unter 100%liegt. Die Threadanzahl wird erhöht, bis entweder der Pool auf die maximale Anzahl von Threads trifft, genügend Threads erstellt wurden, um alle eingehenden Arbeitsaufgaben zu erfüllen, oder die CPU wurde gesättigt. Häufig, aber nicht immer, werden bei einer ThreadPool-Starvation große Werte für dotnet.thread_pool.queue.length und niedrige Werte für dotnet.thread_pool.work_item.count angezeigt, was bedeutet, dass eine große Menge an anstehender Arbeit und wenig Arbeit abgeschlossen wird. Hier ist ein Beispiel der Zähler, während die Anzahl der Threads weiterhin steigt.

[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

Sobald sich die Anzahl der ThreadPool-Threads stabilisiert, leidet der Pool nicht mehr unter Ressourcenknappheit. Wenn sie jedoch mit einem hohen Wert (mehr als etwa dreimal die Anzahl der Prozessorkerne) stabilisiert wird, bedeutet dies in der Regel, dass der Anwendungscode einige ThreadPool-Threads blockiert, und der ThreadPool wird durch Ausführen mit mehr Threads kompensiert. Wenn bei konstant hohen Thread-Anzahlen keine großen Auswirkungen auf die Anforderungslatenz auftreten, aber die Auslastung im Laufe der Zeit stark schwankt oder die Anwendung regelmäßig neu gestartet wird, gerät der ThreadPool wahrscheinlich in eine Phase der Verknappung, in der die Anzahl der Threads langsam erhöht wird und eine schlechte Anforderungslatenz verursacht. Jeder Thread verbraucht außerdem Arbeitsspeicher, sodass das Reduzieren der Gesamtanzahl der benötigten Threads einen zusätzlichen Vorteil bietet.

Ab .NET 6 wurden ThreadPool-Heuristiken geändert, um die Anzahl der ThreadPool-Threads als Reaktion auf bestimmte blockierende Aufgaben-APIs viel schneller zu skalieren. ThreadPool-Überlastung kann immer noch mit diesen APIs auftreten, aber die Dauer ist viel kürzer als bei älteren .NET-Versionen, da die Runtime schneller reagiert. Führen Sie Bombardier erneut mit dem api/diagscenario/taskwait Endpunkt aus:

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

In .NET 6 sollten Sie beobachten, dass der Pool die Threadanzahl schneller erhöht als zuvor und dann bei einer hohen Anzahl von Threads stabilisiert wird. Eine Verknappung der ThreadPool-Ressourcen tritt auf, während die Anzahl der Threads zunimmt.

ThreadPool-Engpass beheben

Um ThreadPool-Starvation zu vermeiden, müssen ThreadPool-Threads nicht blockiert bleiben, damit sie zur Bearbeitung eingehender Aufgaben verfügbar sind. Es gibt mehrere Möglichkeiten, um zu bestimmen, was jeder Thread ausgeführt hat. Wenn das Problem nur gelegentlich auftritt, empfiehlt es sich, eine Ablaufverfolgung mit dotnet-trace zu erfassen, um das Anwendungsverhalten über einen bestimmten Zeitraum aufzuzeichnen. Wenn das Problem ständig auftritt, können Sie das Dotnet-Stack-Tool verwenden oder ein Dump mit dotnet-dump erfassen, das in Visual Studio angezeigt werden kann. dotnet-stack kann schneller sein, da sie die Threadstapel sofort auf der Konsole anzeigt. Das Visual Studio-Dumpdebugging bietet jedoch bessere Visualisierungen, die Frames der Quelle zuordnen, Just My Code kann Laufzeitimplementierungsframes herausfiltern, und das Feature "Parallel Stacks" kann dazu beitragen, große Anzahl von Threads mit ähnlichen Stapeln zu gruppieren. Dieses Tutorial zeigt die dotnet-stack- und dotnet-trace-Optionen. Ein Beispiel dafür, wie Sie die Threadstapel mithilfe von Visual Studio untersuchen können, finden Sie im Tutorial "Diagnosing ThreadPool starvation".

Diagnostizieren eines fortlaufenden Problems mit dotnet-stack

Führen Sie Bombardier erneut aus, um den Webserver unter Last zu setzen:

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

Führen Sie dann dotnet-stack aus, um die Thread-Stack-Traces anzuzeigen.

dotnet-stack report -n DiagnosticScenarios

Es sollte eine lange Ausgabe mit einer großen Anzahl von Stapeln angezeigt werden, von denen viele wie folgt aussehen:

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()

Die Frames am unteren Rand dieser Stapel geben an, dass es sich bei diesen Threads um ThreadPool-Threads handelt:

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

Und die Frames am oberen Rand zeigen, dass der Thread bei einem Aufruf von GetResultCore(bool) innerhalb der DiagnosticScenarioController.TaskWait()-Funktion blockiert wird.

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()

Diagnostizieren eines zeitweiligen Problems mit dotnet-trace

Der Dotnet-Stack-Ansatz ist nur für offensichtliche, konsistente Blockierungsvorgänge wirksam, die in jeder Anforderung auftreten. In einigen Szenarien geschieht die Blockierung sporadisch nur alle paar Minuten, wodurch dotnet-stack weniger nützlich für die Diagnose des Problems ist. In diesem Fall können Sie dotnet-trace verwenden, um Ereignisse über einen bestimmten Zeitraum zu sammeln und in einer Nettrace-Datei zu speichern, die später analysiert werden kann.

Es gibt ein bestimmtes Ereignis, das hilft, Threadpool-Starvation zu diagnostizieren: das WaitHandleWait-Ereignis, das in .NET 9 eingeführt wurde. Er wird ausgegeben, wenn ein Thread durch Vorgänge blockiert wird, wie etwa sync-over-async-Aufrufe (z. B. Task.Result, Task.Wait und Task.GetAwaiter().GetResult()) oder durch andere Sperrvorgänge wie lock, Monitor.Enter, ManualResetEventSlim.Wait und SemaphoreSlim.Wait.

Führen Sie Bombardier erneut aus, um den Webserver unter Last zu setzen:

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

Starten Sie dann dotnet-trace, um Wartezeitereignisse zu sammeln:

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

Dies sollte eine Datei mit dem Namen DiagnosticScenarios.exe_yyyyddMM_hhmmss.nettrace erzeugen, die die Ereignisse enthält. Dieser Netzwerkpfad kann mit zwei verschiedenen Tools analysiert werden.

  • PerfView: Ein von Microsoft für Windows entwickeltes Leistungsanalysetool.
  • .NET Events Viewer: Ein von der Community entwickeltes Blazor-Analyse-Webtool zur Nettrace-Analyse.

In den folgenden Abschnitten wird gezeigt, wie Sie jedes Tool zum Lesen der Nettrace-Datei verwenden.

Analysieren eines Nettraces mit Perfview

  1. Laden Sie PerfView herunter, und führen Sie es aus.

  2. Öffnen Sie die Nettrace-Datei, indem Sie darauf doppelklicken.

    Screenshot des Öffnens eines Nettrace in PerfView

  3. Doppelklicken Sie auf Advanced Group>Any Stacks. Ein neues Fenster wird geöffnet.

    Screenshot der Ansicht

  4. Doppelklicken Sie auf die Zeile "Ereignis Microsoft-Windows-DotNETRuntime/WaitHandleWait/Start".

    Nun sollten die Stack-Traces angezeigt werden, in denen die WaitHandleWait-Ereignisse erschienen sind. Sie werden nach "WaitSource" aufgeteilt. Derzeit gibt es zwei Quellen: MonitorWait für Ereignisse, die über Monitor.Wait ausgegeben werden, und Unknown für alle anderen.

    Screenshot der Ansicht

  5. Beginnen Sie mit MonitorWait, wie sie 64.8% der Ereignisse darstellt. Sie können die Kontrollkästchen aktivieren, um die Stapelablaufverfolgungen zu erweitern, die für das Auslösen dieses Ereignisses verantwortlich sind.

    Screenshot der erweiterten Stapelansicht für Wait-Ereignisse in PerfView.

    Diese Stapelablaufverfolgung kann gelesen werden als: Task<T>.Result gab ein WaitHandleWait-Ereignis mit einem WaitSource MonitorWait aus (Task<T>.Result verwendet Monitor.Wait zum Durchführen einer Warteschleife). Es wurde aufgerufen von DiagScenarioController.TaskWait, das von einem Lambda aufgerufen wurde, das von einem ASP.NET-Code aufgerufen wurde.

Analysieren eines Nettrace mit .NET-Ereignisanzeige

  1. Wechseln Sie zu verdie-g.github.io/dotnet-events-viewer.

  2. Ziehen und ablegen Sie die Nettrace-Datei.

    Screenshot einer Nettrace-Öffnung in der .NET-Ereignisanzeige.

  3. Wechseln Sie zur Seite " Ereignisstruktur ", wählen Sie das Ereignis "WaitHandleWaitStart" aus, und wählen Sie dann "Abfrage ausführen" aus.

    Screenshot einer Ereignisabfrage in der .NET-Ereignisanzeige.

  4. Sie sollten die Stapelablaufverfolgungen sehen, in denen die Ereignisse vom Typ WaitHandleWait ausgegeben wurden. Klicken Sie auf die Pfeile, um die Stapelspuren zu erweitern, die für das Erzeugen dieses Ereignisses verantwortlich sind.

    Screenshot der Strukturansicht in der .NET-Ereignisanzeige.

    Diese Stack-Trace kann gelesen werden als: ManualResetEventSlim.Wait hat ein WaitHandleWait-Ereignis ausgegeben. Es wurde von Task.SpinThenBlockWait aufgerufen, das von Task.InternalWaitCore aufgerufen wurde, das von Task<T>.Result aufgerufen wurde, das von DiagScenario.TaskWait aufgerufen wurde, das von einem Lambda aufgerufen wurde, das von einem ASP.NET-Code aufgerufen wurde.

In realen Szenarien finden Sie möglicherweise viele Wartezeitereignisse, die von Threads außerhalb des Threadpools ausgegeben werden. Hier untersuchen Sie eine Threadpool-Verknappung, sodass alle Wartezeiten bei dedizierten Threads außerhalb des Threadpools nicht relevant sind. Sie können feststellen, ob ein Stack Trace aus einem Threadpool-Thread stammt, indem Sie sich die ersten Methoden ansehen, die eine Erwähnung des Threadpools enthalten sollten (z. B. WorkerThread.WorkerThreadStart oder ThreadPoolWorkQueue).

Oberer Teil der Stack-Trace eines Thread-Pool-Threads.

Codekorrektur

Jetzt können Sie zum Code für diesen Controller in der Controller/DiagnosticScenarios.cs-Datei der Beispiel-App navigieren, um zu sehen, dass eine asynchrone API ohne Verwendung awaitaufgerufen wird. Dies ist das sync-over-async-Codemuster, das dafür bekannt ist, Threads zu blockieren und die häufigste Ursache für ThreadPool-Erschöpfung ist.

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

In diesem Fall kann der Code problemlos geändert werden, um stattdessen die async/await zu verwenden, wie im TaskAsyncWait() Endpunkt dargestellt. Die Verwendung von Await ermöglicht es dem aktuellen Thread, andere Arbeitselemente zu warten, während die Datenbankabfrage ausgeführt wird. Wenn die Datenbanksuche abgeschlossen ist, wird die Ausführung eines ThreadPool-Threads fortgesetzt. Auf diese Weise wird während jeder Anforderung kein Thread im Code blockiert.

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

Das Ausführen von Bombadier zum Senden der Last an den api/diagscenario/taskasyncwait Endpunkt zeigt, dass die Threadpool-Threadanzahl viel niedriger bleibt und die durchschnittliche Latenz bei Verwendung des async/await-Ansatzes bei nahezu 500 ms bleibt:

>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