Debuggen von ThreadPool-Mangel

Dieser Artikel gilt für: ✔️ .NET Core 3.1 oder höher

In diesem Tutorial erfahren Sie, wie Sie einen Mangel im ThreadPool debuggen. Ein Mangel im ThreadPool tritt auf, wenn im Pool keine Threads zum Verarbeiten neuer Arbeitselemente verfügbar sind. Dies führt häufig dazu, dass Anwendungen langsam reagieren. Anhand des bereitgestellten Beispiels einer ASP.NET Core-Web-App können Sie einen Mangel im ThreadPool absichtlich herbeiführen und Sie erfahren, wie Sie diesen diagnostizieren können.

In diesem Tutorial wird Folgendes vermittelt:

  • Untersuchen einer App, die auf Anforderungen langsam reagiert
  • Verwenden des Tools „dotnet-counters“, um festzustellen, ob im ThreadPool ein Mangel vorliegt
  • Verwenden des Tools „dotnet-stack“, um zu ermitteln, mit welchen Arbeitselementen die ThreadPool-Threads beschäftigt sind

Voraussetzungen

Im Tutorial wird Folgendes verwendet:

Ausführen der Beispiel-App

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

    E:\demo\DiagnosticScenarios>dotnet build
    Microsoft (R) Build Engine version 17.1.1+a02f73656 for .NET
    Copyright (C) Microsoft Corporation. All rights reserved.
    
      Determining projects to restore...
      All projects are up-to-date for restore.
      DiagnosticScenarios -> E:\demo\DiagnosticScenarios\bin\Debug\net6.0\DiagnosticScenarios.dll
    
    Build succeeded.
        0 Warning(s)
        0 Error(s)
    
    Time Elapsed 00:00:01.26
    
  2. Führen Sie die App aus:

    E:\demo\DiagnosticScenarios>bin\Debug\net6.0\DiagnosticScenarios.exe
    info: Microsoft.Hosting.Lifetime[14]
          Now listening on: http://localhost:5000
    info: Microsoft.Hosting.Lifetime[14]
          Now listening on: https://localhost:5001
    info: Microsoft.Hosting.Lifetime[0]
          Application started. Press Ctrl+C to shut down.
    info: Microsoft.Hosting.Lifetime[0]
          Hosting environment: Production
    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, sollte die Antwort success:taskwait nach etwa 500 ms zurückgegeben werden. Ist dies der Fall, verarbeitet der Webserver den Datenverkehr erwartungsgemäß.

Beobachten einer schlechten Leistung

Der Demowebserver verfügt über mehrere Endpunkte, die eine Datenbankanforderung simulieren und eine Antwort an den Benutzer zurückgeben. Jeder dieser Endpunkte hat eine Verzögerung von etwa 500 ms, wenn Anforderungen nacheinander verarbeitet werden. Die Leistung des Webservers ist unter einer gewissen Last jedoch wesentlich schlechter. Laden Sie das Tool Bombardier zum Testen der Auslastung herunter, und beobachten Sie den Unterschied bei der Wartezeit, wenn gleichzeitig 125 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 weisen bei hoher Auslastung eine Wartezeit von 500 ms auf und liegen damit deutlich über dem Durchschnitt (3,48 s bzw. 15,42 s). Wenn Sie dieses Beispiel auf einer älteren Version von .NET Core ausführen, werden wahrscheinlich beide Beispiele gleich schlecht abschneiden. Bei .NET 6 wurden die ThreadPool-Heuristiken aktualisiert. Dadurch verringert sich die durch das im ersten Beispiel verwendete ungünstige Codierungsmuster verursachte Leistungsbeeinträchtigung.

Erkennen eines Mangels im ThreadPool

Wenn Sie das oben beschriebene Verhalten bei einem realen Dienst beobachten würden, wüssten Sie, dass der Dienst unter Last langsam reagiert, aber Sie würden die Ursache nicht kennen. dotnet-counters ist ein Tool, mit dem Leistungsindikatoren live angezeigt werden können. Diese Leistungsindikatoren können Anhaltspunkte für bestimmte Probleme liefern und lassen sich meist leicht ermitteln. In Produktionsumgebungen verfügen Sie möglicherweise über ähnliche Leistungsindikatoren, die von Remoteüberwachungstools und Webdashboards bereitgestellt werden. Installieren Sie dotnet-counters, und überwachen Sie den Webdienst:

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

[System.Runtime]
    % Time in GC since last GC (%)                                 0
    Allocation Rate (B / 1 sec)                                    0
    CPU Usage (%)                                                  0
    Exception Count (Count / 1 sec)                                0
    GC Committed Bytes (MB)                                        0
    GC Fragmentation (%)                                           0
    GC Heap Size (MB)                                             34
    Gen 0 GC Count (Count / 1 sec)                                 0
    Gen 0 Size (B)                                                 0
    Gen 1 GC Count (Count / 1 sec)                                 0
    Gen 1 Size (B)                                                 0
    Gen 2 GC Count (Count / 1 sec)                                 0
    Gen 2 Size (B)                                                 0
    IL Bytes Jitted (B)                                      279,021
    LOH Size (B)                                                   0
    Monitor Lock Contention Count (Count / 1 sec)                  0
    Number of Active Timers                                        0
    Number of Assemblies Loaded                                  121
    Number of Methods Jitted                                   3,223
    POH (Pinned Object Heap) Size (B)                              0
    ThreadPool Completed Work Item Count (Count / 1 sec)           0
    ThreadPool Queue Length                                        0
    ThreadPool Thread Count                                        1
    Time spent in JIT (ms / 1 sec)                                 0.387
    Working Set (MB)                                              87

Die oben aufgeführten Leistungsindikatoren sind ein Beispiel für eine Situation, in der der Webserver keine Anforderungen verarbeitet hat. Führen Sie Bombardier mit dem Endpunkt api/diagscenario/tasksleepwait und einer anhaltenden Auslastung 2 Minuten lang aus, sodass sie genügend Zeit haben, um zu beobachten, was mit den Leistungsindikatoren geschieht.

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

Der Mangel im ThreadPool tritt auf, wenn keine freien Threads zum Verarbeiten der der Arbeitselemente in die Warteschlange zur Verfügung stehen und die Laufzeit durch Erhöhen der Anzahl der ThreadPool-Threads reagiert. Sie sollten beobachten, dass sich die ThreadPool Thread Count schnell auf das 2- bis 3fache der Anzahl der Prozessorkerne im Computer erhöht und dass anschließend 1 bis 2 weitere Threads pro Sekunde dazukommen, bis sich die Anzahl schließlich auf etwas über 125 stabilisiert. Die langsame und stetige Zunahme der ThreadPool-Threads in Kombination mit einer CPU-Auslastung von wesentlich weniger als 100 % sind die wichtigsten Anzeichen dafür, dass ein aktueller Mangel im ThreadPool Ursache für den Leistungsengpass ist. Die Anzahl der Threads wird so lange erhöht, bis entweder im Pool die maximale Anzahl von Threads erreicht ist, genügend Threads erstellt wurden, um alle eingehenden Arbeitselemente zu verarbeiten, oder die CPU voll ausgelastet ist. Häufig, aber nicht immer, zeigt sich ein Mangel im ThreadPool auch durch große Werte für ThreadPool Queue Length und niedrige Werte für ThreadPool Completed Work Item Count, was bedeutet, dass eine große Menge an ausstehenden Arbeitselementen vorhanden ist und nur eine geringe Menge an Arbeitselementen verarbeitet wird. Im Folgenden ist ein Beispiel für die Leistungsindikatoren dargestellt, während die Anzahl der Threads weiter ansteigt:

Press p to pause, r to resume, q to quit.
    Status: Running

[System.Runtime]
    % Time in GC since last GC (%)                                 0
    Allocation Rate (B / 1 sec)                               24,480
    CPU Usage (%)                                                  0
    Exception Count (Count / 1 sec)                                0
    GC Committed Bytes (MB)                                       56
    GC Fragmentation (%)                                          40.603
    GC Heap Size (MB)                                             89
    Gen 0 GC Count (Count / 1 sec)                                 0
    Gen 0 Size (B)                                         6,306,160
    Gen 1 GC Count (Count / 1 sec)                                 0
    Gen 1 Size (B)                                         8,061,400
    Gen 2 GC Count (Count / 1 sec)                                 0
    Gen 2 Size (B)                                               192
    IL Bytes Jitted (B)                                      279,263
    LOH Size (B)                                              98,576
    Monitor Lock Contention Count (Count / 1 sec)                  0
    Number of Active Timers                                      124
    Number of Assemblies Loaded                                  121
    Number of Methods Jitted                                   3,227
    POH (Pinned Object Heap) Size (B)                      1,197,336
    ThreadPool Completed Work Item Count (Count / 1 sec)           2
    ThreadPool Queue Length                                       29
    ThreadPool Thread Count                                       96
    Time spent in JIT (ms / 1 sec)                                 0
    Working Set (MB)                                             152

Sobald sich die Anzahl der ThreadPool-Threads stabilisiert, herrscht im Pool kein Mangel mehr. Wenn sich die Anzahl der Threads jedoch auf einem hohen Wert stabilisiert (mehr als das Dreifache der Anzahl der Prozessorkerne), deutet dies in der Regel darauf hin, dass der Anwendungscode einige ThreadPools-Threads blockiert und der ThreadPool dies kompensiert, indem er mehr Threads ausführt. Die Ausführung einer konstant hohen Anzahl von Threads wirkt sich nicht unbedingt auf die Anforderungswartezeit aus. Wenn die Last im Laufe der Zeit jedoch stark schwankt oder die Anwendung regelmäßig neu gestartet wird, besteht die Gefahr, dass im ThreadPool ein Mangel auftritt, wobei sich die Anzahl der Threads langsam erhöht und die Anforderungswartezeit zunimmt. Zudem belegt jeder Thread Arbeitsspeicher, sodass die Reduzierung der Gesamtzahl an Threads einen weiteren Vorteil darstellt.

Beginnend mit .NET 6 wurden ThreadPool-Heuristiken geändert, sodass die Anzahl der ThreadPool-Threads als Reaktion auf bestimmte blockierende Task-APIs nun viel schneller skaliert wird. Bei diesen APIs kann im ThreadPool immer noch ein Mangel auftreten, aber die Dauer ist viel kürzer als bei älteren .NET-Versionen, da die Runtime schneller reagiert. Führen Sie Bombardier erneut aus, und verwenden Sie dabei den Endpunkt api/diagscenario/taskwait:

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

In .NET 6 sollten Sie beobachten, dass sich die Anzahl der Threads im Pool schneller erhöht als zuvor und dass sie sich bei einer hohen Anzahl von Threads stabilisiert. Im ThreadPool herrscht Mangel, während die Anzahl der Threads zunimmt.

Beheben eines Mangels im ThreadPool

Wenn ein Mangel im ThreadPool verhindert werden soll, dürfen die ThreadPool-Threads nicht blockiert werden, sodass sie zur Verarbeitung eingehender Arbeitselemente verfügbar sind. Es gibt zwei Möglichkeiten, zu ermitteln, was die einzelnen Threads bewirken. Die eine besteht darin, das Tool dotnet-stack zu verwenden, die andere darin, mit dotnet-dump ein Speicherabbild zu erstellen, das in Visual Studio angezeigt werden kann. dotnet-stack ist möglicherweise schneller, weil damit die Threadstapel sofort auf der Konsole angezeigt werden. Das Debuggen des Visual Studio-Speicherabbilds ermöglicht jedoch eine bessere Visualisierung zum Zuordnen von Frames zur Quelle. Mit „Nur eigenen Code“ können Runtimeimplementierungsframes herausgefiltert werden. Und mit dem Feature „Parallele Stapel“ kann eine große Anzahl von Threads in ähnlichen Stapeln gruppiert werden. In diesem Tutorial wird die Option dotnet-stack vorgestellt. Ein Beispiel für die Untersuchung der Threadstapel mithilfe von Visual Studio finden Sie im Video mit dem Tutorial zur Diagnose eines Mangels im ThreadPool.

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 anschließend dotnet-stack aus, um die Threadstapelüberwachung anzuzeigen:

dotnet-stack report -n DiagnosticScenarios

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

Nun können Sie in der Datei Controllers/DiagnosticScenarios.cs zum Code für diesen Controller navigieren, um zu sehen, dass damit eine asynchrone API ohne await aufgerufen wird. Hierbei handelt es sich um das Codemuster sync-over-async, das bekanntermaßen Threads blockiert und die häufigste Ursache für einen Mangel im ThreadPool 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 wie beim Endpunkt TaskAsyncWait() den async/await-Ansatz zu verwenden. Wenn await verwendet wird, kann der aktuelle Thread andere Arbeitselemente verarbeiten, während die Datenbankabfrage durchgeführt wird. Nach Abschluss der Datenbanksuche wird die Ausführung des ThreadPool-Threads fortgesetzt. So wird verhindert, dass während einer Anforderung ein Thread im Code blockiert wird:

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

Wenn Bombadier zum Senden einer Last an den Endpunkt api/diagscenario/taskasyncwait ausgeführt wird und dabei der async/await-Ansatz verwendet wird, zeigt sich, dass die Anzahl der ThreadPool-Threads wesentlich niedriger bleibt und die durchschnittliche Wartezeit etwa bei 500 ms liegt:

>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