優化.NET 應用程序 CPU 和內存的11 個實踐

  • 2022 年 1 月 27 日
  • 筆記

//michaelscodingspot.com/cpu-bound-memory-bound/

優化.NET 應用程序 CPU 和內存的11 個實踐

凡事都有其限度,對吧?汽車只能開這麼快,進程只能使用這麼多內存,程序員只能喝這麼多咖啡。我們的生產力受到資源的限制,我們有能力更好或更差地利用它們。儘可能接近其極限使用我們的每一種資源是我們的目標,我們希望使用我們的 CPU 和內存的每一點,否則我們會為昂貴的機器多付錢。

然而,若是我們使用了過多的資源,我們就有可能導致性能問題、服務不可用問題和程序宕機底崩潰問題。軟件開發看似簡單,但一旦遇到性能問題,就會變得非常棘手,這就是我們今天要討論的內容。

定義最佳基準

讓我們嘗試描述我們的最佳應用程序行為。假設我們有許多服務器機器需要處理高吞吐量的請求。為簡單起見,讓我們暫時忘記高峰時間或周末。我們的服務器負載在一天中的所有時間都或多或少相同。我們為這些服務器機器支付了很多錢,我們希望從它們那裡獲得儘可能多的價值,這意味着處理儘可能多的請求。按照我們對簡單性的承諾,我們還假設服務器僅使用內存和 CPU 來處理所述請求,並且沒有其他瓶頸,例如慢速網絡或鎖爭用。

在所描述的場景中,我們的最佳行為是在任何給定時間使用儘可能多的 CPU 和內存,對嗎?這樣,我們可以用更少的機器來處理相同數量的請求。但是您可能不想利用這些資源中的 99.9%,因為負載的輕微增加可能會導致性能問題、服務器崩潰、數據丟失和其他令人頭疼的問題。所以我們應該選擇一個有足夠緩衝問題的數值。平均 85% 或 90% 的 CPU 和內存利用率聽起來是正確的。

我們應該首先優化什麼?

我們的應用程序不是為平等利用 CPU 和內存而構建的。或者到它託管的機器的確切限制。因此,您首先應該查看的是您的服務器是CPU-bound還是Memory-bound。當服務器受 CPU 限制時,這意味着服務器可以處理的吞吐量受到其 CPU 的限制。換句話說,如果您嘗試處理更多請求,CPU 將在其他資源(如內存)達到其限制之前達到 100%。同樣的邏輯也適用於Memory-bound服務器。服務器的吞吐量將受到它可以分配的內存的限制,當嘗試處理更多負載時,在其他資源(如 CPU)達到其限制之前,該內存將達到 100%。

還有其他資源可以限制服務器,例如I/O,在這種情況下,吞吐量會受到磁盤或網絡的讀取或寫入限制。但是我們將在這篇文章中忽略這一點,樂觀地假設我們的 I/O 是快速且無限的。

一旦你知道是什麼限制了你的服務器的性能,你就會知道首先要嘗試和優化什麼。如果您的服務器受 CPU 限制,那麼優化內存使用沒有意義,因為它不會提高處理的吞吐量。事實上,它可能會損害吞吐量,因為您可能會因為更多的 CPU 利用率而提高內存使用率。對於內存受限的服務器也是如此,在這種情況下,您應該在查看 CPU 之前優化內存使用。

測量 .NET 服務器中的 CPU 和內存消耗

CPU 和內存的實際測量最簡單的是使用Performance Counters完成。CPU 使用率的指標是Process | % 處理器時間。內存有幾個指標,但我建議查看Process | 私有位元組。您可能還對.NET CLR 內存感興趣 | # 代表託管內存的所有堆中的位元組(CLR 佔用的部分,而不是所有內存,即託管 + 本機內存)。

