優(yōu)化.NET 應用程序 CPU 和內存的11 個(gè)實(shí)踐
前言
凡事都有其限度,對吧?汽車(chē)只能開(kāi)這么快,進(jìn)程只能使用這么多內存,程序員只能喝這么多咖啡。我們的生產(chǎn)力受到資源的限制,我們有能力更好或更差地利用它們。盡可能接近其極限使用我們的每一種資源是我們的目標,我們希望使用我們的 CPU 和內存的每一點(diǎn),否則我們會(huì )為昂貴的機器多付錢(qián)。然而,若是我們使用了過(guò)多的資源,我們就有可能導致性能問(wèn)題、服務(wù)不可用問(wèn)題和程序宕機底崩潰問(wèn)題。軟件開(kāi)發(fā)看似簡(jiǎn)單,但一旦遇到性能問(wèn)題,就會(huì )變得非常棘手,這就是我們今天要討論的內容。
定義最佳基準
讓我們嘗試描述我們的最佳應用程序行為。假設我們有許多服務(wù)器機器需要處理高吞吐量的請求。為簡(jiǎn)單起見(jiàn),讓我們暫時(shí)忘記高峰時(shí)間或周末。我們的服務(wù)器負載在一天中的所有時(shí)間都或多或少相同。我們?yōu)檫@些服務(wù)器機器支付了很多錢(qián),我們希望從它們那里獲得盡可能多的價(jià)值,這意味著(zhù)處理盡可能多的請求。按照我們對簡(jiǎn)單性的承諾,我們還假設服務(wù)器僅使用內存和 CPU 來(lái)處理所述請求,并且沒(méi)有其他瓶頸,例如慢速網(wǎng)絡(luò )或鎖爭用。
在所描述的場(chǎng)景中,我們的最佳行為是在任何給定時(shí)間使用盡可能多的 CPU 和內存,對嗎?這樣,我們可以用更少的機器來(lái)處理相同數量的請求。但是你可能不想利用這些資源中的 99.9%,因為負載的輕微增加可能會(huì )導致性能問(wèn)題、服務(wù)器崩潰、數據丟失和其他令人頭疼的問(wèn)題。所以我們應該選擇一個(gè)有足夠緩沖問(wèn)題的數值。平均 85% 或 90% 的 CPU 和內存利用率聽(tīng)起來(lái)是正確的。
我們應該首先優(yōu)化什么?
我們的應用程序不是為平等利用 CPU 和內存而構建的。或者到它托管的機器的確切限制。因此,你首先應該查看的是你的服務(wù)器是CPU-bound還是Memory-bound。當服務(wù)器受 CPU 限制時(shí),這意味著(zhù)服務(wù)器可以處理的吞吐量受到其 CPU 的限制。換句話(huà)說(shuō),如果你嘗試處理更多請求,CPU 將在其他資源(如內存)達到其限制之前達到 100%。同樣的邏輯也適用于Memory-bound服務(wù)器。
服務(wù)器的吞吐量將受到它可以分配的內存的限制,當嘗試處理更多負載時(shí),在其他資源(如 CPU)達到其限制之前,該內存將達到 100%。還有其他資源可以限制服務(wù)器,例如I/O,在這種情況下,吞吐量會(huì )受到磁盤(pán)或網(wǎng)絡(luò )的讀取或寫(xiě)入限制。但是我們將在這篇文章中忽略這一點(diǎn),樂(lè )觀(guān)地假設我們的 I/O 是快速且無(wú)限的。一旦你知道是什么限制了你的服務(wù)器的性能,你就會(huì )知道首先要嘗試和優(yōu)化什么。
如果你的服務(wù)器受 CPU 限制,那么優(yōu)化內存使用沒(méi)有意義,因為它不會(huì )提高處理的吞吐量。事實(shí)上,它可能會(huì )損害吞吐量,因為你可能會(huì )因為更多的 CPU 利用率而提高內存使用率。對于內存受限的服務(wù)器也是如此,在這種情況下,你應該在查看 CPU 之前優(yōu)化內存使用。
測量 .NET 服務(wù)器中的 CPU 和內存消耗
CPU 和內存的實(shí)際測量最簡(jiǎn)單的是使用Performance Counters完成。CPU 使用率的指標是Process | % 處理器時(shí)間。內存有幾個(gè)指標,但我建議查看Process | 私有字節。你可能還對**.NET CLR 內存感興趣 | # 代表托管內存的所有堆中的字節**(CLR 占用的部分,而不是所有內存,即托管 + 本機內存)。要查看性能計數器,你可以在 Windows 計算機上使用Process Explorer或 PerfMon,或者在 .NET Core 服務(wù)器上使用dotnet-counters 。如果你的應用程序部署在云中,你可以使用像Application Insights(Azure Monitor的一部分)這樣的 APM 工具來(lái)顯示這些信息。或者,你可以在代碼中獲取性能計數器值并每 10 秒左右記錄一次,使用Azure 數據資源管理器之類(lèi)的工具在圖表中顯示數據。
一旦確定了哪些資源限制了你的 .NET 服務(wù)器,就該優(yōu)化該資源消耗了。如果你受 CPU 限制,讓我們減少 CPU 使用率。如果你受內存限制,讓我們減少內存使用量。至少如果你在云中運行,一種簡(jiǎn)單的方法是更改機器規格。如果你受內存限制,請增加內存。如果你受 CPU 限制,請增加內核數量或獲得更快的 CPU。這將提高成本,但在此之前,你可以檢查一些容易實(shí)現的目標,以?xún)?yōu)化 CPU 或內存消耗。在更改機器規格之前嘗試進(jìn)行這些優(yōu)化,因為優(yōu)化后一切都會(huì )改變。你可能會(huì )優(yōu)化 CPU 使用率并變得受內存限制。然后優(yōu)化內存使用并再次成為 CPU 密集型。因此,如果你想避免不得不不斷更改機器資源以適應最新的優(yōu)化,最好把它留到最后。所以讓我們談?wù)勔恍﹥却鎯?yōu)化。 優(yōu)化內存使用 有很多方法可以?xún)?yōu)化 .NET 中的內存使用。深入討論它們需要一整本書(shū),而且已經(jīng)有好幾本了。但我會(huì )盡量給你一些方向和想法。 1、了解什么占用了你的內存 嘗試優(yōu)化內存時(shí),你應該做的第一件事是了解全局。什么占用了大部分內存?有哪些數據類(lèi)型?它們分配在哪里?它們會(huì )在記憶中停留多久?有幾種工具可以獲取此信息:?捕獲轉儲文件并使用內存分析器或WinDbg打開(kāi)它。?使用新的GC 轉儲(.NET Core 3.1+) 并使用 Visual Studio 進(jìn)行調查。?捕獲堆快照并使用內存分析器、PerfView或Visual Studio 診斷工具對其進(jìn)行探索。此分析將顯示哪些對象占用了你的大部分內存。如果你發(fā)現它被采取了 2、了解誰(shuí)把內存放在了哪里 找出誰(shuí)引用了最大的內存塊很棒,但這可能還不夠。有時(shí)你需要知道這些內存是如何分配的。你可能從引用路徑中知道,一些占用大部分內存的對象位于緩存中,但誰(shuí)將它們放在那里?來(lái)自單個(gè)時(shí)間點(diǎn)的內存快照無(wú)法提供該答案。為此,你需要分配堆棧跟蹤。分析器使你能夠記錄你的應用程序并在每次分配時(shí)保存調用堆棧。例如,你可能會(huì )發(fā)現創(chuàng )建有問(wèn)題 ?使用 PerfView 的 GC Heap [] Stacks 之一 分配讓你全面了解占用大部分內存的內容以及它是如何產(chǎn)生的。一旦你知道了這一點(diǎn),你就可以開(kāi)始切割最大的塊并優(yōu)化它們以減少內存使用。 3、檢查內存泄漏 在 .NET 中導致內存泄漏非常容易。有了足夠多的泄漏,內存消耗會(huì )隨著(zhù)時(shí)間的推移而增加,你會(huì )遇到各種各樣的問(wèn)題。內存瓶頸就是其中之一,但由于 GC 壓力,你最終也會(huì )遇到 CPU 問(wèn)題。當你不再需要對象但由于某種原因它們仍然被引用并且垃圾收集器永遠不會(huì )釋放它們時(shí),就會(huì )發(fā)生內存泄漏。發(fā)生這種情況的原因有很多。要了解你是否有嚴重的內存泄漏,請查看一段時(shí)間內的內存消耗圖表(進(jìn)程 | 私有字節計數器)。如果內存一直在增加,而沒(méi)有偏離某個(gè)水平,則可能存在內存泄漏。 使用內存分析器調試泄漏相當簡(jiǎn)單。 4、切換到 GC 工作站模式 .NET 中有幾種垃圾收集器模式。主要的兩種模式是Workstation GC和Server GC。Workstation GC 針對更短的 GC 暫停和更快的交互性進(jìn)行了優(yōu)化,非常適合桌面應用程序。服務(wù)器 GC 具有更長(cháng)的 GC 暫停時(shí)間,并且針對更高的吞吐量進(jìn)行了優(yōu)化。 在 Server GC 模式下,應用程序可以在垃圾回收之間處理更多數據。服務(wù)器 GC 為每個(gè) CPU 核心創(chuàng )建不同的托管堆。這意味著(zhù)不同的 X 代內存空間需要更長(cháng)的時(shí)間才能填滿(mǎn),因此內存消耗會(huì )更高。你基本上是在用內存換取吞吐量。從 GC 服務(wù)器模式(.NET 服務(wù)器的默認模式)更改為 GC 工作站模式將減少內存使用量。這在請求負載不重的小型應用程序中可能是合理的。也許在與主應用程序一起運行的 IIS 主機中的輔助進(jìn)程中。Sergey Tepliakov對此有一篇很棒的文章。 5、檢查你的緩存 在第 1 步之后,你應該能夠看到哪些對象占用了你的內存,但我想特別強調緩存。每當涉及到高內存消耗時(shí),根據我的經(jīng)驗,它總是最終成為內存泄漏或緩存。緩存似乎是許多問(wèn)題的神奇解決方案。當你可以將結果保存在內存中并重新使用它時(shí),為什么要執行兩次?但是緩存是有代價(jià)的。一個(gè)簡(jiǎn)單的實(shí)現會(huì )將對象永遠保存在內存中。你應該按時(shí)間限制或以其他方式使緩存無(wú)效。緩存還會(huì )將臨時(shí)對象留在內存中相對較長(cháng)的時(shí)間,這會(huì )導致更多的 Gen 1 和 Gen 2 收集,進(jìn)而導致GC 壓力。以下是一些優(yōu)化內存緩存的想法: ?使用.NET 中的現有緩存實(shí)現可以輕松創(chuàng )建失效策略。 ?考慮為某些事情選擇不緩存。你可能會(huì )用 CPU 或 IO 換取內存,但是當你受到內存限制時(shí),你應該這樣做。 ?考慮使用內存不足緩存。這可能是將數據保存在文件或本地數據庫中?;蛘呤褂孟馬edis這樣的分布式緩存解決方案。 6、定期調用GC.Collect() 這條建議是違反直覺(jué)的,因為最好的做法是永遠不要調用 因此,GC 的自私本性可能是生活在同一臺機器上的**其他進(jìn)程的問(wèn)題,可能托管在同一個(gè) IIS 上。 這種多余的內存可能會(huì )導致其他進(jìn)程更快地達到它們的極限,或者導致它們各自的垃圾收集器更加努力地工作,因為它們可能錯誤地認為它們即將耗盡內存。你可能會(huì )認為,如果其他進(jìn)程的 GC 會(huì )達到認為我們內存不足并因此更加努力地工作的程度,那么我們自己的進(jìn)程也會(huì )這樣認為并觸發(fā)垃圾收集來(lái)解決問(wèn)題。但我們不能做出這樣的假設。一方面,這些進(jìn)程可能運行不同的 GC 實(shí)現版本(因為不同的 CLR 版本)。此外,你有不同的應用程序行為可以使 GC 以不同的方式工作。例如,一個(gè)進(jìn)程可能會(huì )以更高的速率分配內存,因此 GC 將更快地開(kāi)始“強調”可用內存。底線(xiàn)是軟件很困難,當你在一臺機器上有多個(gè)進(jìn)程時(shí),就像 IIS 一樣,你需要考慮到這一點(diǎn),并可能采取一些不尋常的步驟。 優(yōu)化 CPU 使用率 硬幣的另一面是 CPU 使用率。一旦你發(fā)現 CPU 是應用程序吞吐量的瓶頸,就需要做很多事情。 1、分析你的應用程序 優(yōu)化 CPU 的第一步是了解它。究竟是什么原因造成的?哪些方法負責?哪些請求是最大的 CPU 消耗者,哪些是流量?這一切都可以通過(guò)分析應用程序來(lái)解決。分析允許你記錄執行范圍并顯示所有被調用的方法以及它們在記錄期間使用了多少 CPU。分析器通常允許將這些結果視為普通列表、調用樹(shù)甚至火焰圖。這是 PerfView 中的簡(jiǎn)單列表視圖: 這是相同場(chǎng)景的火焰圖: 你可以通過(guò)以下方式分析你的應用: ?如果場(chǎng)景在本地重現,請使用性能分析器,如PerfView、dotTrace、ANTS perf profiler,或在你的開(kāi)發(fā)計算機上使用 Visual Studio 。 ?在生產(chǎn)環(huán)境中,最簡(jiǎn)單的分析方法是使用應用程序性能監控 (APM) 工具,例如Azure Application Insights profiler或RayGun。 ?你可以通過(guò)將代理復制到生產(chǎn)機器并記錄快照來(lái)分析沒(méi)有 APM 的生產(chǎn)環(huán)境。使用 PerfView,你應該復制整個(gè)程序。它結構緊湊,無(wú)需安裝。使用 dotTrace,你可以復制允許在生產(chǎn)中記錄快照的輕量級代理。 ?在 .NET Core 3.0+ 應用程序中,你可以安裝 .NET Core 3.0 SDK 并使用 dotnet-trace 命令行工具記錄快照,然后使用 PerfView 將其復制到開(kāi)發(fā)機器并進(jìn)行分析。 2、檢查垃圾收集器的使用情況 我想說(shuō)優(yōu)化 .NET CPU 使用最重要的一點(diǎn)是正確的內存管理。在這方面要問(wèn)的重要問(wèn)題是:“垃圾收集浪費了多少 CPU?”。GC 的工作方式是在收集期間,你的執行線(xiàn)程被凍結。這意味著(zhù)垃圾收集直接影響性能。因此,如果你受 CPU 限制,我建議你檢查的第一件事是性能計數器。NET CLR 內存 | % GC 時(shí)間。我不能給你一個(gè)指示問(wèn)題的神奇數字,但根據經(jīng)驗,當這個(gè)值超過(guò) 20% 時(shí),你可能會(huì )遇到問(wèn)題。如果超過(guò) 40%,那么你肯定有問(wèn)題。如此高的百分比表明 GC 壓力,并且有辦法處理它。 3、使用數組和對象池來(lái)重用內存 陣列的分配和不可避免的解除分配可能非常昂貴。高頻率執行這些分配會(huì )造成 GC 壓力并消耗大量 CPU 時(shí)間。解決這個(gè)問(wèn)題的一個(gè)好方法是使用內置的 我們已經(jīng)討論過(guò)轉移到GC 工作站模式以節省內存。但如果你受 CPU 限制,請考慮切換到服務(wù)器模式以節省 CPU。權衡是服務(wù)器模式以更多內存為代價(jià)允許更高的吞吐量。 因此,如果你保持相同的吞吐量,你最終將節省 CPU 時(shí)間,否則垃圾收集會(huì )花費這些時(shí)間。默認情況下,.NET 服務(wù)器很可能具有 GC 服務(wù)器模式,因此可能不需要此更改。但是可能有人之前將其更改為工作站模式,在這種情況下,你應該小心將其更改回來(lái),因為他們可能有充分的理由。 更改時(shí),請務(wù)必監控內存消耗和 GC 中的 % Time。你可能想查看第 2 代回收率,但如果這個(gè)數字很高,它將反映在更高的 GC 時(shí)間百分比中。 5、檢查其他進(jìn)程 當試圖將你的服務(wù)器發(fā)揮到最佳極限時(shí),你可能想要徹底了解它,這意味著(zhù)不要放棄存在于你的進(jìn)程之外的問(wèn)題。很有可能其他進(jìn)程不時(shí)消耗一堆CPU,并導致一段時(shí)間的性能下降。這些可能是你在 IIS 上部署的其他應用程序、定期 Web 作業(yè)、由操作系統觸發(fā)的東西、防病毒程序或其他一千種東西。對此進(jìn)行分析的一種方法是使用 PerfView 記錄整個(gè)系統中的 ETW 事件。PerfView 從所有進(jìn)程中捕獲 CPU 堆棧。你可以以很小的性能開(kāi)銷(xiāo)運行它很長(cháng)時(shí)間。你可以在達到某個(gè) CPU 峰值時(shí)自動(dòng)停止收集并進(jìn)行挖掘。你可能會(huì )對結果感到驚訝。 總結 在我看來(lái),從自上而下的層面處理大規模的性能問(wèn)題是令人著(zhù)迷的。你可能有一個(gè)團隊花費數月時(shí)間優(yōu)化一段代碼,相比之下,資源分配的簡(jiǎn)單更改將產(chǎn)生更大的影響。而且,如果你的業(yè)務(wù)足夠大,那么這個(gè)微小的變化就會(huì )轉化為一大筆錢(qián)。你記得在你的合同中要求一個(gè)傭金條款嗎?無(wú)論如何,我希望這篇文章對你有用。提示:檢查機器級指標和進(jìn)程級指標。你可能會(huì )發(fā)現其他進(jìn)程正在限制你的性能。
MyProgram.CustomerData
那就更好了。但通常,最大的對象類(lèi)型是string
、byte[]
或byte[][]
。由于應用程序中的幾乎所有內容都可以使用這些類(lèi)型,因此你需要找到引用它們的人。為此,查看所占用的包容性內存(又名保留內存)很重要。這個(gè)指標不僅包括對象本身占用的內存,還包括它引用的對象占用的內存。例如,你可能會(huì )發(fā)現它MyProgram.Inventory.Item
本身并不占用太多內存,但它引用了一個(gè)byte[]
它保存內存中的圖像并占用高達 70% 的內存。上面描述的所有工具都可以顯示包含最多字節的對象和到 GC 根的引用路徑(也就是到根的最短路徑)。MyProgram.Inventory.Item
對象的流程將它們分配到調用堆棧App.OnShowHistoryClicked | App.SeeItemHistory | App.GetItemFromDatabase
中。要獲得分配堆棧,你可以:?使用商業(yè)內存分析器來(lái)顯示分配。GC.Collect()
. 垃圾收集器很聰明,它應該自己知道何時(shí)觸發(fā)收集。但問(wèn)題是垃圾收集器只考慮自己的進(jìn)程。如果它沒(méi)有足夠的內存,它會(huì )小心觸發(fā)收集并騰出空間。但如果它確實(shí)有足夠的內存,GC 會(huì )非常樂(lè )意忍受過(guò)多的內存消耗。ArrayPool
ObjectPool (僅限 .NET Core)。這個(gè)想法很簡(jiǎn)單。為數組或對象分配一個(gè)共享緩沖區,然后在不分配和取消分配新內存的情況下重復使用。這是一個(gè)簡(jiǎn)單的使用示例ArrayPool
:public void Foo()
{
var pool = ArrayPool<int>.Shared;
int[] array = pool.Rent(ArraySize);// do stuf
pool.Return(array);
}4、切換到 GC 服務(wù)器模式