다음을 통해 공유


스레드 풀의 고갈 문제 디버그

이 문서는 .NET 9.0 이상 버전에 적용됩니다 ✔️.

이 자습서에서는 ThreadPool 고갈 시나리오를 디버그하는 방법을 알아봅니다. ThreadPool 고갈은 풀에 새 작업 항목을 처리하는 데 사용할 수 있는 스레드가 없고 애플리케이션이 느리게 응답하는 경우가 많을 때 발생합니다. 제공된 예제 ASP.NET Core 웹앱을 사용하여 ThreadPool을 의도적으로 고갈시키고 진단하는 방법을 알아볼 수 있습니다.

이 자습서에서는 다음을 수행합니다.

  • 요청에 느리게 응답하는 앱 조사
  • dotnet-counters 도구를 사용하여 ThreadPool 고갈이 발생할 가능성이 있는지 식별합니다.
  • dotnet-stack 및 dotnet-trace 도구를 사용하여 ThreadPool 스레드를 사용 중인 상태로 유지하는 작업을 결정합니다.

필수 조건

이 자습서에서는 다음을 사용합니다.

샘플 앱 실행

샘플 앱에 대한 코드를 다운로드하고 .NET SDK를 사용하여 실행합니다.

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

웹 브라우저를 사용하여 https://localhost:5001/api/diagscenario/taskwait에 요청을 보내는 경우, 약 500 ms 후에 success:taskwait의 응답이 반환되는 것을 확인할 수 있습니다. 웹 서버가 예상대로 트래픽을 처리하고 있음을 보여 줍니다.

성능 저하 관찰

데모 웹 서버에는 데이터베이스 요청을 모의한 다음 사용자에게 응답을 반환하는 여러 엔드포인트가 있습니다. 이러한 각 엔드포인트는 요청을 한 번에 하나씩 처리할 때 약 500ms의 지연이 발생하지만 웹 서버에서 부하가 발생할 경우 성능이 훨씬 저하됩니다. Bombardier 부하 테스트 도구를 다운로드하고 각 엔드포인트에 125개의 동시 요청이 전송될 때 대기 시간의 차이를 관찰합니다.

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

이 두 번째 엔드포인트는 더 나쁜 작업을 수행하는 코드 패턴을 사용합니다.

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

이러한 두 엔드포인트는 부하가 높을 때 평균 대기 시간(각각 3.48초 및 15.42초)보다 훨씬 더 많이 표시됩니다. 이전 버전의 .NET Core에서 이 예제를 실행하면 두 예제가 모두 똑같이 잘못 수행되는 것을 볼 수 있습니다. .NET 6은 첫 번째 예제에서 사용되는 잘못된 코딩 패턴의 성능 영향을 줄이는 ThreadPool 추론을 업데이트했습니다.

ThreadPool 고갈 감지

실제 서비스에서 위의 동작을 관찰한 경우 부하가 느려지는 것을 알 수 있지만 원인을 알 수 없습니다. dotnet-counters 는 라이브 성능 카운터를 표시할 수 있는 도구입니다. 이러한 카운터는 특정 문제에 대한 단서를 제공할 수 있으며 종종 쉽게 얻을 수 있습니다. 프로덕션 환경에서는 원격 모니터링 도구 및 웹 대시보드에서 제공하는 유사한 카운터가 있을 수 있습니다. dotnet-counters를 설치하고 웹 서비스 모니터링을 시작합니다.

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

앱이 .NET 9보다 오래된 .NET 버전을 실행하는 경우 dotnet-counters의 출력 UI는 약간 다르게 표시됩니다. 자세한 내용은 dotnet-counters를 참조하세요 .

앞의 카운터는 웹 서버가 요청을 처리하지 않은 경우의 예입니다. 엔드포인트를 사용하여 Bombardier를 api/diagscenario/tasksleepwait 다시 실행하고 2분 동안 부하를 유지하므로 성능 카운터에 어떤 일이 일어나는지 관찰할 충분한 시간이 있습니다.

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