要查看性能計數器,您可以在 Windows 計算機上使用Process Explorer或 PerfMon,或者在 .NET Core 服務器上使用dotnet-counters 。如果您的應用程序部署在雲中,您可以使用像Application InsightsAzure Monitor的一部分)這樣的 APM 工具來顯示這些信息。或者,您可以在代碼中獲取性能計數器值並每 10 秒左右記錄一次,使用Azure 數據資源管理器之類的工具在圖表中顯示數據。

提示:檢查機器級指標和進程級指標。您可能會發現其他進程正在限制您的性能。

一旦確定了哪些資源限制了您的 .NET 服務器,就該優化該資源消耗了。如果您受 CPU 限制,讓我們減少 CPU 使用率。如果您受內存限制,讓我們減少內存使用量。

至少如果您在雲中運行,一種簡單的方法是更改機器規格。如果您受內存限制,請增加內存。如果您受 CPU 限制,請增加內核數量或獲得更快的 CPU。這將提高成本,但在此之前,您可以檢查一些容易實現的目標,以優化 CPU 或內存消耗。在更改機器規格之前嘗試進行這些優化,因為優化後一切都會改變。您可能會優化 CPU 使用率並變得受內存限制。然後優化內存使用並再次成為 CPU 密集型。因此,如果您想避免不得不不斷更改機器資源以適應最新的優化,最好把它留到最後。

所以讓我們談談一些內存優化。

優化內存使用

有很多方法可以優化 .NET 中的內存使用。深入討論它們需要一整本書,而且已經有好幾本了。但我會盡量給你一些方向和想法。

1. 了解什麼佔用了你的內存

嘗試優化內存時,您應該做的第一件事是了解全局。什麼佔用了大部分內存?有哪些數據類型?它們分配在哪裡?它們會在記憶中停留多久?

有幾種工具可以獲取此信息:

此分析將顯示哪些對象佔用了您的大部分內存。如果你發現它被採取了MyProgram.CustomerData那就更好了。但通常,最大的對象類型是stringbyte[]byte[][]。由於應用程序中的幾乎所有內容都可以使用這些類型,因此您需要找到引用它們的人。為此,查看所佔用的包容性內存(又名保留內存)很重要。這個指標不僅包括對象本身佔用的內存,還包括它引用的對象佔用的內存。例如,您可能會發現它MyProgram.Inventory.Item本身並不佔用太多內存,但它引用了一個byte[]它保存內存中的圖像並佔用高達 70% 的內存。上面描述的所有工具都可以顯示包含最多位元組的對象和到 GC 根的引用路徑(也就是到根的最短路徑)。

2. 了解誰把內存放在了哪裡

找出誰引用了最大的內存塊很棒,但這可能還不夠。有時您需要知道這些內存是如何分配的。您可能從引用路徑中知道,一些佔用大部分內存的對象位於緩存中,但誰將它們放在那裡?來自單個時間點的內存快照無法提供該答案。為此,您需要分配堆棧跟蹤。分析器使您能夠記錄您的應用程序並在每次分配時保存調用堆棧。例如,您可能會發現創建有問題MyProgram.Inventory.Item對象的流程將它們分配到調用堆棧App.OnShowHistoryClicked | App.SeeItemHistory | App.GetItemFromDatabase中。

要獲得分配堆棧,您可以:

分配讓您全面了解佔用大部分內存的內容以及它是如何產生的。一旦你知道了這一點,你就可以開始切割最大的塊並優化它們以減少內存使用。

3.檢查內存泄漏

在 .NET 中導致內存泄漏非常容易。有了足夠多的泄漏,內存消耗會隨着時間的推移而增加,你會遇到各種各樣的問題。內存瓶頸就是其中之一,但由於 GC 壓力,您最終也會遇到 CPU 問題。

當您不再需要對象但由於某種原因它們仍然被引用並且垃圾收集器永遠不會釋放它們時,就會發生內存泄漏。發生這種情況的原因有很多。

