Udostępnij za pośrednictwem


Debugowanie starvation puli wątków

Ten artykuł dotyczy: ✔️ .NET Core 3.1 i nowszych wersji

Z tego samouczka dowiesz się, jak debugować scenariusz głodu threadpool. W przypadku braku dostępnych wątków pula wątków do przetwarzania nowych elementów roboczych często powoduje powolne reagowanie aplikacji. Korzystając z podanego przykładu ASP.NET Core aplikacji internetowej, możesz celowo spowodować zagęszczanie puli ThreadPool i dowiedzieć się, jak ją zdiagnozować.

W tym samouczku wykonasz następujące czynności:

  • Badanie aplikacji, która odpowiada na żądania powoli
  • Użyj narzędzia dotnet-counters, aby zidentyfikować zagwiazdkę ThreadPool, która prawdopodobnie występuje
  • Użyj narzędzia dotnet-stack, aby określić, jaka praca utrzymuje zajęte wątki ThreadPool

Wymagania wstępne

W tym samouczku są używane następujące elementy:

Uruchamianie przykładowej aplikacji

  1. Pobierz kod przykładowej aplikacji i skompiluj go przy użyciu zestawu SDK platformy .NET:

    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. Uruchom aplikację:

    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
    

Jeśli używasz przeglądarki internetowej i wysyłasz żądania do https://localhost:5001/api/diagscenario/taskwaitusługi , powinna zostać wyświetlona odpowiedź success:taskwait zwrócona po około 500 ms. Oznacza to, że serwer internetowy obsługuje ruch zgodnie z oczekiwaniami.

Obserwowanie niskiej wydajności

Demonstracyjny serwer internetowy ma kilka punktów końcowych, które kpią z żądania bazy danych, a następnie zwracają odpowiedź użytkownikowi. Każdy z tych punktów końcowych ma opóźnienie około 500 ms podczas obsługi żądań pojedynczo, ale wydajność jest znacznie gorsza, gdy serwer internetowy jest obciążony. Pobierz narzędzie do testowania obciążenia bombardiera i obserwuj różnicę w opóźnieniu, gdy do każdego punktu końcowego są wysyłane 125 równoczesnych żądań.

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

Ten drugi punkt końcowy używa wzorca kodu, który działa jeszcze gorzej:

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

Oba te punkty końcowe pokazują znacznie więcej niż średnie opóźnienie 500 ms, gdy obciążenie jest wysokie (odpowiednio 3,48 s i 15,42 s). Jeśli uruchomisz ten przykład na starszej wersji platformy .NET Core, prawdopodobnie oba przykłady będą działać równie źle. Platforma .NET 6 zaktualizowała heurystyki threadpool, które zmniejszają wpływ na wydajność złego wzorca kodowania używanego w pierwszym przykładzie.

Wykrywanie zagęszczania puli wątków

Jeśli zaobserwowano powyższe zachowanie w rzeczywistej usłudze, wiesz, że odpowiada powoli pod obciążeniem, ale nie znasz przyczyny. dotnet-counters to narzędzie, które może wyświetlać liczniki wydajności na żywo. Te liczniki mogą dostarczać wskazówek dotyczących niektórych problemów i często są łatwe do uzyskania. W środowiskach produkcyjnych mogą istnieć podobne liczniki udostępniane przez narzędzia do monitorowania zdalnego i internetowe pulpity nawigacyjne. Zainstaluj liczniki dotnet-counter i rozpocznij monitorowanie usługi internetowej:

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

Powyższe liczniki są przykładem, gdy serwer internetowy nie obsługiwał żadnych żądań. Uruchom ponownie bombardier z api/diagscenario/tasksleepwait punktem końcowym i trwałym obciążeniem przez 2 minuty, więc jest mnóstwo czasu, aby obserwować, co się dzieje z licznikami wydajności.

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

Zagęszczanie puli wątków występuje, gdy nie ma wolnych wątków do obsługi elementów roboczych w kolejce, a środowisko uruchomieniowe reaguje, zwiększając liczbę wątków ThreadPool. Należy obserwować ThreadPool Thread Count szybki wzrost do 2-3x liczby rdzeni procesora na maszynie, a następnie kolejne wątki są dodawane 1-2 na sekundę, aż ostatecznie ustabilizować się gdzieś powyżej 125. Powolny i stały wzrost wątków ThreadPool w połączeniu z użyciem procesora CPU znacznie mniej niż 100% są kluczowymi sygnałami, że głodowanie ThreadPool jest obecnie wąskim gardłem wydajności. Zwiększenie liczby wątków będzie kontynuowane, dopóki pula nie osiągnie maksymalnej liczby wątków, utworzono wystarczającą liczbę wątków, aby spełnić wszystkie przychodzące elementy robocze lub procesor został nasycony. Często, ale nie zawsze, głodowanie ThreadPool będzie również pokazywać duże wartości dla ThreadPool Queue Length i niskie wartości dla ThreadPool Completed Work Item Count, co oznacza, że istnieje duża ilość oczekujących prac i niewiele pracy jest ukończone. Oto przykład liczników, gdy liczba wątków nadal rośnie:

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

