優化.NET 應用程式 CPU 和記憶體的11 個實踐

溪源More發表於2022-01-27

https://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 開發人員感興趣,我在其中深入討論了效能和記憶體問題的故障排除。

相關文章