ThreadPool 고갈은 대기 중인 작업 항목을 처리할 자유 스레드가 없고 ThreadPool 스레드 수를 늘려 런타임이 응답할 때 발생합니다. 이 값은 dotnet.thread_pool.thread.count 컴퓨터의 프로세서 코어 수의 2~3배로 빠르게 증가한 다음, 125 이상에서 안정화될 때까지 추가 스레드가 초당 1-2개 추가됩니다. ThreadPool 고갈이 현재 성능 병목 상태라는 주요 신호는 ThreadPool 스레드의 느리고 꾸준한 증가와 CPU 사용량이%100개 미만이라는 것입니다. 풀이 최대 스레드 수에 도달하거나, 들어오는 모든 작업 항목을 충족하기에 충분한 스레드가 만들어졌거나, CPU가 포화될 때까지 스레드 수 증가가 계속됩니다. ThreadPool 고갈은 흔히, 그러나 항상 그런 것은 아니지만, dotnet.thread_pool.queue.length에 큰 값이 나타나고 dotnet.thread_pool.work_item.count에 낮은 값이 나타납니다. 이는 보류 중인 작업이 많은 반면, 완료되는 작업이 거의 없음을 의미합니다. 다음은 스레드 수가 계속 증가하는 동안 카운터의 예입니다.

