這是一個老鳥失足,程式沒寫好吃光 CPU 的故事。開始前推薦大家兩篇先修知識:
接獲通報,某主機在離峰時段出現 CPU 維持 50% 高檔狀況,來源則是某個 ASP.NET AppPool Process。依照 SOP,先擷取 Memory Dump 後再重啟 AppPool。(提醒:建立 Dump 檔前需確認 AppPool 是 32 位元還是 64 位元,若為 32 位元需改用 32 位元版 TaskManager,32 位元版 TaskManager 位於 C:\Windows\SysWOW64\taskmgr.exe,執行後 Process 名稱應為「Task Manager (32bit)」。)
取得 DMP 檔後著手分析,上回體驗過 DebugDiag Tools 威力後就回不去了。一般案件調查,出動 DebugDiag Tools 足矣, DebugDiag Analysis Report 點幾下滑鼠報告出爐,簡單明瞭直搗黃龍,至於 WinDbg,就留待密室殺人等級玄案再上場。而本次案例,更讓我對 DebugDiag Analysis Report 的分析能力讚嘆不已。
分析報告如上,發現兩個問題, Thread 26 跟 OracleInternal.SelfTuning.OracleTuner.DoScan()/Sleep() 有關,研判是 Oracle 監控機制,而 Sleep 不會是 CPU 飆高原因,在此忽略。把焦點放在 Thread 20,DebugDiag 給的說明是:
Multiple threads enumerating through a collection is intrinsically not a thread-safe procedure. If the dictionary object accessed by these threads is declared as static then the threads can go in an infinite loop while trying to enumerate the dictionary if one of the threads writes to the dictionary while the other threads are reading\enumerating through the same dictionary. You may also experience High CPU during this stage. For more details refer to High CPU in .NET app using a static Generic.Dictionary
意思是多執行緒存取 static 集合時,若其中一條執行緒試圖寫入 Dictionary,而其他執行緒正好要讀取或列舉同一個 Dictionary,有可能導致無窮迴圈吃光CPU,結尾並附上 Debugger Lady Tess 的部落格文章詳解。
報告裡列出耗用最多 CPU 時間的兩條 Thread 是 20 跟 21:
Top 5 Threads by CPU time
Note - Times include both user mode and kernel mode for each thread
Thread ID: 21
Total CPU Time: 1 day(s) 02:54:20.718 Entry Point for Thread: clr!Thread::intermediateThreadProc
Thread ID: 20
Total CPU Time: 1 day(s) 02:54:20.390 Entry Point for Thread: clr!Thread::intermediateThreadProc
Thread ID: 14
Total CPU Time: 00:00:01.468 Entry Point for Thread: clr!GCThreadStub
Thread ID: 12
Total CPU Time: 00:00:01.343 Entry Point for Thread: clr!GCThreadStub
Thread ID: 13
Total CPU Time: 00:00:00.999 Entry Point for Thread: clr!GCThreadStub
而 Thread 20、21 的 Callstack 指向同一程式片段,也明確吻合 Tess 文章中所說的 Dictionary.FindEntry:
Thread 20:
mscorlib_ni!System.Collections.Generic.Dictionary`2[[System.Int32, mscorlib],[System.__Canon, mscorlib]].FindEntry(Int32)+4a
mscorlib_ni!System.Collections.Generic.Dictionary`2[[System.Int32, mscorlib],[System.__Canon, mscorlib]].ContainsKey(Int32)+a
BLAH.ViewModels.UIText.GetInstance(System.Globalization.CultureInfo)+5f
BLAH.ViewModels.UIText.GetInstance(Int32)+51
BLAH.ViewModels.UIText.GetInstance(BLAH.LangOption)+3a
BLAH.Client.ClientAgent.GiveFreestyle(System.String, System.String, System.String, System.String, System.String, System.String)+19ac
BLAHWeb.Controllers.ClientController+<>c__DisplayClass4_0.b__0()+67Thread 21:
mscorlib_ni!System.Collections.Generic.Dictionary`2[[System.Int32, mscorlib],[System.__Canon, mscorlib]].Insert(Int32, System.__Canon, Boolean)+131
mscorlib_ni!System.Collections.Generic.Dictionary`2[[System.Int32, mscorlib],[System.__Canon, mscorlib]].Add(Int32, System.__Canon)+10
BLAH.ViewModels.UIText.GetInstance(System.Globalization.CultureInfo)+a7
BLAH.ViewModels.UIText.GetInstance(Int32)+51
BLAH.ViewModels.UIText.GetInstance(BLAH.LangOption)+3a
BLAH.Client.ClientAgent.GiveFreestyle(System.String, System.String, System.String, System.String, System.String, System.String)+1806
BLAHWeb.Controllers.ClientController+<>c__DisplayClass4_0.b__0()+67
引用文章的說法:What is happening here, and causing the high CPU is that the FindEntry method walks through the dictionary, trying to find the key. If multiple threads are doing this at the same time, especially if the dictionary is modified in the meantime you may end up in an infinite loop in FindEntry causing the high CPU behavior and the process may hang. CPU 飆高發生在 FindEntry 方法遍巡 Dictionary 比對 Key 值的過程,當有多個 Thread 同時 FindEntry,若此時有 Thread 修改 Dictionary 內容,就可能讓 FindEntry 陷入無窮迴圈吃光 CPU。
兇手為以下這段程式碼,dict 為 static Dictionary 物件存放各語系專屬物件,GetInstance() 檢查是否已存在該語系物件,若存在則直接回傳,不存在再當場建立。由 Callstack 來看,Thread 20 是 ContainsKey 背後觸發 FindEntry,Thread 21 則是 Add 背後觸發 Insert,符合「Thread 查詢時另一 Thread 試圖變更 Dictionary 內容」情境,結局是二者都陷入無窮迴圈,四核主機被兩個 Thread 各吃掉一整核 CPU 25%,50% 運算能力陷入空轉。(想起之前也研究過 Dictionary Thread-Safe 議題,當時未發現還有無窮迴圈這種結局)
publicstatic UIText GetInstance(global::System.Globalization.CultureInfo culture)
{
int lcid = culture.LCID;
if (!dict.ContainsKey(lcid))
{
dict.Add(lcid, new UIText(culture));
}
return dict[lcid];
}
修正方法很簡單,用 lock 將 ContainsKey()、Add() 到讀取內容包起來,限定同一時間只有一個 Thread 執行這段程式,杜絕存取衝突:(另一解法是改用 ConcurrentDictionary,改動幅度略大,詳情請參考前文)
publicstatic UIText GetInstance(global::System.Globalization.CultureInfo culture)
{
lock (dict)
{
int lcid = culture.LCID;
if (!dict.ContainsKey(lcid))
{
dict.Add(lcid, new UIText(culture));
}
return dict[lcid];
}
}
事後想從 IIS Log 找到出事的 Request。分析報表有 Thread 的起始時間(Create Time),原本要以此時點定位:
Thread 20 - System ID 3492
Entry point clr!Thread::intermediateThreadProc
Create time 2017/9/2 下午 07:33:00
但想想不對,IIS Thread 建立後會放在 Pool 重複使用,其建立時間不等於導致無窮迴圈 Request 的時間,而更重要的一點,當 Request 陷入無窮迴圈,永遠沒有完成的一天,在 IIS Log 不會留下任何記錄。(Request 處理完成後才會寫 IIS Log,不然哪來的執行時間長度?)
說起來,這是一個多執行緒程式開發的低級錯誤,一旦宣告 static 就該確認所有存取動作在多執行緒環境不會出錯,尤其 ASP.NET Request 是不折不扣的多執行緒呼叫來源,要時時提高警覺。犯一次錯,學一次教訓,下回要加倍注意。
【延伸閱讀】集合物件的多執行緒存取注意事項