Gdy liczba wątków ThreadPool zostanie ustabilizowana, pula nie będzie już głodna. Jeśli jednak stabilizuje się na wysokiej wartości (więcej niż trzy razy więcej niż trzy razy więcej rdzeni procesora), zwykle oznacza to, że kod aplikacji blokuje niektóre wątki ThreadPool, a pula ThreadPool jest wyrównująca przez uruchomienie z większą liczbą wątków. Stałe działanie z dużą liczbą wątków nie musi mieć dużego wpływu na opóźnienie żądania, ale jeśli obciążenie znacznie się zmienia w czasie lub aplikacja będzie okresowo uruchamiana ponownie, za każdym razem, gdy pula wątków prawdopodobnie wejdzie w okres głodu, w którym powoli zwiększa liczbę wątków i dostarcza słabe opóźnienie żądań. Każdy wątek zużywa również pamięć, więc zmniejszenie całkowitej liczby potrzebnych wątków zapewnia kolejną korzyść.

Począwszy od platformy .NET 6, heurystyka puli ThreadPool została zmodyfikowana w celu skalowania w górę liczby wątków ThreadPool znacznie szybciej w odpowiedzi na niektóre blokujące interfejsy API zadań. W przypadku tych interfejsów API nadal może wystąpić zagęszczanie puli wątków, ale czas trwania jest znacznie krótszy niż w przypadku starszych wersji platformy .NET, ponieważ środowisko uruchomieniowe reaguje szybciej. Uruchom ponownie bombardier z api/diagscenario/taskwait punktem końcowym:

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

Na platformie .NET 6 należy obserwować, jak pula zwiększa liczbę wątków szybciej niż wcześniej, a następnie stabilizuje się na dużej liczbie wątków. W trakcie wspinaczki liczby wątków występuje głodowanie puli wątków.

Rozpoznawanie zagęszczania puli wątków

Aby wyeliminować głód wątków ThreadPool, wątki ThreadPool muszą pozostać odblokowane, aby były dostępne do obsługi przychodzących elementów roboczych. Istnieją dwa sposoby określania, co robi każdy wątek, za pomocą narzędzia dotnet-stack lub przechwytywania zrzutu za pomocą polecenia dotnet-dump , który można wyświetlić w programie Visual Studio. narzędzie dotnet-stack może być szybsze, ponieważ wyświetla stosy wątków natychmiast w konsoli, ale debugowanie zrzutów programu Visual Studio oferuje lepsze wizualizacje mapowane ramki na źródło. Funkcja Just My Code może odfiltrować ramki implementacji środowiska uruchomieniowego, a funkcja Stosy równoległe może pomóc w grupowaniu dużej liczby wątków z podobnymi stosami. W tym samouczku przedstawiono opcję dotnet-stack. Aby zapoznać się z przykładem badania stosów wątków przy użyciu programu Visual Studio, zobacz film z samouczkiem dotyczącym diagnozowania buforu wątków .

Uruchom ponownie serwer Bombardier, aby załadować serwer internetowy:

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

Następnie uruchom polecenie dotnet-stack, aby wyświetlić ślady stosu wątku:

dotnet-stack report -n DiagnosticScenarios

Powinny zostać wyświetlone długie dane wyjściowe zawierające dużą liczbę stosów, z których wiele wygląda następująco:

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

Ramki u dołu tych stosów wskazują, że te wątki są wątkami ThreadPool:

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

A ramki w górnej części ujawniają, że wątek jest zablokowany w wywołaniu metody GetResultCore(bool) z funkcji DiagnosticScenarioController.TaskWait():

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

Teraz możesz przejść do kodu dla tego kontrolera w pliku Controllers/DiagnosticScenarios.cs przykładowej aplikacji, aby zobaczyć, że wywołuje on asynchroniczny interfejs API bez użycia polecenia await. Jest to wzorzec kodu sync-over-async , który jest znany z blokowania wątków i jest najczęstszą przyczyną głodu threadpool.

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

W takim przypadku kod można łatwo zmienić, aby zamiast tego używać async/await, jak pokazano w punkcie TaskAsyncWait() końcowym. Użycie funkcji await umożliwia bieżącemu wątkowi obsługę innych elementów roboczych, gdy zapytanie bazy danych jest w toku. Po zakończeniu wyszukiwania bazy danych wątek ThreadPool wznowi wykonywanie. W ten sposób żaden wątek nie jest blokowany w kodzie podczas każdego żądania:

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

Uruchomienie narzędzia Bombadier w celu wysłania obciążenia do punktu końcowego api/diagscenario/taskasyncwait pokazuje, że liczba wątków ThreadPool pozostaje znacznie niższa, a średnie opóźnienie pozostaje w pobliżu 500 ms podczas korzystania z podejścia asynchronicznego/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