2019年8月9日 星期五

利用 PerfView 來診斷是否有 .NET Core ThreadPool Starvation 的問題

前言

因為遇到了 ThreadPool Starvation 的問題,到找了相關的文章 (Diagnostic .NET Core ThreadPool Starvation with PerfView),但閱讀英文速度太慢,避免之後又遇到此問題,又要重新看一次,就簡單的翻譯一下;希望也能幫助到有需要的人。

以下翻譯文章。

服務無法處理爆量的請求 (在 CPU 使用率 20% 的時候,可以正常作業,但是遇到爆量的請求卻沒有使用更多的 CPU 資源)

這些症狀顯示瓶頸不在 CPU。基本上是服務的請求在等待其他資源,因而無法處理爆量的請求。最常見的例子是,請求的資源來自其他的機器。

然而有一個潛在的例子,缺乏的資源就是 Thread 本身。Service 沒有 Thread 可以執行下一個請求,所以就卡在那邊等待有可用的 Thread。此時,花了較長的時間在做資料庫查詢,但是卻不是在 CPU 活動的時間發生的,看起來像是 I/O 操作導致查詢超出原本的時間。這種問題稱為 ThreadPool Starvation。



什麼是 ThreadPool


Thread 是執行一程式的狀態... 略。

簡單的說就是重用 Thread。在 .NET 中,可以使用 ThreadPool.QueueUserWorkItem 來把一個方法委派到 ThreadPool 執行 (注意,.NET 並不鼓勵直接使用 ThreadPool。建議使用 Task.Factory.StartNew(Action) ,但此方法也會將方法排入 ThreadPool 佇列,不過更容易處理例外狀況,以及得到執行的結果)。

什麼是非同步程式設計


在過去,服務使用多執行序執行模型,服務會建立執行序來處理同步的請求。在中小型的服務來說,並不會產生什麼問題,但是當你要同時處理 1000 或 10000 請求時就行不通了,因為 Thread 相當耗資源。

當處理大規模的請求時,可以使用非同步設計模式。此模型,不再是每個請求就使用一個 Thread。當在做長時間的操作時,改去註冊一個 callback,並重用這些 Thread 去處理等待的作業。

非同步設計架構使用一些 Thread (大約為實體處理器的個數) 就可以同時處理大量的請求。

為什麼大規模的非同步服務會有 ThreadPool Starvation 的問題


一般情況下,非同步服務運作正常,但服務個規模變大時可能就會出問題。例如,在一台有16個處理器的機器上,同時有一千個請求。 使用 async ThreadPool 只需要 16 threads 去處理這 1000個請求。假設在請求前寫了 Sleep(200),前 16個請求會延遲 200 ms,下 16個延遲 400 ms,再下 16個延遲 600 ms。同時一千個請求,平均回應時間會高達六秒。這就是為什麼稍微卡住一下,會使得效能突然變糟。

這個例子是刻意產生的,但是足以解釋這個情況。如果產生了任何的延遲,Thread 的數目就會從 16 跳到 1000。.NET ThreadPool 會以緩慢的速度增加更多的 Thread (例如: 每秒一到兩個 Thread, 大約要幾分鐘後才會增加到一千)。也因此失去了非同步的好處。

只有在不延遲的狀況下,非同步設計才能獲得其效益。如果在大型的程式裡,有延遲的話,很快地就會把 ThreadPool 裡的 Thread 用完。在大型的程式,正確的做法是, 避免在經常使用的地方產生延遲。

什麼原因導致卡住


造成延遲常見的原因包括:
  1. 呼叫的 API 以同步的方式進行 I/O 操作 (因為是同步的,所以如果 I/O 速度不能很快的完成,就會造成延遲)
  2. 呼叫 Task.Wait() 或是 Task.GetResult,因為呼叫的是非同步的方法,比較理想的做法是利用 'await' 來取代