[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

ThreadPool 스레드 수가 안정화되면 풀이 더 이상 부족하지 않습니다. 그러나 높은 값(프로세서 코어 수의 약 3배 이상)으로 안정화되는 경우 일반적으로 애플리케이션 코드가 일부 ThreadPool 스레드를 차단하고 ThreadPool이 더 많은 스레드로 실행하여 보정됨을 나타냅니다. 높은 스레드 수에서 안정적으로 실행하는 것은 요청 대기 시간에 큰 영향을 미치지는 않지만 시간이 지남에 따라 부하가 크게 달라지거나 앱이 주기적으로 다시 시작되는 경우 ThreadPool이 스레드를 느리게 증가시키고 요청 대기 시간을 저하시키는 고갈의 기간에 들어갈 가능성이 높습니다. 또한 각 스레드는 메모리를 사용하므로 필요한 총 스레드 수를 줄이면 또 다른 이점이 있습니다.

.NET 6부터 ThreadPool 추론은 특정 차단 작업 API에 대한 응답으로 ThreadPool 스레드 수를 훨씬 더 빠르게 확장하도록 수정되었습니다. ThreadPool 고갈은 이러한 API에서 계속 발생할 수 있지만 런타임이 더 빠르게 응답하기 때문에 이전 .NET 버전보다 기간이 훨씬 짧습니다. api/diagscenario/taskwait 엔드포인트를 사용하여 Bombardier를 다시 실행합니다.

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

.NET 6에서는 풀이 이전보다 더 빠르게 스레드 수를 증가시키고 많은 수의 스레드에서 안정화되는 것을 관찰해야 합니다. 스레드 수가 상승하는 동안 ThreadPool 고갈이 발생합니다.

ThreadPool 고갈 해결

ThreadPool 고갈을 제거하려면 들어오는 작업 항목을 처리할 수 있도록 ThreadPool 스레드가 차단 해제된 상태로 유지되어야 합니다. 각 스레드가 수행한 작업을 확인하는 방법에는 여러 가지가 있습니다. 문제가 가끔 발생하는 경우 dotnet-trace 를 사용하여 추적을 수집하는 것이 일정 기간 동안 애플리케이션 동작을 기록하는 것이 가장 좋습니다. 문제가 지속적으로 발생하는 경우 dotnet-stack 도구를 사용하거나 Visual Studio에서 볼 수 있는 dotnet-dump가 있는 덤프를 캡처할 수 있습니다. dotnet-stack은 콘솔에 스레드 스택을 즉시 표시하므로 더 빠를 수 있습니다. 그러나 Visual Studio 덤프 디버깅은 프레임을 원본에 매핑하는 더 나은 시각화를 제공하고, 내 코드만 실행 프레임을 필터링할 수 있으며, 병렬 스택 기능은 유사한 스택을 사용하여 많은 수의 스레드를 그룹화하는 데 도움이 될 수 있습니다. 이 자습서에서는 dotnet-stack 및 dotnet-trace 옵션을 보여줍니다. Visual Studio를 사용하여 스레드 스택을 조사하는 예제는 ThreadPool 고갈 진단 자습서 비디오를 참조하세요.

dotnet-stack을 사용하여 연속 문제 진단

Bombardier를 다시 실행하여 웹 서버를 로드합니다.

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

그런 다음 dotnet-stack을 실행하여 스레드 스택 추적을 확인합니다.

dotnet-stack report -n DiagnosticScenarios

많은 수의 스택이 포함된 긴 출력이 표시되어야 하며, 그 중 상당수는 다음과 같습니다.

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

이러한 스택의 맨 아래에 있는 프레임은 이러한 스레드가 ThreadPool 스레드임을 나타냅니다.

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

위쪽에 있는 프레임은 DiagnosticScenarioController.TaskWait() 함수에서 호출할 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()

dotnet-trace를 사용하여 일시적인 문제 진단

dotnet-stack 접근 방식은 모든 요청에서 발생하는 명백하고 일관된 차단 작업에만 적용됩니다. 일부 시나리오에서는 차단이 몇 분마다 산발적으로 발생하므로 dotnet-stack은 문제를 진단하는 데 덜 유용합니다. 이 경우 dotnet 추적을 사용하여 일정 기간 동안 이벤트를 수집하고 나중에 분석할 수 있는 nettrace 파일에 저장할 수 있습니다.

스레드 풀 고갈을 진단하는 데 도움이 되는 특정 이벤트 중 하나는 .NET 9에서 도입된 WaitHandleWait 이벤트입니다. 스레드가 동기화-비동기 호출(예: Task.Result, Task.Wait, Task.GetAwaiter().GetResult())이나 lock, Monitor.Enter, ManualResetEventSlim.Wait, SemaphoreSlim.Wait와 같은 다른 잠금 작업으로 인해 차단될 때 내보내집니다.

Bombardier를 다시 실행하여 웹 서버를 로드합니다.

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

그런 다음, dotnet-trace를 실행하여 대기 이벤트를 수집합니다.

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

이벤트를 포함하는 이름이 지정된 DiagnosticScenarios.exe_yyyyddMM_hhmmss.nettrace 파일을 생성해야 합니다. 이 nettrace는 다음 두 가지 도구를 사용하여 분석할 수 있습니다.

  • PerfView: Windows용 Microsoft에서 개발한 성능 분석 도구입니다.
  • .NET 이벤트 뷰어: 커뮤니티에서 개발한 Nettrace 분석 Blazor 웹 도구입니다.

다음 섹션에서는 각 도구를 사용하여 nettrace 파일을 읽는 방법을 보여줍니다.

Perfview를 사용하여 nettrace 분석

  1. PerfView를 다운로드하고 실행합니다.

  2. nettrace 파일을 두 번 클릭하여 엽니다.

    PerfView에서 nettrace를 여는 스크린샷

  3. 고급 그룹>임의의 스택을 두 번 클릭합니다. 새 창이 열립니다.

    PerfView의 스택 보기 스크린샷

  4. "Event Microsoft-Windows-DotNETRuntime/WaitHandleWait/Start" 줄을 두 번 클릭합니다.

    이제 WaitHandleWait 이벤트가 내보내진 스택 추적이 표시됩니다. "WaitSource"에 의해 분할됩니다. 현재 MonitorWait 통해 내보내는 이벤트와 Unknown 다른 모든 소스의 두 가지 원본이 있습니다.

    PerfView의 대기 이벤트에 대한 스택 보기의 스크린샷.

  5. 이벤트의 64.8%을 나타내므로 MonitorWait부터 시작하세요. 확인란을 선택하여 이 이벤트를 내보내는 스택 추적을 확장할 수 있습니다.

    PerfView에서 대기 이벤트에 대한 확장된 스택 보기의 스크린샷.

    이 스택 추적을 다음과 같이 읽을 수 있습니다: Task<T>.Result이(가) Task<T>.ResultMonitor.Wait를 사용하여 WaitSource MonitorWait으로 WaitHandleWait 이벤트를 발생시켰습니다. 일부 ASP.NET 코드에 의해 호출된 람다에 의해 호출된 DiagScenarioController.TaskWait에 의해 호출되었다.

.NET 이벤트 뷰어를 사용하여 nettrace 분석

  1. verdie-g.github.io/dotnet-events-viewer 이동합니다.

  2. nettrace 파일을 드래그 앤 드롭합니다.

    .NET 이벤트 뷰어에서 nettrace를 여는 스크린샷

  3. 이벤트 트리 페이지로 이동하여 "WaitHandleWaitStart" 이벤트를 선택한 다음 쿼리 실행을 선택합니다.

    .NET 이벤트 뷰어의 이벤트 쿼리 스크린샷

  4. 당신은 WaitHandleWait 이벤트가 발생한 스택 추적을 확인해야 합니다. 화살표를 클릭하여 이 이벤트를 내보내는 스택 추적을 확장합니다.

    .NET 이벤트 뷰어의 트리 뷰 스크린샷

    이 스택 추적은 WaitHandleWait 이벤트를 내보낸 것으로 ManualResetEventSlim.Wait 읽을 수 있습니다. Task.SpinThenBlockWait에 의해 호출된 Task.InternalWaitCore에 의해 호출된 Task<T>.Result에 의해 호출된 DiagScenario.TaskWait에 의해 호출된 일부 람다에 의해 호출된 일부 ASP.NET 코드에 의해 호출되었습니다.

실제 시나리오에서는 스레드 풀 외부의 스레드에서 내보내는 대기 이벤트를 많이 찾을 수 있습니다. 여기서는 스레드 풀의 고갈을 조사하고 있으므로 스레드 풀 외부의 전용 스레드에 대한 모든 대기는 관련이 없습니다. 스레드 풀에 대한 멘션을 포함해야 하는 첫 번째 메서드(예 WorkerThread.WorkerThreadStart : 또는 ThreadPoolWorkQueue)를 확인하여 스레드 풀 스레드에서 스택 추적이 있는지 확인할 수 있습니다.

스레드 풀 스레드 스택 추적의 맨 위입니다.

코드 수정

이제 샘플 앱의 Controllers/DiagnosticScenarios.cs 파일에서 이 컨트롤러에 대한 코드로 이동하여 사용하지 await않고 비동기 API를 호출하고 있는지 확인할 수 있습니다. 이는 스레드를 차단하는 것으로 알려져 있으며 ThreadPool 고갈의 가장 일반적인 원인인 동기화 오버 비동기 코드 패턴입니다.

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

이 경우 엔드포인트에 표시된 TaskAsyncWait() 대로 비동기/await를 대신 사용하도록 코드를 쉽게 변경할 수 있습니다. await를 사용하면 데이터베이스 쿼리가 진행 중인 동안 현재 스레드가 다른 작업 사이트를 서비스할 수 있습니다. 데이터베이스 조회가 완료되면 ThreadPool 스레드가 실행을 다시 시작합니다. 이렇게 하면 각 요청 중에 코드에서 스레드가 차단되지 않습니다.

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

엔드포인트에 부하를 보내기 위해 api/diagscenario/taskasyncwait Bombadier를 실행하면 ThreadPool 스레드 수가 훨씬 낮게 유지되고 비동기/대기 방법을 사용할 때 평균 대기 시간이 500ms 가까이 유지됨을 보여 줍니다.

>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