要了解您是否有嚴重的內存泄漏,請查看一段時間內的內存消耗圖表(進程 | 私有位元組計數器)。如果內存一直在增加,而沒有偏離某個水平,則可能存在內存泄漏。

使用內存分析器調試泄漏相當簡單。

4. 切換到 GC 工作站模式

.NET 中有幾種垃圾收集器模式。主要的兩種模式是Workstation GCServer GC。Workstation GC 針對更短的 GC 暫停和更快的交互性進行了優化,非常適合桌面應用程序。服務器 GC 具有更長的 GC 暫停時間,並且針對更高的吞吐量進行了優化。在 Server GC 模式下,應用程序可以在垃圾回收之間處理更多數據。

服務器 GC 為每個 CPU 核心創建不同的託管堆。這意味着不同的 X 代內存空間需要更長的時間才能填滿,因此內存消耗會更高。您基本上是在用內存換取吞吐量。從 GC 服務器模式(.NET 服務器的默認模式)更改為 GC 工作站模式將減少內存使用量。這在請求負載不重的小型應用程序中可能是合理的。也許在與主應用程序一起運行的 IIS 主機中的輔助進程中。

Sergey Tepliakov對此有一篇很棒的文章。

5.檢查你的緩存

在第 1 步之後,您應該能夠看到哪些對象佔用了您的內存,但我想特彆強調緩存。每當涉及到高內存消耗時,根據我的經驗,它總是最終成為內存泄漏或緩存。

緩存似乎是許多問題的神奇解決方案。當您可以將結果保存在內存中並重新使用它時,為什麼要執行兩次?但是緩存是有代價的。一個簡單的實現會將對象永遠保存在內存中。您應該按時間限制或以其他方式使緩存無效。緩存還會將臨時對象留在內存中相對較長的時間,這會導致更多的 Gen 1 和 Gen 2 收集,進而導致GC 壓力

以下是一些優化內存緩存的想法:

  • 使用.NET 中的現有緩存實現可以輕鬆創建失效策略。
  • 考慮為某些事情選擇不緩存。您可能會用 CPU 或 IO 換取內存,但是當您受到內存限制時,您應該這樣做。
  • 考慮使用內存不足緩存。這可能是將數據保存在文件或本地數據庫中。或者使用像Redis這樣的分佈式緩存解決方案。

6.定期調用GC.Collect()

這條建議是違反直覺的,因為最好的做法是永遠不要調用GC.Collect(). 垃圾收集器很聰明,它應該自己知道何時觸發收集。但問題是垃圾收集器只考慮自己的進程。如果它沒有足夠的內存,它會小心觸發收集並騰出空間。但如果它確實有足夠的內存,GC 會非常樂意忍受過多的內存消耗。因此,GC 的自私本性可能是生活在同一台機器上的其他進程的問題,可能託管在同一個 IIS 上。這種多餘的內存可能會導致其他進程更快地達到它們的極限,或者導致它們各自的垃圾收集器更加努力地工作,因為它們可能錯誤地認為它們即將耗盡內存。

您可能會認為,如果其他進程的 GC 會達到認為我們內存不足並因此更加努力地工作的程度,那麼我們自己的進程也會這樣認為並觸發垃圾收集來解決問題。但我們不能做出這樣的假設。一方面,這些進程可能運行不同的 GC 實現版本(因為不同的 CLR 版本)。此外,您有不同的應用程序行為可以使 GC 以不同的方式工作。例如,一個進程可能會以更高的速率分配內存,因此 GC 將更快地開始「強調」可用內存。底線是軟件很困難,當你在一台機器上有多個進程時,就像 IIS 一樣,你需要考慮到這一點,並可能採取一些不尋常的步驟。

優化 CPU 使用率

硬幣的另一面是 CPU 使用率。一旦您發現 CPU 是應用程序吞吐量的瓶頸,就需要做很多事情。

1. 分析您的應用程序

優化 CPU 的第一步是了解它。究竟是什麼原因造成的?哪些方法負責?哪些請求是最大的 CPU 消耗者,哪些是流量?這一切都可以通過分析應用程序來解決。

分析允許您記錄執行範圍並顯示所有被調用的方法以及它們在記錄期間使用了多少 CPU。分析器通常允許將這些結果視為普通列表、調用樹甚至火焰圖。

這是 PerfView 中的簡單列表視圖:

這是相同場景的火焰圖:

您可以通過以下方式分析您的應用:

2.檢查垃圾收集器的使用情況

我想說優化 .NET CPU 使用最重要的一點是正確的內存管理。在這方面要問的重要問題是:「垃圾收集浪費了多少 CPU?」。GC 的工作方式是在收集期間,您的執行線程被凍結。這意味着垃圾收集直接影響性能。因此,如果您受 CPU 限制,我建議您檢查的第一件事是性能計數器NET CLR 內存 | % GC 時間

我不能給你一個指示問題的神奇數字,但根據經驗,當這個值超過 20% 時,你可能會遇到問題。如果超過 40%,那麼你肯定有問題。如此高的百分比表明 GC 壓力,並且有辦法處理它

3.使用數組和對象池來重用內存

陣列的分配和不可避免的解除分配可能非常昂貴。高頻率執行這些分配會造成 GC 壓力並消耗大量 CPU 時間。解決這個問題的一個好方法是使用內置的ArrayPoolObjectPool 僅限 .NET Core)。這個想法很簡單。為數組或對象分配一個共享緩衝區,然後在不分配和取消分配新內存的情況下重複使用。這是一個簡單的使用示例ArrayPool

public void Foo()
{
    var pool = ArrayPool<int>.Shared;
    int[] array = pool.Rent(ArraySize);// do stuf
    pool.Return(array);
}

4. 切換到 GC 服務器模式

我們已經討論過轉移到GC 工作站模式以節省內存。但如果您受 CPU 限制,請考慮切換到服務器模式以節省 CPU。權衡是服務器模式以更多內存為代價允許更高的吞吐量。因此,如果您保持相同的吞吐量,您最終將節省 CPU 時間,否則垃圾收集會花費這些時間。

默認情況下,.NET 服務器很可能具有 GC 服務器模式,因此可能不需要此更改。但是可能有人之前將其更改為工作站模式,在這種情況下,您應該小心將其更改回來,因為他們可能有充分的理由。

更改時,請務必監控內存消耗和 GC 中的 % Time。您可能想查看第 2 代回收率,但如果這個數字很高,它將反映在更高的 GC 時間百分比中。

5.檢查其他進程

當試圖將您的服務器發揮到最佳極限時,您可能想要徹底了解它,這意味着不要放棄存在於您的進程之外的問題。很有可能其他進程不時消耗一堆CPU,並導致一段時間的性能下降。這些可能是您在 IIS 上部署的其他應用程序、定期 Web 作業、由操作系統觸發的東西、防病毒程序或其他一千種東西。

對此進行分析的一種方法是使用 PerfView 記錄整個系統中的 ETW 事件。PerfView 從所有進程中捕獲 CPU 堆棧。您可以以很小的性能開銷運行它很長時間。您可以在達到某個 CPU 峰值時自動停止收集並進行挖掘。您可能會對結果感到驚訝。

總結

在我看來,從自上而下的層面處理大規模的性能問題是令人着迷的。您可能有一個團隊花費數月時間優化一段代碼,相比之下,資源分配的簡單更改將產生更大的影響。而且,如果您的業務足夠大,那麼這個微小的變化就會轉化為一大筆錢。你記得在你的合同中要求一個傭金條款嗎?無論如何,我希望這篇文章對你有用,如果你發現了,你可能會對我的書Practical Debugging for .NET 開發人員感興趣,我在其中深入討論了性能和內存問題的故障排除。