在大型的應用程式中,很容易一不小心就造成了延遲,使得 ThreadPool 需要建立越來越多的 Thread,進而引發 Starvation的現象。

如何知道 ThreadPool 中的 Thread 餓死了


若 CPU 的使用率不如預期的增長,先從以上的症狀開始著手。再來可以檢查以下的徵兆,讓我們可以更確定發生了 Thread Starvation 。

一如往常,當服務發生了問題,檢視效能的變化是很有幫助的。在 Windows 上,可以下載 PerfView 來追蹤 Thread 使用的情況。Linux 可以使用 perfCollect 。在 .NET Core 2.2 可以使用 'dotnet profile' 命令 (之後的文章會介紹)。Application Insights profiler 也可以用來追蹤 Thread 使用情況。

只要追蹤服務 60 秒就可足以我們觀察。

尋找 Thread 成長的數量


一個關鍵的徵兆是,有工作要做,但是沒有 Thread,ThreadPool 嘗試要建立新的 Thread,但 Thread 以相當的緩慢的速度增加 (大約一秒一兩個)。因此,可以在 PerfView 的 'events view' 中可以看到 OS kernel 事件中 Thread 以大約每秒兩個的速度增加。



Linux trace 沒有 OS 事件,不過每次有新的 Thread 產生,都可以在 .NET Runtime 事件中看到。可以從以下的事件看到

  • Microsoft-Windows-DotNETRuntime/IOThreadCreation/Start - windows only (it has a special queue for threads blocked on certain I/O) for new I/O workers
  • Microsoft-Windows-DotNETRuntime/ThreadPoolWorkerThread/Start  - logged on both Linux and Windows for new workers
  • Microsoft-Windows-DotNETRuntime/ThreadPoolWorkerThreadAdjustment/Adjustment - indicates normal worker adjustment (will show increasing count)


如果覆載過高,服務也跑不太下去。從 OS 效能,觀察服務的 Thread 數量就可以知道。

找到卡住的 API


如果遇到 ThreadPool Starvation 的問題,很可能是呼叫的 API 執行時花了太長的時間,在 Windows 上,可以在 PerfView 的 'Thread Time (with StartStop Activities) view' 中檢視卡住的 Thread 狀態。觀察這些 Thread,你會發現 'BLOCKED_TIME' 的規律,這就是 API 導致延遲的原因。

積極地面對問題


不要等到你的服務已經要掛掉才去找延遲的原因。並不需要大型的環境才能觀察到 ThreadPool Starvation 的問題。積極的態度可以在問題發生之前就解決問題。

替代方案: 強制設定更多 Thread


ThreadPool Starvation 的解法是解決產生延遲的 Thread。然而,當無法輕易地修改程式碼時,在短時間內又需要有快解時,ThreadPool.SetMinThreads 可以設定 ThreadPool 中最少的 Thread 數目 (在 Windows 中有分 I/O thread pool 和其他工作 thread pool,所以要看是哪個 pool 的 thread 持續的增加,再來增加其最少數量)。一般 woker thread 的最少數目,也可以透過 COMPlus_ForceMinWorkerThreads 環境變數來設定。不過 I/O threadpool 沒有環境變數可以設定。一般來說,這不是一個好的解法,因為需要許多的 threads,而且很沒效率。這應該只能作為權宜之計。(It should only be used as a stop-gap measure.)

總結


關於 .NET ThreadPool Starvation
  1. 當服務運作不正常,但 CPU 沒有好好利用
  2. 主要的症狀: Thread 的數目持續地增加
  3. 可以透過檢視 .NET Runtime 事件,監視 ThreadPool Thread 增加的情形
  4. 透過 'thread time' 去找到哪一個請求延遲了
  5. 在擴大服務地規模前,積極地尋找延遲的時間,並解決因擴大服務產生的問題
  6. 如果可以解決延遲是最好的,但如果沒法增加 ThreadPool 地大小,那麼至少服務要可以在短時間內反應

沒有留言: