尋找和分析 .NET 應用程序中的高 CPU 使用率
已發表: 2022-03-11軟件開發可能是一個非常複雜的過程。 作為開發人員,我們需要考慮很多不同的變量。 有些不在我們的控制之下,有些在實際代碼執行的那一刻我們不知道,有些是我們直接控制的。 .NET 開發人員也不例外。
鑑於這一現實,當我們在受控環境中工作時,事情通常會按計劃進行。 一個例子是我們的開發機器,或者我們可以完全訪問的集成環境。 在這些情況下,我們可以使用工具來分析影響我們的代碼和軟件的不同變量。 在這些情況下,我們也不必處理繁重的服務器負載,或者嘗試同時做同樣事情的並髮用戶。
在描述和安全的情況下,我們的代碼可以正常工作,但在高負載或其他一些外部因素的生產中,可能會出現意想不到的問題。 生產中的軟件性能很難分析。 大多數時候,我們必須在理論場景中處理潛在問題:我們知道問題可能會發生,但我們無法對其進行測試。 這就是為什麼我們需要基於我們正在使用的語言的最佳實踐和文檔進行開發,並避免常見錯誤。
如前所述,當軟件上線時,事情可能會出錯,代碼可能會以我們沒有計劃的方式開始執行。 當我們不得不處理問題而無法調試或確切知道發生了什麼時,我們最終可能會陷入這種情況。 在這種情況下我們能做什麼?
在本文中,我們將分析基於 Windows 的服務器上的 .NET Web 應用程序的高 CPU 使用率的真實案例場景,識別問題所涉及的進程,更重要的是,首先為什麼會發生這個問題以及我們如何解決這個問題。
CPU 使用率和內存消耗是廣泛討論的話題。 通常很難確定特定進程應該使用多少資源(CPU、RAM、I/O)以及使用時間。 雖然有一件事是肯定的——如果一個進程在很長一段時間內使用超過 90% 的 CPU,我們就會遇到麻煩,因為在這種情況下服務器將無法處理任何其他請求。
這是否意味著流程本身存在問題? 不必要。 可能是該過程需要更多的處理能力,或者它正在處理大量數據。 首先,我們唯一能做的就是嘗試找出發生這種情況的原因。
所有操作系統都有幾種不同的工具來監視服務器中發生的事情。 Windows 服務器專門有任務管理器,性能監視器,或者在我們的例子中,我們使用了 New Relic Servers,這是一個很好的服務器監控工具。
首發症狀及問題分析
部署應用程序後,在前兩週的一段時間內,我們開始看到服務器出現 CPU 使用高峰,這導致服務器無響應。 我們必須重新啟動它以使其再次可用,並且該事件在該時間段內發生了 3 次。 正如我之前提到的,我們使用 New Relic Servers 作為服務器監視器,它顯示w3wp.exe
進程在服務器崩潰時使用了 94% 的 CPU。
Internet 信息服務 (IIS) 工作進程是運行 Web 應用程序的 Windows 進程 ( w3wp.exe
),負責處理髮送到特定應用程序池的 Web 服務器的請求。 IIS 服務器可能有多個應用程序池(和幾個不同的w3wp.exe
進程),它們可能會產生問題。 根據該流程擁有的用戶(這在 New Relic 報告中顯示),我們確定問題出在我們的 .NET C# Web 表單遺留應用程序上。
.NET Framework 與 Windows 調試工具緊密集成,因此我們嘗試做的第一件事是查看事件查看器和應用程序日誌文件以查找有關正在發生的事情的一些有用信息。 無論我們是否在事件查看器中記錄了一些異常,它們都沒有提供足夠的數據來分析。 這就是為什麼我們決定更進一步並收集更多數據的原因,所以當事件再次發生時,我們會做好準備。
數據採集
收集用戶模式進程轉儲的最簡單方法是使用調試診斷工具 v2.0 或簡單的 DebugDiag。 DebugDiag 有一套用於收集數據(DebugDiag Collection)和分析數據(DebugDiag Analysis)的工具。
所以,讓我們開始定義使用調試診斷工具收集數據的規則:
打開 DebugDiag Collection 並選擇
Performance
。- 選擇
Performance Counters
並單擊Next
。 - 單擊
Add Perf Triggers
。 - 展開
Processor
(不是Process
)對象並選擇% Processor Time
。 請注意,如果您使用的是 Windows Server 2008 R2 並且擁有超過 64 個處理器,請選擇Processor Information
對象而不是Processor
對象。 - 在實例列表中,選擇
_Total
。 - 單擊
Add
,然後單擊OK
。 選擇新添加的觸發器並單擊
Edit Thresholds
。- 在下拉列表中選擇
Above
。 - 將閾值更改為
80
。 輸入
20
作為秒數。 如果需要,您可以調整此值,但請注意不要指定少量秒數,以防止錯誤觸發。- 單擊
OK
。 - 單擊
Next
。 - 單擊
Add Dump Target
。 - 從下拉列表中選擇
Web Application Pool
。 - 從應用程序池列表中選擇您的應用程序池。
- 單擊
OK
。 - 單擊
Next
。 - 再次單擊
Next
。 - 如果您願意,請為您的規則輸入一個名稱,並記下將保存轉儲的位置。 如果需要,您可以更改此位置。
- 單擊
Next
。 - 選擇
Activate the Rule Now
並單擊Finish
。
所描述的規則將創建一組相當小的小型轉儲文件。 最終轉儲將是具有完整內存的轉儲,並且轉儲將大得多。 現在,我們只需要等待高 CPU 事件再次發生。
一旦我們在選定的文件夾中有轉儲文件,我們將使用 DebugDiag 分析工具來分析收集的數據:
選擇性能分析器。
添加轉儲文件。
開始分析。
DebugDiag 將需要幾分鐘(或幾分鐘)來解析轉儲並提供分析。 完成分析後,您將看到一個網頁,其中包含摘要和大量有關線程的信息,類似於以下內容:
正如您在摘要中看到的那樣,有一條警告說“在一個或多個線程上檢測到轉儲文件之間的 CPU 使用率過高”。 如果我們點擊推薦,我們將開始了解我們的應用程序的問題所在。 我們的示例報告如下所示:
正如我們在報告中看到的,有一個關於 CPU 使用率的模式。 所有具有高 CPU 使用率的線程都與同一類相關。 在跳轉到代碼之前,讓我們看一下第一個。
這是我們問題的第一個線程的詳細信息。 我們感興趣的部分如下:
在這裡,我們調用了我們的代碼GameHub.OnDisconnected()
,它觸發了有問題的操作,但在此調用之前,我們有兩個 Dictionary 調用,這可以讓我們了解正在發生的事情。 讓我們看一下 .NET 代碼,看看該方法在做什麼:
public override Task OnDisconnected() { try { var userId = GetUserId(); string connId; if (onlineSessions.TryGetValue(userId, out connId)) onlineSessions.Remove(userId); } catch (Exception) { // ignored } return base.OnDisconnected(); }
我們這裡顯然有問題。 報告的調用堆棧說問題出在字典上,在這段代碼中,我們正在訪問字典,特別是導致問題的行是這一行:
if (onlineSessions.TryGetValue(userId, out connId))
這是字典聲明:
static Dictionary<int, string> onlineSessions = new Dictionary<int, string>();
這個 .NET 代碼有什麼問題?
每個有面向對象編程經驗的人都知道靜態變量將被這個類的所有實例共享。 讓我們更深入地了解靜態在 .NET 世界中的含義。
根據 .NET C# 規範:
使用 static 修飾符聲明一個靜態成員,該成員屬於類型本身而不是特定對象。
這是 .NET C# 語言規範關於靜態類和成員的說明:
與所有類類型一樣,當加載引用該類的程序時,.NET Framework 公共語言運行時 (CLR) 會加載靜態類的類型信息。 程序無法準確指定加載類的時間。 但是,保證在程序中第一次引用該類之前加載它並初始化其字段並調用其靜態構造函數。 靜態構造函數只被調用一次,靜態類在程序所在的應用程序域的整個生命週期內都保留在內存中。
非靜態類可以包含靜態方法、字段、屬性或事件。 即使沒有創建類的實例,靜態成員也可以在類上調用。 靜態成員總是由類名訪問,而不是實例名。 無論創建了多少個類實例,都只存在一個靜態成員的副本。 靜態方法和屬性不能訪問其包含類型中的非靜態字段和事件,並且它們不能訪問任何對象的實例變量,除非它在方法參數中顯式傳遞。
這意味著靜態成員屬於類型本身,而不是對象。 它們也由 CLR 加載到應用程序域中,因此靜態成員屬於託管應用程序的進程而不是特定線程。
鑑於 Web 環境是多線程環境,因為每個請求都是由w3wp.exe
進程生成的新線程; 並且鑑於靜態成員是進程的一部分,我們可能會遇到這樣的場景,即多個不同的線程嘗試訪問靜態(由多個線程共享)變量的數據,這最終可能導致多線程問題。
線程安全下的 Dictionary 文檔說明如下:
Dictionary<TKey, TValue>
可以同時支持多個讀取器,只要不修改集合即可。 即便如此,通過集合枚舉本質上不是線程安全的過程。 在枚舉與寫訪問競爭的極少數情況下,必須在整個枚舉期間鎖定集合。 要允許集合被多個線程訪問以進行讀寫,您必須實現自己的同步。
該聲明解釋了為什麼我們可能會遇到此問題。 根據轉儲信息,問題出在字典 FindEntry 方法上:
如果我們查看字典 FindEntry 實現,我們可以看到該方法遍歷內部結構(桶)以查找值。
所以下面的 .NET 代碼正在枚舉集合,這不是線程安全的操作。
public override Task OnDisconnected() { try { var userId = GetUserId(); string connId; if (onlineSessions.TryGetValue(userId, out connId)) onlineSessions.Remove(userId); } catch (Exception) { // ignored } return base.OnDisconnected(); }
結論
正如我們在轉儲中看到的,有多個線程試圖同時迭代和修改一個共享資源(靜態字典),最終導致迭代進入無限循環,導致線程消耗超過 90% 的 CPU .
這個問題有幾種可能的解決方案。 我們首先實現的一個是以損失性能為代價來鎖定和同步對字典的訪問。 那個時候服務器每天都在崩潰,所以我們需要盡快修復這個問題。 即使這不是最佳解決方案,它也解決了問題。
解決此問題的下一步將是分析代碼並為此找到最佳解決方案。 重構代碼是一種選擇:新的 ConcurrentDictionary 類可以解決這個問題,因為它只鎖定在存儲桶級別,這將提高整體性能。 雖然,這是一大步,還需要進一步分析。