.NET Memory Performance Analysis
知道什么時候該擔心,以及在需要擔心的時候該怎么做
譯者注
作者信息:Maoni Stephens - 微軟架構師,負責.NET Runtime GC設計與實現 博客鏈接 Github
譯者:Bing Translator、INCerry 博客鏈接:https://incerry.cnblogs.com 聯系郵箱:incerry@foxmail.com
本文已獲得Maoni大佬授權,另外感謝@曉青、@賈佬、@黑洞、@曉晨、@一線碼農 在百忙之中抽出時間校對和提出修改建議。
本文Github倉庫:https://github.com/InCerryGit/mem-doc/blob/master/doc/.NETMemoryPerformanceAnalysis.zh-CN.md
原文鏈接:https://github.com/Maoni0/mem-doc/blob/master/doc/.NETMemoryPerformanceAnalysis.md
本文90%通過機器翻譯,另外10%譯者按照自己的理解進行翻譯,和原文相比有所刪減,與原文並不是一一對應,但是意思基本一致。另外文章較長,還沒有足夠的時間完全校對好,后續還會對一些語句不通順、模糊和錯漏的地方進行補充,請關注文檔版本號。
文檔版本號 | 修訂記錄 | 修訂人 | 修訂日期 |
---|---|---|---|
0.0.1 | 翻譯文檔創建 | - | 2021-12-05 |
0.0.2 | 人工校對,修復超鏈接錯誤 | - | 2021-12-16 |
0.0.3 | 校對信息,修復樣式問題,修復描述問題 thx @曉青 @Maoni | - | 2021-12-17 |
譯者水平有限,如果錯漏歡迎批評指正
本文檔的目的
本文旨在幫助.NET開發者,如何思考內存性能分析,並在需要時找到正確的方法來進行這種分析。在本文檔中.NET的包括.NET Framework和.NET Core。為了在垃圾收集器和框架的其他部分獲得最新的內存改進,我強烈建議你使用.NET Core,如果你還沒有的話,因為那是應該盡快去升級的地方。
本文檔的狀態
這是一份正在完善的文檔。現在,這份文檔主要是在Windows上。添加相應的Linux材料肯定會使它更有用。我正計划在未來這樣做,但也非常歡迎其他朋友(尤其是對Linux部分)對該文件的貢獻。
如何閱讀本文檔
這是一份很長的文檔,但你不需要讀完它;你也不需要按順序閱讀各部分。根據你在做性能分析方面的經驗,有些章節可以完全跳過。
🔹 如果你對性能分析工作完全陌生,我建議從頭開始。
🔹 對於那些已經能自如地進行一般的性能分析工作,但希望增加他們在管理內存相關主題方面的知識的人,他們可以跳過開頭,直接進入基礎知識部分。
🔹 如果你不是很有經驗,並且在做一次性能分析,你可以跳到知道什么時候該擔心部分開始閱讀,如果需要,再參考基礎知識部分的具體內容。
🔹 如果你是一名性能工程師,其工作包括將托管內存分析作為一項常規任務,但又是.NET的新手,我強烈建議你真正閱讀並內化GC基礎部分,因為它能幫助你更快地關注正確的事情。然而,如果你手頭有一個緊急問題,你可以去看看我在本文檔中將要使用的工具,熟悉它,然后看看你是否能在GC暫停問題或堆大小問題部分找到相關症狀。
🔹 如果你已經有做托管內存性能分析工作的經驗,並且有具體的問題,你可以在GC停頓時間長或GC堆太大部分找到它。
注意!
當我在寫這篇文檔時,我打算根據分析的需要來介紹一些概念,如並發的GC或釘住。所以在你閱讀的過程中,你會逐漸接觸到它們。如果你已經知道它們是什么,並且正在尋找關於特定概念的解釋,這里有它們的鏈接-
LOH (LOH-Large-Object-Heap 大對象堆)
內容
◼️不要猜測,去測量
◼️虛擬內存基礎知識
◼️GC基礎
◼️頂層應用指標
◼️頂層的GC指標
◼️何時應擔心GC
◼️性能工具一覽
▪️個別的長時間停頓
◼️大尺寸的GC堆
- 調試OOM
- 峰值尺寸太大,但GC后尺寸不大?
- GC后尺寸很大?
- gen2 GC是否主要為后台GC?
- 你看到的堆的大小從GC的角度來看是合理的,但仍然希望有一個更小的堆?
- GC是否為自己的記賬工作使用了太多的內存?
◼️暫停時間太長
◼️運行時的文件版本
◼️性能數據
如何看待性能分析工作?
那些在做性能分析方面有經驗的人知道,這可能就像偵探工作一樣--沒有 "如果你按照這10個步驟去做,你就會改善性能或從根本上解決性能問題"的方法。這是為什么呢?因為在運行的東西不僅僅是你的代碼 - 還有你使用操作系統、運行時、庫(至少是BCL,但通常是許多其他的庫)。而運行你的代碼的線程需要與同一進程中的其他線程和/或其他進程共享機器/VM/容器。
然而,這並不意味着你需要對我剛才提到的一切有一個徹底的了解。否則我們都不會有任何成就 - 你根本沒有時間。但你不需要這樣做。你只需要了解足夠的基礎知識,掌握足夠的性能分析技能,這樣你就可以專注於自己代碼的性能分析。在本文中,我們將討論這兩點。我還會解釋事情為什么會這樣,這樣才有意義,而不是讓你背誦那些很容易被翻出來的東西。
這篇文檔談到了你自己可以做什么,以及什么時候是把分析工作交給GC團隊的好時機,因為這將是需要在運行時進行的改進。很明顯,我們在GC中仍然在做改進的工作(否則我就不會還在這個團隊中)。正如我們將看到的,GC的行為是由你的應用行為驅動的,所以你肯定可以改變你的應用行為來影響GC。在你作為性能工程師需要做多少工作和GC自動處理多少工作之間存在着一個平衡。.NET GC的理念是,我們盡量自動處理;當我們需要你的參與時(通過配置),我們會通過有意義的方式要求你的協助,這種方式是從應用程序的角度,並不要求你了解GC的全部細節。當然,GC團隊一直在努力讓.NET GC處理越來越多的性能場景,這樣用戶就不需要擔心了。但如果你遇到了GC目前不能很好處理的情況,我將指出你可以做什么來解決它。
我對性能分析的目標是使客戶需要做的大部分分析自動化。我們在這方面已經走了很長的路,但我們還沒有達到所有分析都自動化的程度。在這份文件中,我將告訴你目前做分析的方法,在文件的最后,我將給你一個展望,說明我們正在為實現這個目標做了什么樣的改進。
挑選正確的方法來做性能分析
我們都有有限的資源,如何將這些資源花在能夠產生最大回報的事情上是關鍵。這意味着你應該找到哪個部分是最值得優化的,以及如何以最有效的方式優化它們。當你斷定你需要優化某些東西,或者你要如何優化某些東西時,應該有一個合理的理由來說明你為什么這樣做。
知道你的目標是什么
當人們第一次來找我時,我總是問他們這樣一個問題 - 你的性能目標是什么?不同的產品有非常不同的性能要求。在你想出一個數字之前(例如,將某些東西提高X%),你需要知道你要優化的是什么。在最高層次的角度來看,這些是需要優化的方面 -
◼️ 優化內存占用,例如,需要在同一台機器上盡可能多地運行實例。
◼️ 優化吞吐量,例如,需要在一定的時間內處理盡可能多的請求。
◼️針對尾部延遲進行優化,例如,需要滿足一定的延遲SLA。
當然,你可以有多個這樣的要求,例如,你可能需要滿足一個SLA,但仍然需要在一個時間段內至少處理一定數量的請求。在這種情況下,你需要弄清楚什么是優先考慮的,這將決定你應該把大部分精力放在什么地方。
你要明白GC只是框架的一個部分
GC行為的改變可能是由於GC本身的變化或框架其他部分的變化,當你使用一個新版本時,框架中通常會有很多改動。當你在升級后看到內存行為的變化時,可能是由於GC的變化或框架中的其他東西開始分配更多的內存,並以不同的方式保留內存。此外,如果你升級你的操作系統版本或在不同的虛擬化環境中運行你的產品,你也可以得到不同的行為,因為它們可能導致你的應用程序出現不同的行為。
不要猜測,去測量
測量是你在開始一個產品時絕對應該計划做的事情,而不是在事情發生后才想到的,特別是當你知道你的產品需要在相當高負載的情況下運行時。如果你正在閱讀這份文件,那么你很有可能正在從事一些對性能有要求的工作。
對於我所接觸的大多數工程師來說,測量並不是一個陌生的概念。然而,如何測量和測量什么是我見過的許多人需要幫助的事情。
◼️ 這意味着你需要有一些方法來真實地測量你的性能。在復雜的服務器應用程序上的一個常見問題是,你很難在你的測試環境中模擬你在生產中實際看到的情況。
◼️ 測量並不僅僅意味着 "我可以測量我的應用程序每秒可以處理多少個請求,因為這是我所關心的",它意味着你也應該有一些東西,當你的測量結果告訴你某些東西沒有達到理想的水平時,你可以做有意義的性能分析。能夠發現問題是一方面。如果你沒有任何東西可以幫助你找出這些問題的原因,那就沒有什么幫助了。當然,這需要你知道如何收集數據,我們將在下面談及。
◼️ 能夠衡量來自修復/解決方法的效果。
足夠的測量,讓你知道應該把精力集中在哪個領域
我一次又一次地聽到人們會測量某一個點,並選擇只優化這個點,因為他們從朋友或同事那里聽說了這個點。這就是了解基本原理真正有幫助的地方,這樣你就不會一直關注你聽說過的這個點,而這個點可能是也可能不是正確的。
測量那些可能影響你的性能指標的因素
在您知道哪些因素可能對您關心的事情(即您的性能指標)影響最大之后,你應該測量它們的影響,這樣你就可以觀察它們在你開發產品的過程中貢獻是大還是小。一個完美的例子是,服務器應用程序如何改善其P95請求延遲(即第95百分位的請求延遲)。這是一個幾乎每個網絡服務器都會看的性能指標。當然,很多因素都可以影響這個延遲,但你知道那些可能影響最大的因素。
網絡IO只是另一個可能導致你的請求延遲的因素的例子。這里的方框寬度僅僅是為了說明問題。
你每天(或你記錄P95的任何時間單位)的P95延遲可能會波動,但你知道大致的數字。比方說,你的平均請求延遲是<3ms,而你的P95大約是70ms。你必須有一些方法來測量每個請求總共需要多長時間(否則你就不會知道你的延遲百分數)。你可以記錄你看到GC暫停或網絡IO的時間(兩者都可以通過事件來測量)。對於那些在P95延遲附近的請求,你可以計算出 "P95的GC影響",即
這些請求觀察到的GC暫停總時間/請求總延時
如果這是10%,你應該有其他因素沒有計算在內。
通常人們會猜測GC停頓是影響他們P95延遲的原因。當然這是有可能的,但這絕不是唯一可能的因素,也不是對你的P95影響最大的因素。這就是為什么了解影響很重要,它告訴你應該把大部分精力花在什么地方。
而影響你的P95的因素可能與影響你的P99或P99.99的因素非常不同;同樣的原則也適用於其他百分位數。
優化框架代碼與優化用戶代碼
雖然這個文檔是為每一個關心內存分析的人准備的,但根據你所工作的層次,應該有不同的考慮。
作為一個從事終端產品的人,你有很大的自由空間去優化,因為你可以預測你的產品在什么樣的環境下運行,例如,一般來說,你知道你傾向於哪種資源的飽和,CPU,內存或其他東西。你可以控制你的產品在什么樣的機器/虛擬機上運行,你使用什么樣的庫,你如何使用它們。你可以做一些估計,比如 "我們的機器上有128GB的內存,計划在我們最大的進程中拿出20GB的內存緩存"。
從事平台技術或庫工作的人無法預測他們的代碼將在什么樣的環境中運行。這意味着:1)如果希望用戶能夠在性能關鍵路徑上使用代碼,則需要節約內存使用;2)你可能要提供不同的API,在性能和可用性之間做出權衡,並指導你的用戶如何做。
內存基礎知識
正如我在上面提到的,讓一個人對整個技術棧有透徹的了解是完全不現實的。本節列出了任何需要從事內存性能分析工作的人都必須知道的基本知識。
虛擬內存基礎知識
我們通過VMM(虛擬內存管理器)使用內存,它為每個進程提供了自己的虛擬地址空間,盡管同一台機器上的所有進程都共享物理內存(如果你有頁面文件的話)。如果你在一個虛擬機中運行,虛擬機就有一種在真實機器上運行的錯覺。對於應用程序來說,實際上,你很少會直接使用虛擬內存工作。如果你寫的是本地代碼,通常你會通過一些本地分配器來使用虛擬地址空間,比如CRT堆,或者C++的new/delete關鍵字 - 這些分配器會代表你分配和釋放虛擬內存;如果你寫的是托管代碼,GC是代表你分配/釋放虛擬內存的人。
每個VA(虛擬地址)范圍(指虛擬地址的連續范圍)可以處於不同的狀態 - 空閑 (free)、已保留(reserved) 和已提交(committed)。"空閑"很容易理解,就是空閑的內存。"已保留"和"已提交"之間的區別有時讓人困惑。"已保留"是說 "我想讓這個區域的內存供我自己使用"。當你"保留"了一個虛擬地址的范圍后,這個范圍就不能用來滿足其他的"保留"請求。在這一點上,你還不能在這個地址范圍內存儲你的任何數據 - 你必須"提交"它才可以,這意味着系統將不得不用一些物理存儲來支持它,以便你可以在其中存儲東西。當你通過性能工具查看內存時,要確保你看的是正確的東西。如果你的預留空間用完了,或者"提交"空間用完了,你就會出現內存不足的情況(在本文檔中,我主要關注Windows VMM--在Linux中,當你實際接觸到內存時,你會出現OOM(Out Of Memory))。
虛擬內存可以是私有或共享的。私有意味着它只被當前進程使用,而共享意味着它可以被其他進程共享。所有與GC相關的內存使用都是私有的。
虛擬地址空間可能被分割--換句話說,地址空間中可能有 "缺口"(空閑塊)。當你請求保留一大塊虛擬內存時,虛擬機管理器需要在虛擬地址范圍內找到一個足夠大的空閑塊來滿足該請求--如果你只有幾個空閑塊,其總和足夠大,那就無法工作。這意味着即使你有2GB,你也不一定能看到所有的2GB被使用。當大多數應用程序作為32位進程運行時,這是一個嚴重的問題。今天,我們在64位有一個充足的虛擬地址范圍,所以物理內存是主要的關注點。當你提交內存時,VMM確保你有足夠的物理存儲空間,如果你真的想使用該內存。當你實際寫入數據時,VMM將在物理內存中找到一個頁面(4KB)來存儲這些數據。這個頁面現在是你進程工作集的一部分。當你啟動你的進程時,這是一個非常正常的操作。
當機器上的進程使用的內存總量超過機器所擁有的內存時,一些頁面將需要被寫入頁面文件(如果有的話,大多數情況下是這樣的)。這是一個非常緩慢的操作,所以通常的做法是盡量避免進入分頁。我在簡化這個問題--實際的細節與這個討論沒有關系。當進程處於穩定狀態時,通常你希望看到你正在使用的頁面被保留在你的工作集中,這樣我們就不需要支付任何成本來把它們帶回來。在下一節中,我們將討論GC是如何避免分頁的。
我故意把這一節寫得很短,因為GC才是需要代表你與虛擬內存互動的人,但了解一點基本情況有助於解釋性能工具的結果。
GC基礎
垃圾收集器提供了內存安全的巨大好處,使開發人員不必手動釋放內存,並節省了可能是幾個月或幾年的調試堆損壞的時間。如果你不得不調試堆損壞,你就會知道這有多難。但是它也給內存性能分析帶來了挑戰,因為GC不會在每個對象死亡后運行(這將是令人難以置信的低效),而且GC越復雜,如果你需要做內存分析,你就必須考慮得越多(你可能會,也可能不會,我們將在下一節討論這個問題)。本節是為了建立一些基本概念,幫助你對.NET GC有足夠的了解,以便在面對內存調查時知道什么是正確的方法。
了解GC堆內存的使用與進程/機器內存的使用情況
-
GC堆只是你進程中的一種內存使用情況
在每個進程中,每個使用內存的組件都是相互共存的。在任何一個.NET進程中,總有一些非GC堆的內存使用,例如,在你的進程中總是有一些模塊被加載,需要消耗內存。但可以說,對於大多數的.NET應用程序來說,這意味着GC堆占用大部分的內存。
如果一個進程的私有提交字節總數(如上所述,GC堆總是在私有內存中)與你的GC堆的提交字節數相當接近,你就知道大部分是由於GC堆本身造成的,所以這就是你應該關注的地方。如果你觀察到一個明顯的差異,這時你應該開始擔心查看進程中的其他內存使用情況。
-
GC是按進程進行的,但它知道機器上的物理內存負載
GC是一個以進程為維度的組件(自從CLR誕生以來一直如此)。大多數GC的啟發式方法都是基於每個進程的測量,但GC也知道機器上的全局物理內存負載。我們這樣做是因為我們想避免陷入分頁的情況。GC將一定的內存負載百分比識別為 "高內存負載情況"。當內存負載百分比超過這個百分比時,GC就會進入一個更積極的模式,也就是說,如果它認為有成效的話,它會選擇做更多的完全阻塞的GC,因為它想減少堆的大小。
目前,在較小的機器上(即內存小於80GiB),默認情況下GC將90%視為高內存負荷。在有更多內存的機器上,這是在90%到97%之間。這個閾值可以通過COMPlus_GCHighMemPercent環境變量(或者從.NET 5開始在runtimeconfig.json中配置System.GC.HighMemoryPercent)來調整。你想調整這個的主要原因是為了控制堆的大小。例如,在一台有64GB內存的機器上,對於主要的主導進程,當有10%的內存可用時,GC開始反應是合理的。但是對於較小的進程(例如,如果一個進程只消耗1GB的內存),GC可以在<10%的可用內存下舒適地運行,所以你可能想對這些進程設置得更高。另一方面,如果你想讓較大的進程擁有較小的堆大小(即使機器上有大量可用的物理內存),把這個值調低將是一個有效的方法,讓GC更快地做出反應,壓縮堆的大小。
對於在容器中運行的進程,GC會根據容器的限制來考慮物理內存。
本節描述了如何找出每個GC觀察到的內存負載。
了解GC是如何被觸發的
到目前為止,我們用GC來指代組件。下面我將用GC來指代組件,或者指代一個或多個在堆上進行內存回收的集合行為,即GC或GCs。
-
觸發GC的主要原因是分配
由於GC是用來管理內存分配的,自然觸發GC的最主要因素是由於分配。隨着進程的運行和分配的發生,GC將不斷被觸發。我們有一個 "分配預算 "的概念,它是決定何時觸發GC的主導因素。我們將在下面非常詳細地討論分配預算
-
觸發GC的其他因素
GC也可以由於機器運行到高物理內存壓力而被觸發,或者如果用戶通過調用
GC.Collect
而自己誘發GC。
了解分配內存的成本
由於大多數GC是由於分配而觸發的,所以值得了解分配的成本。首先,當分配沒有觸發GC時,它是否有成本?答案是絕對的。有一些代碼需要運行來提供分配--只要你必須運行代碼來做一些事情,就會有成本。這只是一個多少的問題。
分配中開銷最大的部分(沒有觸發GC)是內存清除。GC有一個契約,即它所有分配的內存會用零填充。我們這樣做是為了安全、保障和可靠性的原因。
我們經常聽到人們談論測量GC成本,但卻不怎么談論測量分配成本。一個明顯的原因是由於GC干擾了你的線程。還有一種情況是,監測GC發生的時間是非常輕量的 - 我們提供了輕量級的工具,可以告訴你這個。但是分配一直在發生,而且很難在每次分配發生時都進行監控 - 會占用很多性能資源,很可能使你的進程不再以有意義的狀態運行。我們可以通過以下適當的方式來測量分配成本,在工具部分,我們將看到如何用各種工具技術來做這些事情--
監控內存分配的3種方法
1)我們還可以測量GC的發生頻率,這告訴我們發生了多少分配。畢竟,大多數GC是由於分配而被觸發的。
2)對非常頻繁發生的事情進行分析的方法之一是抽樣。
3)當你有了CPU使用信息,你可以在GC方法名稱查看內存清除的成本。實際上,通過GC方法名稱來查找東西顯然是非常內部且專業的,並受制於實現的變化。但由於本文檔的目標是大眾,包括有經驗的性能工程師,我將提到幾個具體的方法(其名稱往往不會有太大的變化),作為進行性能測量的一種方式。
如何正確看待GC堆的大小
這聽起來是一個簡單的問題。通過測量,對嗎?是的,但是當你測量GC堆的時候,就很重要了。
-
看一下GC堆的大小與GC發生的時間關系
這到底是什么意思?假設我們不考慮GC發生的時間,只是每秒鍾測量一次堆的大小。請看下面這個(編造的)例子
表格 1
秒 動作 這一秒過后的堆大小 1 分配 1 GB 1 GB 2 分配 2 GB 3 GB 3 分配 0 GB 3 GB 4 GC發生(500M存活),然后分配1GB 1.5 GB 5 分配 3 GB 4.5 GB 我們可以說,是的,有一個GC發生在第4秒,因為堆的大小比第3秒小。但我們再看看另一種可能性-
表格2
秒 動作 這一秒過后的堆大小 1 分配 1 GB 1 GB 2 分配 2 GB 3 GB 3 GC發生(1GB存活),然后分配2GB 3 GB 4 分配 1 GB 4 GB 如果我們只有堆的大小數據,我們就不能說GC是否已經發生。
這就是為什么測量GC發生時的堆大小是很重要的。自然,GC本身提供的部分性能測量數據正是如此 - 每次GC前后的堆大小,也就是說,每次GC的開始和結束(以及其他大量的數據,我們將在本文的后面看到)。不幸的是,許多內存工具,或者我經常看到人們采取的診斷方法,都沒有考慮到這一點。他們做內存診斷的方式是 "讓我給你看看在你碰巧問起的時候堆是什么樣子的"。這通常是沒有幫助的,有時甚至是完全誤導的。這並不是說像這樣的工具完全沒有幫助 - 當問題很簡單的時候,它們可能會有幫助。如果你有一個已經持續了一段時間的非常大的內存泄漏,並且你使用了一個工具來顯示你在那個時候的堆(要么通過采取進程轉儲和使用SoS,要么通過另一個工具來轉儲堆),那找到什么東西在泄露內存就真的很容了。這是性能分析中的一個常見模式 - 問題越嚴重,就越容易找出問題。但是,當你遇到的性能問題不是這種顯而易見的情況時,這些工具就顯得不足了。
-
分配預算
看完上一段,思考分配預算的一個簡單方法是上一次GC退出時的堆大小和這次GC進入時的堆大小之間的差異。因此,分配預算是指在觸發下一次GC之前,GC允許多少分配。在表1和表2中,分配預算是一樣的 - 3GB。
然而,由於.NET GC支持釘住對象(防止GC移動被釘住的對象)以及釘住的復雜情況,分配預算往往不是2個堆大小之間的區別。然而,預算是 "在觸發下一次GC之前的分配量 "的想法仍然成立。我們將在本文檔的后面討論更多關於釘住的問題( 后面的內容.)。
當試圖提高內存性能時,我看到人們經常做的一件事(或只做一件事)是減少分配。如果你真的可以在性能關鍵路徑開始之前預先分配所有的東西,我說你更有更多的權利!但是,這有時是非常不實際的。例如,如果你使用的是庫,你並不能完全控制它們的分配(當然,你可以嘗試找到一種無分配的方式來調用API,但並不保證有這樣的方式,而且它們的實現可能會改變)。
那么,減少分配是一件好事嗎?是的,只要它確實會對你的應用程序的性能產生影響,並且不會使你的代碼中的邏輯變得非常笨拙或復雜,從而使它成為一個值得的優化。減少分配實際上會降低性能嗎?這完全取決於你是如何減少分配的。你是在消除分配還是用其他東西來代替它們?因為用其他東西代替分配可能不會減少GC所要做的工作。
-
分代GC的影響
.NET的GC是分代的,有3代,IOW,GC堆中的對象被分為3代;gen0是最年輕的一代,gen2是老一代。gen1作為一個緩沖區,通常是為了在觸發GC時仍在請求中的數據(所以我們希望在我們做gen1時,這些數據不會被你的代碼所引用)。
根據設計,分代GC不會在每次觸發GC時收集整個堆。他們嘗試做年輕一代的GC,比老一代的GC更頻繁。老一代的GC通常成本更高,因為它們收集的堆更多。
你很可能曾經聽說過 "GC暫停 "這個術語。GC暫停是指GC以STW(Stop-The-World)的方式執行其工作時。對於並發GC來說,它與用戶線程同時進行大部分的GC工作,GC暫停的時間並不長,但是GC仍然需要花費CPU周期來完成它的工作。年輕的gen GCs,即gen0和gen1 GC,被稱為短暫的GC,而老的gen GC,即gen2 GC,也被稱為full GC,因為它們收集整個堆。當genX GC發生時,它收集了genX和它所有的年輕世代。因此,gen1 GC同時收集了堆中的gen0和gen1部分。
這也使得看堆變得更加復雜,因為如果你剛從一個老一代的GC中出來,特別是一個正在整理的GC,你的堆的大小顯然比你在該GC被觸發之前要小得多;但如果你看一下年輕一代的GC,它們可能正在被整理,但堆的大小差異沒有那么大,這就是設計。
上面提到的分配預算概念實際上是每一代的,所以gen0、gen1和gen2都有自己的分配預算。用戶的分配將發生在gen0,並消耗gen0的分配預算。當分配消耗了gen0的所有預算時,GC將被觸發,gen0的幸存者將消耗gen1的分配預算。同樣地,gen1的幸存者將消耗gen2的預算。
圖1 - 經過不同代GC的對象
一個對象 "死了 "和它被清理掉之間的區別可能會讓人困惑。我收到的一個常見問題是:"我不再保留我的對象了,而且我看到GC正在發生,為什么我的對象還在那里?"。請注意,一個對象不再被用戶代碼持有的事實(在本文中,用戶代碼包括框架/庫代碼,即不是GC代碼)需要被GC掃描到。要記住的一個重要規則是:"如果一個對象在genX中,這意味着它只可能在genX GC發生時被回收",因為這時GC會真正去檢查genX中的對象是否還活着。如果一個對象在gen2中,不管發生了多少次短暫的GC(即0代和1代GC),這個對象仍然會在那里,因為GC根本沒有收集gen2。另一種思考方式是,一個對象所處的代數越高,GC需要收集的工作就越多。
-
大對象堆
現在是談論大對象的好時機,也就是LOH(大對象堆)。到目前為止,我們已經提到了gen0、gen1和gen2,以及用戶代碼總是在gen0中分配對象。實際上,如果對象太大,這並不正確 - 它們會被分配到堆的另一個部分,即LOH。而gen0、gen1和gen2組成了SOH(小對象堆)。
在某種程度上,你可以認為LOH是一種阻止用戶不小心分配大對象的方式,因為大對象比小對象更容易引入性能挑戰。例如,當運行時默認發放一個對象時,它保證內存被清空。內存清空是一個昂貴的操作,如果我們需要清空更多的內存,它的成本會更高。也更難找到空間來容納一個更大的對象。
LOH在內部是作為gen3被跟蹤的,但在邏輯上它是gen2的一部分,這意味着LOH只在gen2的GC中被收集。這意味着,如果你代碼經常會使用LOH,你就會經常觸發gen2的GC,如果你的gen2也很大,這意味着GC將不得不做大量的工作來執行gen2的GC。
和其他gen一樣,LOH也有它的分配預算,當它用完時,與gen0不同,gen2 GC將被觸發,因為LOH只在gen2 GC期間被清理。
默認情況下,一個對象進入LOH的閾值是>=85000字節。這可以通過使用GCLOHThreshold配置來調整更高。LOH也默認不壓縮,除非它在有內存限制的容器中運行(容器行為在.NET Core 3.0中引入)。
-
碎片化(自由對象)是堆大小的一部分
另一個常見問題是 "我看到gen2有很多自由空間,為什么GC沒有使用這些空間?"。
答案是,GC正在使用這個空間。我們必須再次回到何時測量堆的大小,但現在我們需要增加另一個維度 - 整理GC vs 清掃GC。
.NET GC可以執行整理或清掃GC。整理是開銷更大的操作,因為GC會移動對象(會發生內存復制),這意味着它必須更新堆上這些對象的所有引用,但整理可以大大減少堆的大小。清掃GC不進行壓縮,而是將相鄰的死對象凝聚成一個空閑對象,並將這些空閑對象穿到該代的空閑列表中。空閑列表占據的大小,我們稱之為碎片,也是gen的一部分,因此在我們報告gen和堆的大小時也包括在內。雖然在這種情況下,堆的大小並沒有什么變化,但重要的是要明白這個空閑列表是用來容納年輕一代的幸存者的,所以我們要使用空閑空間。
這里我們將介紹GC的另一個概念 - 並發的GC與阻塞的GC。
並發GC/后台GC
我們知道,如果我們以停止托管線程的方式進行GC,可能需要很長的時間,也就是我們所說的完全阻塞式GC。我們不想讓用戶線程暫停那么久,所以大多數時候,一個完整的GC是並發進行的,這意味着GC線程與用戶線程同時運行,在GC的大部分時間里(一個並發的GC仍然需要暫停用戶線程,但只是短暫的暫停)。目前.NET中的並發GC風格被稱為后台GC,或簡稱BGC。BGC只進行清掃。也就是說,BGC的工作是建立一個第二代自由列表來容納第一代的幸存者。短暫的GC總是作為阻塞的GC來做,因為它們足夠短。
現在我們再來思考一下 "何時測量 "的問題。當我們做一個BGC時,在該GC結束時,一個新的自由列表被建立起來。隨着第一代GC的運行,他們將使用這個自由列表的一部分來容納他們的幸存者,所以列表的大小將變得越來越小。因此,當你說 "我看到gen2有很多空閑空間 "時,如果那是在BGC剛剛發生的時候,或者剛剛發生不久的時候,那是正常的。如果到了我們做下一次BGC的時候,gen2中總是有很多空閑空間,這意味着我們做了那么多工作來建立一個空閑列表,但它並沒有被使用多少,這就是一個真正的性能問題。我已經在一些場景中看到了這種情況,我們正在建立一個解決方案,使我們能夠進行最佳的BGC觸發。
Pinning 再次增加了碎片的復雜性,我們將在釘住章節中談及。
-
GC堆的物理表示
我們一直在討論如何正確地測量GC堆的大小,但是GC堆在內存中到底是什么樣子的,也就是說,GC堆是如何物理組織的?
GC像其他Win32程序一樣通過
VirtualAlloc
和VirtualFree
API來獲取和釋放虛擬內存(在Linux上通過mmap
/munmap
完成)。GC對虛擬內存進行的操作有以下幾點當GC堆被初始化時,它為SOH保留了一個初始段,為LOH保留了另一個初始段,並且只在每個段的開頭提交幾個頁面來存儲一些初始信息。
當分配發生在這個段上時,內存會根據需要被提交。對於SOH來說,由於只有一個段,gen0、gen1和gen2此時都在這個段上。要記住的一個不變因素是,兩個短暫的gen,即gen0和gen1,總是生活在同一個段上,這個段被稱為短暫段,這意味着合並的短暫gen永遠不會比一個段大。如果SOH的增長超過了一個段的容量,在GC期間將獲得一個新的段。gen0和gen1所在的段是新的短暫段,另一個段現在變成了gen2段。這是在GC期間完成的。LOH是不同的,因為用戶的分配會進入LOH,新的段是在分配時間內獲得的。因此,GC堆可能看起來像這樣(在段的末尾可能有未使用的空間,用白色空間表示):
圖. 2 - GC堆的段
隨着GC的發生和內存回收,當段上沒有發現活對象時,段就會被釋放;段空間的末端(即段上最后一個活對象的末端,直到段的末端)被取消提交,除了短暫的段。
對短暫段的特殊處理
對於短暫段,我們保留GC后提交的最后一個實時對象之后的空間,因為我們知道gen0分配將立即使用這個空間。因為我們要分配的內存量是gen0的預算,所以提交的空間量就是gen0的預算。這回答了另一個常見問題 - "為什么GC提交的內存比堆的大小多?"。這是因為提交的字節包括gen0預算部分,而如果你碰巧在GC發生后不久看一下堆,它還沒有消耗大部分的空間。特別是當你有服務器GC時,它可能有相當大的gen0預算;這意味着這個差異可能很大,例如,如果有32個堆,每個堆有50MB的gen0預算,你在GC后馬上看堆的大小,你看到的大小會比提交的字節少(32 * 50 = 1.6 GB)。
請注意,在.NET 5中,取消提交的行為發生了變化,我們可以留下更多的內存,因為我們想把gen1也納入GC的考慮。另外,服務器GC的取消提交現在是在GC暫停之外完成的,所以GC結束時報告的部分內容可能會被取消提交。這是一個實現細節--使用gen0的預算通常仍然是一個非常好的近似值,可以確定投入的部分是多少。
按照上面的例子,在gen2 GC之后,堆可能看起來是這樣的(注意這只是一個例子說明)。
圖3 - gen2 GC后的GC堆段
在gen0的GC之后,由於它只能收集gen0的空間,我們可能會看到這個:
圖4 - gen0 GC后的GC堆段
大多數時候,你不必關心GC堆被組織成段的事實,除了在32位上,因為虛擬地址空間很小(總共2-4GB),而且可能是碎片化的,甚至當你要求分配一個小對象時,你可能得到一個OOM,因為我們需要保留一個新的段。在64位平台上,也就是我們大多數客戶現在使用的平台上,有大量的虛擬地址空間,所以預留空間不是一個問題。而且在64位平台上,段的大小要大得多。
-
GC自己的記賬
很明顯,GC也需要做自己的記賬工作,這就需要消耗內存 - 這大約是GC堆大小的1%。最大的消耗是由於啟用了並行GC,這是默認的。准確地說,並發的GC記賬與堆的儲備大小成正比,但其余的記賬實際上與堆的范圍成正比。由於這是1%,你需要關心它的可能性極低。
-
什么時候GC會拋出一個OOM異常?
幾乎所有人都聽說過或遇到過OOM異常。GC究竟什么時候會拋出一個OOM異常呢?在拋出OOM之前,GC確實非常努力。因為GC大多做短暫的GC,這意味着堆的大小往往不是最小的,這是設計上的。然而,GC通常會嘗試一個完全阻塞的GC,並在拋出OOM之前驗證它是否仍然不能滿足分配請求。但也有一個例外,那就是GC有一個調整啟發式,說它不會繼續嘗試完全阻塞的GC,如果它們不能有效地縮小堆的大小。它將嘗試一些gen1 GCs和完全阻塞的GCs混合在一起。所以你可能會看到一個OOM拋出,但拋出它的GC並不是一個完全阻塞的GC。
了解GC暫停,即何時觸發GC以及GC持續多長時間
當人們研究 GC 暫停問題時,我總是問他們是否關心總暫停和/或單個暫停。總暫停是由 "GC中的%暫停時間 "來表示的,每次GC被觸發,暫停都會被加到總暫停中。通常情況下,你關心這個是出於吞吐量的原因,因為你不希望GC過多地暫停你的代碼,以至於把吞吐量降低到可接受的程度。單個暫停表示單個GC持續的時間。除了作為總暫停的一部分,你關心單個暫停的一個原因通常是為了請求的尾部延遲--你想減少長的GC以消除或減少它們對尾部延遲的影響。
-
單個GC的持續時間
.NET的GC是一個引用追蹤式GC,這意味着GC需要通過各種根(例如,堆棧定位,GC處理表)去追蹤,以找出哪些對象應該是活的。因此,GC的工作量與有多少對象在內存中存活成正比。一個GC持續的時間與GC的工作量大致成正比。我們將在本文檔的后面更多地討論根的問題。
對於阻塞式GC來說,由於它們在整個GC期間暫停用戶線程,所以GC持續的時間與GC暫停的時間相同。對於BGC,它們可以持續相當長的時間,但暫停時間要小得多,因為GC主要是以並發的方式工作。
注意,我說過GC的持續時間與GC的工作量大致成正比。為什么是大致?GC需要像其他東西一樣分享機器上的核心。對於阻塞式GC,當我們說 "GC暫停用戶線程 "時,我們實際上是指 "執行托管代碼的線程"。執行本地代碼的線程可以自由運行(盡管需要等待GC結束,如果它們需要在GC仍在進行的時候返回到托管代碼)。最后,不要忘了,在線程運行時,其他進程由於GC的原因暫停了你的進程。
這就是我們引入的另一個概念,即GC的不同主要類型--工作站GC vs 服務器GC(簡稱WKS GC vs SVR GC)。
服務器GC
顧名思義,它們分別用於工作站(即客戶端)和服務器的工作負載。工作站工作負載意味着你與許多其他進程共享機器,而服務器工作負載通常意味着它是機器上的主導進程,並傾向於有許多用戶線程在這個進程中工作。這兩種GC的主要區別在於,WKS GC只有一個堆,SVR GC有多少個堆取決於機器上有多少邏輯核心,也就有和邏輯核心相同數量的GC線程進行GC工作。到目前為止,我們介紹的所有概念都適用於每個堆,例如,分配預算現在是每代每堆,所以每個堆都有自己的gen0預算。當任何一個堆的gen0分配預算用完后,就會觸發GC。上圖中的GC堆段將在每個堆上重復出現(盡管它們可能包含不同數量的內存)。
由於2種工作負載的性質不同,SVR GC有2個明顯不同的屬性,而WKS GC則沒有。
-
SVR GC線程的優先級被設置為 "THREAD_PRIORITY_HIGHEST",這意味着如果其他線程的優先級較低,它就會搶占這些線程,而大多數線程都是如此。相比之下,WKS GC在觸發GC的用戶線程上運行GC工作,所以它的優先級是該線程運行的任何優先級,通常是正常的優先級。
-
SVR GC線程與邏輯核心硬性綁定。
參見MSDN文檔中關於SVR GC的圖解。既然我們現在談到了服務器和並發/后台GC,你可能會問服務器GC也有並發的嗎?答案是肯定的。我再次向你推薦MSDN doc,因為它對Background WKS GC與Background SVR GC有一個明確的說明。
我們這樣做的原因是,當SVR GC發生時,我們希望它能夠盡可能快地完成它的工作。雖然這在大多數情況下確實達到了這個目標,但是它可能會帶來一個你應該注意的復雜情況 - 如果在SVR GC發生的同時,有其他線程也以
THREAD_PRIORITY_HIGHEST
或更高的速度運行,它們會導致SVR GC花費更長的時間,因為每個GC線程只在其各自的核心上運行(我們將在后面的章節)看到如何診斷長GC的問題。而這種情況通常非常罕見,但是有一個注意事項,那就是當你在同一台機器上有多個使用SVR GC的進程時。在運行時的早期,這種情況很少見,但是隨着這種情況越來越少,我們創建了一些配置,允許你為使用SVR GC的進程指定更少的GC堆/線程。這些配置的解釋是這里。我見過一些人故意把一個大的服務器進程分成多個小的進程,這樣每個進程都會有一個較小的堆,通過使用堆數較少的服務器GC。他們用這種方式取得了更好的效果(更小的堆意味着更短的暫停時間,如果它確實需要做完全阻塞的GC的話)。這是一個有效的方法,但當然只能在有意義的情況下使用它 - 對於某些應用來說,將一個進程分成多個進程是非常尷尬的。
-
-
多長時間觸發一次GC?
如前所述,當gen0的分配預算用完時,就會觸發GC。當一個GC被觸發時,發生的第一步是我們決定這個GC將是哪一代。在工具那一章節,我們將看到哪些原因會導致GC從gen0升級到可能的gen1或gen2,但其中的一個主要因素是gen1和gen2的分配預算。如果我們檢測到gen2的分配預算已經用完,我們就會把這個GC升級到完全的GC。
因此,"多長時間觸發一次GC "的答案是由gen0/LOH預算耗盡的頻率決定的,而gen1或gen2的GC被觸發的頻率主要由gen1和gen2的預算耗盡的頻率決定。你自然會問 "那么預算是如何計算的?"。預算主要是根據我們看到的那一代的存活率來計算的。存活率越高,預算就越大。如果GC收集了一代對象並發現大多數對象都存活了,那么這么快再收集它就沒有意義了,因為GC的目標是回收內存。如果GC做了所有這些工作,而能回收的內存卻很少,那么它的效率就會非常低。
這如何轉化為觸發GC的頻率是,如果一個代被頻繁地使用(即,它的存活率很低),它將被更頻繁地收集。這就解釋了為什么我們最頻繁地收集gen0,因為gen0是用於非常臨時的對象,其存活率非常低。根據代際假說,對象要么活得很久,要么很臨時,gen2持有長壽的對象,所以它們被收集的次數最少。
如前所述,在高內存負載情況下,我們會更積極地觸發gen2阻塞式GC。當內存負載很高的時候,我們傾向於做完全阻塞的GC,這樣我們就可以進行整理。雖然BGC對暫停時間有好處,但它對縮小堆沒有好處,而當GC認為它的內存不足時,縮小堆就更重要了。
當內存負載不高時,我們做完全阻塞的GC的另一個原因是當gen2碎片非常高時,GC認為大幅減少堆的大小是有成效的。如果這對你來說是不必要的(即你有足夠的可用內存),而且你寧願避免長時間的停頓,你可以將延遲模式設置為SustainedLowLatency,告訴GC只在必須的時候做全阻塞的GC。
-
要記住的一條規則
那是很多材料,但如果我們把它總結為一條規則,這就是我在談論GC被觸發的頻率和單個GC持續的時間時總是告訴人們的事情。
存活的對象數量通常決定了GC需要做多少工作;不存活的對象數量通常決定了GC被觸發的頻率
下面是一些極端的例子,當我們應用這一規則時-
情況1 - gen0根本沒有任何存活對象。這意味着gen0的GC被頻繁地觸發。但是單次gen0的暫停時間非常短,因為基本上沒有工作要做。
情況2 - 大部分gen2對象都存活。這意味着gen2的GC被觸發的頻率很低。對於單個gen2的暫停,如果GC作為阻塞GC進行,那暫停時間會非常長;如果作為BGC進行,會持續很長時間(但暫停時間仍然很短)。
你不能處於分配率和生存率都很高的情況下 - 你會很快耗盡內存。
-
是什么使一個對象得以存活
從GC的角度來看,它被各種運行時組件告知哪些對象應該存活。它並不關心這些對象是什么類型;它只關心有多少內存可以存活,以及這些對象是否有引用,因為它需要通過這些引用來追蹤那些也應該存活的子對象。我們一直在對GC本身進行改進,以改善GC暫停,但作為一個寫托管代碼的人,知道是什么讓對象存活下來是一個重要的方法,你可以通過它來改善你這邊的個別GC暫停。
1. 分代方面
我們已經談到了分代GC的效果,所以第一條規則是
當一個代沒有被回收,這意味着該代的所有對象都是活的。
因此,如果我們正在收集gen2,代數方面是不相關的,因為所有的代數都會被收集。我收到的一個常見問題是:"我已經多次調用GC.Collect()了,對象還在那里,為什么GC不把它處理掉呢?"。這是因為當你誘導一個完全阻塞的GC時,GC並不參與決定哪些對象應該是活的 - 它只會由我們將在下面討論的用戶根(堆棧/GC句柄/等等)告知是否存活,我們將在下面談論。因此,這意味着無論什么東西還活着,都是因為它需要活着,而GC根本無法回收它。
不幸的是,很少有性能工具會強調生成效應,盡管這是.NET GC的一個基石。許多性能工具會給你一個堆轉儲--有些會告訴你哪些堆棧變量或哪些GC句柄持有對象。你可以擺脫很大比例的GC句柄,但你的GC暫停時間幾乎沒有改善。為什么呢?如果你的大部分GC暫停是由於gen0的GC被gen2中的一些對象持有而造成的,那么如果你設法擺脫一些gen2的對象,而這些對象並不持有這些gen0的對象,那也是沒有用的。是的,這將減少gen2的工作,但是如果gen2的GC發生的頻率很低,那就不會有太大的區別,如果你的目標是減少gen2的GC的數量,你就不會有什么進展。
2. 用戶根
你最有可能聽到的常見類型的根是指向對象的堆棧變量、GC句柄和終結器隊列。我把這些稱為用戶根,因為它們來自用戶代碼。由於這些是用戶代碼可以直接影響的東西,所以我將詳細地討論它們。
-
堆棧變量
堆棧變量,特別是對於C#程序來說,實際上並沒有被談及很多。原因是JIT也能很好地意識到堆棧變量何時不再被使用。當一個方法完成后,堆棧根保證會消失。但即使在這之前,JIT也能知道什么時候不再需要一個堆棧變量,所以不會向GC報告,即使GC發生在一個方法的中間。請注意,在DEBUG構建中不是這種情況。
-
GC句柄
GC句柄是一種方式,用戶代碼可以持有一個對象,或者檢查一個對象而不持有它。前者被稱為強柄,后者被稱為弱柄。強句柄需要被釋放,以使它不再保留一個對象,也就是說,你需要在句柄上調用Free。有一些人給我看了!gcroot(SoS調試器的一個擴展命令,可以顯示一個對象的根部)的輸出,說有一個強句柄指向一個對象,問我為什么GC還沒有回收這個對象。根據設計,這個句柄告訴GC這個對象需要是活的,所以GC不能回收它。目前,以下用戶暴露的句柄類型是強句柄。Strong和Pinned;而弱柄是Weak和WeakTrackResurrection。但是如果你看過SoS的 !gchandles輸出,Pinned句柄也可以包括AsyncPinned。
釘住
我在上面提到過幾次釘住。大多數人都知道釘住是什么 - 它向GC表示一個對象不能被移動。但從GC的角度來看,釘住的意義是什么呢?由於GC不能移動這些被釘住的對象,它使被釘住的對象之前的死角變成了一個自由對象,這個自由對象可以用來容納年輕一代的生存者。但這里有一個問題 - 正如我們從上面的代際討論中看到的,如果我們簡單地將這些被釘住的對象提升到老一代,就意味着這些自由空間也是老一代的一部分,要用它們來容納年輕一代的幸存者,唯一的辦法就是我們真的對年輕一代做一次GC(否則我們甚至沒有 "年輕一代的幸存者")。然而,如果我們能在gen0中保留這些自由空間,它們就可以被用戶分配使用。這就是為什么GC有一個叫做降代的功能,我們將把這些被釘住的對象降代到gen0,這意味着它們之間的空閑空間將是gen0的一部分,當用戶代碼分配時,我們可以立即使用它們。
圖5 - 降代(我從一張舊的幻燈片上取下來的,所以這看起來與之前的片段圖片有些不同。)
由於gen0分配可以發生在這些自由空間中,這意味着它們將消耗gen0預算而不增加gen0的大小(除非自由空間不能滿足所有的gen0預算,在這種情況下它將需要增長gen0)。
然而,GC 不會無條件地降代,因為我們不想在 gen0 中留下許多固定對象,這意味着我們必須在每次 GC 中再次查看它們,可能會有很多次 GC(因為它們是 gen0 的一部分,每當我們執行 gen0 GC 我們需要查看它們)。 這意味着如果您遇到嚴重的固定情況,它仍然會導致 gen2 中的碎片問題。 同樣,GC 確實有機制來應對這些情況。 但是如果你想對 GC 施加更少的壓力,你可以從用戶的 POV 中遵循這個規則—
早點釘住對象,分批釘住對象
我們的想法是,如果你把對象釘在已經整理的那部分堆里,意味着這些對象已經不需要移動了,所以碎片化就不是問題。如果你以后確實需要釘住,通常的做法是分配一批緩沖區,然后把它們釘在一起,而不是每次都分配一個並釘住它。在.NET 5中,我們引入了一個名為POH(Pinned Object Heap(固定堆))的新特性,允許你告訴GC在分配時將釘住的對象放在一個特定的堆上。因此,如果你有這樣的控制權,在POH上分配它們將有助於緩解碎片化問題,因為它們不再散落在普通堆上。
-
終結器
終結隊列是另一個根來源。如果你已經寫了一段時間的.NET應用程序,你有可能聽說過終結器是你需要避免的東西。然而,有時終結器並不是你的代碼,而是來自你所使用的庫。由於這是一個非常面向用戶的特性,我們來詳細了解一下。下面是終結器的基本性能含義 -
分配
· 如果你分配了一個可終結的對象(意味着它的類型有一個終結器),就在GC返回到VM端的分配助手之前,它將把這個對象的地址記錄在終結隊列中。
· 有一個終結者意味着你不能再使用快速分配器進行分配,因為每個可終結的對象的分配都要到GC去注冊。
然而,這種成本通常是不明顯的,因為你不太可能分配大部分可終結的對象。更重要的成本通常來自於GC實際發生的時間,以及在GC期間如何處理可終結的對象。
回收
當GC發生時,它將發現那些仍然活着的對象,並對它們升代。然后它將檢查終結隊列中的對象,看它們是否被升代 - 如果一個對象沒有被升代,就意味着它已經死了,盡管它不能被回收(見下一段的原因)。如果你在被收集的幾代中有成噸的可終結的對象,僅這一成本就可能是明顯的。比方說,你有一大堆被提升到gen2的可終結對象(只是因為它們一直在存活),而你正在做大量的gen2 GC,在每個gen2 GC中,我們需要花時間來掃描所有的可終結對象。如果你很不頻繁地做gen2 GC,你就不需要支付這個成本。
這里就是你聽到 "終結器不好 "的原因了 - 為了運行GC已經發現的這個對象的終結器,這個對象需要是存活的。由於我們的GC是一代一代的,這意味着它將被提升到更高的一代,正如我們上面所談到的,這反過來意味着它將需要一個更高的一代GC,也就是說,一個更昂貴的GC來收集這個對象。因此,如果一個可終結的對象在第一代GC中被發現死亡,它將需要等到下一次做第二代GC時才會被收集,而這可能是相當長的一段時間。也就是說,這個對象的內存的回收可能會被推遲很多。
然而,如果你用GC.SuppressFinalize來抑制終結器,你告訴GC的是你不需要運行這個對象的終結器。所以GC就沒有理由去提升(升代)它。當GC發現它死亡時,它將被回收。
運行終結器
這是由終結器線程處理的。在GC發現死的、可終結的對象(然后被升代)后,它將其移至終結隊列的一部分,告訴終結者線程何時向GC請求運行終結者,並向終結者線程發出信號,表示有工作要做。在GC完成后,終結器線程將運行這些終結器。被移到終結隊列這一部分的對象被說成是 "准備好終結了"。你可能已經看到各種工具提到了這個術語,例如,sos的 !finalizequeue命令告訴你finalize隊列的哪一部分儲存了准備好的對象,像這樣:
Ready for finalization 0 objects (000002E092FD9920->000002E092FD9920)
您經常會看到這是 0,因為終結器線程以高優先級運行,因此終結器將快速運行(除非它們被某些東西阻塞)。
下圖說明了2個對象以及可最終確定的對象F是如何演變的。正如你所看到的,在它被提升到gen1之后,如果有一個gen0的GC,F仍然是活的,因為gen1沒有被收集;只有當我們做一個gen1的GC時,F才能真正成為死的,我們看一下F所處的代。
圖 6 - O 是不可終結的,F 是可終結的
3. 托管內存泄漏
現在我們了解了不同類別的根,我們可以談談管理性內存泄漏的定義了
托管內存泄漏意味着你至少有一個用戶根,隨着進程的運行,直接或間接地引用了越來越多的對象。這是一個泄漏,因為根據定義,GC不能回收這些對象的內存,所以即使GC盡了最大努力(即做一個全堆阻塞的GC),堆的大小最終還是會增長。
所以最簡單的方法,如果可行的話,識別你是否有托管內存泄漏,就是在你知道你應該有相同的內存使用量的時候,簡單地誘導全阻塞GC(例如,在每個請求結束時),並驗證堆的大小沒有增長。顯然,這只是一種幫助調查內存泄漏的方法--當你在生產中運行你的應用程序時,你通常不希望誘發全阻塞的GCs。
-
“主線GC場景” vs “非主線”
如果你有一個程序只是使用堆棧並創建一些對象來使用,GC已經優化了很多年了。基本上是 "掃描堆棧以獲得根部,並從那里處理對象"。這就是許多GC論文所假設的主線GC方案,也是唯一的方案。當然,作為一個已經存在了幾十年的商業產品,並且必須滿足各種客戶的要求,我們還有一堆其他的東西,比如GC句柄和終結器。需要了解的是,雖然多年來我們也對這些東西進行了優化,但我們的操作是基於 "這些東西不多 "的假設,這顯然不是對每個人都是如此。因此,如果你確實有很多這樣的東西,那么如果你在診斷內存問題時,就值得關注了。換句話說,如果你沒有任何內存問題,你不需要關心;但如果你有(例如,在GC時間百分比高),它們是值得懷疑的好東西。
-
**完全不做 GC 的部分 GC 暫停—線程掛起 **
我們沒有提到的GC暫停的最后一個部分是根本不做GC工作的部分--我指的是運行時中的線程暫停機制。GC調用暫停機制,讓進程中的線程在GC工作開始前停止。我們調用這個機制是為了讓線程到達它們的安全點。因為GC可能會移動對象,所以線程不能在隨機的點上停止;它們需要在運行時知道如何向GC報告對GC堆對象的引用的點上停止,這樣GC才能在必要時更新它們。這是一個常見的誤解,認為GC在做暫停工作--GC只是調用暫停機制來讓你的線程停止。然而暫停被報告為GC暫停的一部分,因為GC是使用它的主要組件。
我們談到了並發與阻塞的GC,所以我們知道阻塞的GC會讓你的線程在GC期間保持暫停狀態,而BGC(並發的味道)會讓它們在短時間內暫停,並在用戶線程運行時做大部分的GC工作。不太常見的是,讓線程進入暫停狀態可能需要一段時間。大多數情況下這是非常快的,但是緩慢的暫停是一類與管理內存相關的性能問題,我們將專門討論如何診斷這些問題。
注意,在GC的暫停部分,只有運行托管代碼的線程被暫停。運行本地代碼的線程可以自由運行。然而,如果它們需要在這樣的暫停部分返回到托管代碼,它們將需要等待,直到暫停部分結束。
知道什么時候該擔心
與任何性能調查一樣,首要的是弄清楚你是否應該擔心這個問題。
頂層應用指標
如上所述,關鍵是要有性能目標 - 這些應該由一個或多個頂級應用指標來表示。它們是應用指標,因為它們直接告訴你應用的性能方面的數據,例如,你處理的並發請求數,平均、最大和/或P95請求延遲。
使用頂級應用指標來表明你在開發產品時是否有性能退步或改進,這是相對容易理解的,所以我們不會在這里花太多時間。但有一點值得指出的是,有時要讓這些指標穩定到有一個月到一個月的趨勢,甚至一天到一天的趨勢並不容易,原因很簡單,因為工作負載並不是每天都保持不變,特別是對尾部延遲的測量。我們如何解決這個問題呢?
· 這正是衡量能影響它們的因素的重要原因之一。當然,你很可能在前期不知道所有的因素。當你知道得越多,你就可以把它們加入到你要測量的東西的范圍內。
· 有一些頂級的組件指標,幫助你決定工作負載中有多少變化。對於內存,一個簡單的指標是做了多少分配。如果在今天的高峰時段,你的分配量是昨天的兩倍,你就知道這表明今天的工作負荷也許給GC帶來了更大的壓力(分配量絕對不是影響GC暫停的唯一因素,見上面的GC暫停一節)。然而,有一個原因使得這成為一個受歡迎的追蹤對象,因為它與用戶代碼直接相關--你可以在一行代碼中看到分配何時發生,而將GC與一行代碼關聯起來則比較困難。
頂層的GC指標
既然你在閱讀本文檔,顯然你關心的組件之一就是GC。那么,你應該跟蹤哪些頂層的GC指標,以及如何決定何時應該擔心?
我們提供了許多不同的GC指標,你可以測量 - 顯然你不需要關心所有的指標。事實上,要確定你是否/何時應該開始擔心GC,你只需要一到兩個頂級的GC指標。表3列出了哪些頂級GC指標是基於你的性能目標相關的。如何收集這些指標將在[后面的章節]中描述(#如何收集頂層的GC指標)。
表格3
Application perf goal 應用性能目標 | Relevant GC metrics 相關的GC指標 |
---|---|
Throughput 吞吐量 | % Pause time in GC (maybe also % CPU time in GC) 在GC中暫停時間的百分比(也許還有GC中CPU時間的百分比) |
Tail latency 尾部延時 | Individual GC pauses 個別的GC停頓 |
Memory footprint 內存占用率 | GC heap size histogram GC堆大小直方圖 |
何時應擔心GC
如果你理解了GC基本原理,那么GC行為是由應用行為驅動的,這一點應該是非常明顯的。頂層的應用指標應該告訴你什么時候出現了性能問題。而GC指標可以幫助你對這些性能問題進行調查。例如,如果你知道你的工作負載在一天中長時間處於休眠狀態,那么你看一天中 "GC中暫停時間百分比 "指標的平均值是沒有意義的,因為 "GC中暫停時間百分比 "的平均值會非常小。看這些GC指標的一個更合理的方法是:"我們在X點左右發生了故障,讓我們看一下那段時間的GC指標,看看GC是否可能是故障的原因"。
當相關的GC指標顯示GC的影響很小的時候,把你的精力放在其他地方會更有成效。如果它們表明GC確實有很大的影響,這時你應該開始擔心如何進行內存管理分析,這就是本文檔的大部分內容。
讓我們詳細看看每個目標,以了解為什么你應該看他們相應的GC指標 -
吞吐量
為了提高你的吞吐量,你希望GC盡可能少地干擾你的線程。GC會在以下兩個方面進行干擾
· GC可以暫停你的線程 - 阻塞的GC會在整個GC期間暫停它們,BGC會暫停一小段時間。這種暫停由 "GC中的%暫停時間(% Pause time in GC)"來表示。
· GC線程會消耗CPU來完成工作,雖然BGC不會讓你的線程暫停太多,但它確實會與你的線程競爭CPU。所以還有一個指標叫做 "GC花費的CPU時間%(% CPU time in GC)"。
這兩個數字可能有很大差別。"GC中的暫停時間百分比 "的計算方法是
線程被GC暫停時的耗時/進程的總耗時
因此,如果從進程開始到現在已經10s了,線程由於GC而暫停了1s,那么GC中的暫停時間百分比就是10%。
即使BGC不在其中,GC中的CPU時間百分比也可能多於或少於GC中的暫停時間百分比,因為這取決於CPU在進程中被其他事物使用的情況。當GC正在進行時,我們希望看到它盡可能快地完成;所以我們希望看到它在執行期間有盡可能高的CPU使用率。這曾經是一個非常令人困惑的概念,但現在似乎發生得更少了。我曾經收到過一些擔心的人的報告,說 "當我看到一個服務器GC時,它使用了100%的CPU! 我需要減少這個!"。我向他們解釋說,這實際上正是我們希望看到的--當GC暫停了你的線程時,我們希望能使用所有的CPU,這樣我們就能更快地完成GC工作。假設GC的暫停時間為10%,在GC暫停期間,CPU使用率為100%(例如,如果你有8個核心,GC會完全使用所有8個核心),在GC之外,你的線程的CPU使用率為50%,並且沒有BGC發生(意味着GC只在你的線程暫停時做工作),那么GC的CPU時間將為
(100% * 10%) / (100% * 10% + 50% * 90%) = 18%
我建議首先監測GC中的%暫停時間,因為它的監測開銷很低,而且是一個很好的衡量標准,可以確定你是否應該把GC作為一個最高級別的指標來關注。監測GC中的CPU時間百分比的成本較高(需要實際收集CPU樣本),而且通常沒有那么關鍵,除非你的應用程序正在做大量的BGC,而且CPU真的飽和了。
通常情況下,一個行為良好的應用程序在GC中的暫停時間小於5%,而它正在積極處理工作負載。如果你的應用程序的暫停時間是3%,那么你把精力放在GC上就沒有什么成效了--即使你能去掉一半的暫停時間(這很困難),你也不會使總的性能提高多少。
尾部延時
之前我們討論了如何考慮測量導致你的尾部延遲的因素。如果尾部延遲是你的目標,除了其他因素外,GC或最長的GC可能發生在那些最長的請求中。因此,測量這些單獨的GC暫停是很重要的,看看它們是否/在多大程度上導致了你的延遲。有一些輕量級的方法可以知道一個單獨的GC暫停何時開始和結束,我們會看到在本文檔后面。
內存占用率
如果你還沒有正確閱讀GC堆只是你進程中的一種內存使用情況,以及如何測量GC heap size,我強烈建議你現在就去做。實際上,一個被管理的進程在GC堆之外還有明顯的甚至是大量的內存使用,這並不罕見,所以了解是否是這樣的情況很重要。如果GC堆在整個進程的內存使用中只占很小的比例,那么你專注於減少GC堆的大小就沒有意義了。
挑選正確的工具和解釋數據
性能工具一覽
我怎么強調挑選正確工具的重要性都不為過。我經常看到一些人花了很多時間(有時是幾個月)試圖弄清一個問題,因為他們沒有發現正確的工具和/或如何使用它。這並不是說即使有了正確的工具,你也不需要花費一些精力--有時只需要一分鍾,但其他時候可能需要許多分鍾或幾個小時。
挑選合適的工具的另一個挑戰是,除非你在做一個基本的分析,否則根本沒有很多工具可以選擇。也就是說,有更多的工具能夠解決簡單的問題,所以如果你正在解決其中的一個問題,你選擇哪一個並不那么重要。例如,如果你有一個嚴重的托管內存泄漏,你可以在你的開發環境中重現,你將能夠很容易地找到一個能夠比較堆快照的工具,這樣你就可以看到哪些對象存活了,而不應該。你很有可能通過這種方式來解決你的問題。你不需要關心像何時測量堆的大小這樣的事情,就像我們在"如何正確測量GC堆的大小 "部分廣泛談論的那樣。
我們使用的工具以及它是如何完成工作的
運行時團隊制作的、我經常使用的工具是PerfView - 你們中的很多人可能都聽說過它。但我還沒有看到很多人充分使用它。PerfView的核心是使用TraceEvent,這是一個解碼ETW(Event Tracing for Windows)事件的庫,它來自運行時提供者、內核提供者和其他一些提供者。如果你以前沒有接觸過ETW事件,你可以把它們看作是各種組件隨着時間的推移所發出的數據。它們具有相同的格式,所以來自不同組件的事件可以被那些知道如何解釋ETW事件的工具放在一起看。這對香水調查來說是一個非常強大的東西。在ETW術語中,事件按提供者(例如,運行時提供者)、關鍵字(例如,GC)和粗略程度(例如,通常意味着輕量級的Informational和通常更重的Verbose)進行分類。使用ETW的成本與你所收集的事件量成正比。在GC信息級別,開銷非常小,如果你需要,你可以一直收集這些事件。
由於.NET Core是跨平台的,而ETW在Linux上並不存在,我們有其他的事件機制,旨在使這個過程透明,如LTTng或EventPipe。因此,如果你使用TraceEvent庫,你可以在Linux上獲得這些事件的信息,就像你在Windows上使用ETW事件那樣。然而,有不同的工具可以在Linux上收集這些事件。
PerfView中的另一個功能,我不太經常使用,但作為GC的用戶,你可能更經常使用,那就是堆快照,即顯示堆上有哪些對象,它們之間是如何連接的。我不經常使用它的原因是,GC並不關心對象的類型。
你可能也用過我們的調試器擴展SoS。我很少用SoS來分析性能,因為它更像是一個調試工具,而不是剖析工具。它也不怎么看GCs,主要是看堆,即堆統計和轉儲單個對象。
在本節的其余部分,我將向你展示如何用PerfView的正確方式進行內存分析。我將多次引用內存基礎來解釋為什么要用這種方式進行分析,這樣做是有意義的,而不是讓你記住我們在這里做什么。
如何開始進行內存性能分析
當你開始進行內存性能分析時,這些步驟是否聽起來很熟悉?
-
捕獲一個CPU采樣文件,看看你是否可以減少任何高開銷的方法的CPU
-
在一個工具中打開一個堆快照,看看你能擺脫什么?
-
捕獲內存分配,看看你能擺脫什么?
根據你要解決的問題,這些可能是有缺陷的。比方說,你有一個尾部延遲的問題,你正在做1)。你可能正在尋找是否可以減少你的代碼中的CPU使用率,或者是一些庫代碼。但是,如果你的尾部延遲受到長GC的影響,減少這些就不太可能影響你的長GC情況。
處理問題的有效方法是推理出有助於實現性能目標的因素,然后從那里開始。我們談到了對不同性能目標有貢獻的頂級GC指標。我們將討論如何收集它們,並看看我們如何分析每一個指標。
本文檔的大多數讀者已經知道如何收集與內存有關的一般指標,所以我將簡要介紹一下。由於我們知道GC堆只是進程中內存使用的一部分,但GC知道物理內存負載,我們想測量進程的內存使用和機器的物理內存負載。在Windows上,你可以通過收集以下性能計數器來實現這一目標。
Memory\Available MBytes(內存\可用內存 單位MB)
Process\Private Bytes(進程\私有內存占用 單位MB)
對於一般的CPU使用率,你可以監控
Process\% Processor time(進程\占用處理器時間百分比)
計數器。調查CPU時間的一個常見做法是,每隔一段時間就進行一次CPU抽樣調查(例如,有些人可能每小時做一次,每次一分鍾),並查看匯總的CPU堆棧。
如何收集頂層的GC指標
GC發出輕量級的信息級事件,你可以收集(如果你願意,可以一直開着),涵蓋所有頂級的GC指標。使用PerfView的命令行來收集這些事件 -
perfview /GCCollectOnly /AcceptEULA /nogui collect
完成后,在perfview cmd窗口中按下s
來停止它。
這應該運行足夠長的時間來捕獲足夠多的GC活動,例如,如果你知道問題發生的時間,這應該涵蓋問題發生前的時間(不僅僅是在問題發生的時間)。如果你不確定問題何時開始發生,你可以讓它開很長時間。
如果你知道要運行多長時間的集合,可以使用下面的方法(實際上這個方法用得更多)。
perfview /GCCollectOnly /AcceptEULA /nogui /MaxCollectSec:1800 collect
並將1800(半小時)替換為你需要的任何秒數。當然,你也可以將這個參數應用於其他命令行。這將產生一個名為PerfViewGCCollectOnly.etl.zip的文件。用PerfView的話來說,我們稱之為GCCollectOnly跟蹤。
在Linux上,有一種類似的方法,就是這個dotnet trace命令行:
dotnet trace collect -p <pid> -o <outputpath with .nettrace extension> --profile gc-collect --duration <in hh:mm:ss format>
這算是一種等價的方法,因為它收集了同樣的GC事件,但只針對已經啟動的一個進程,而perfview命令行收集的是整個機器的ETW,即該機器上每個進程的GC事件,在收集開始后啟動的進程也會收集到。
還有其他方法來收集頂級的GC指標,例如,在.NET Framework上,我們有GC perf計數器;在.NET Core上,也增加了一些GC計數器。計數器和事件之間最重要的區別是,計數器是抽樣的,而事件則捕獲所有的GC,但抽樣也足夠好。在.NET 5中,我們還添加了一個GC采樣API--
public static GCMemoryInfo GetGCMemoryInfo(GCKind kind);
它返回的GCMemoryInfo是基於 "GCKind "的,在GCMemoryInfo.cs中做了解釋。
/// <summary>指定垃圾收集的種類</summary>
/// <remarks>
/// GC可以是3種類型中的一種--短暫的、完全阻塞的或后台的。
/// 它們的發生頻率是非常不同的。短暫的GC比其他兩種GC發生的頻率高得多。
/// 其他兩種類型。后台GC通常不經常發生,而
/// 完全阻塞的GC通常發生的頻率很低。為了對那些非常
/// 不經常發生的GC,集合被分成不同的種類,因此調用者可以要求所有三種GC
/// 同時保持
/// 合理的采樣率,例如,如果你每秒鍾采樣一次,如果沒有這個
/// 區分,你可能永遠不會觀察到一個后台GC。有了這個區別,你可以
/// 總是能得到你指定的最后一個GC的信息。
/// </remarks>
public enum GCKind
{
/// <summary>任何種類的回收</summary>
Any = 0,
/// <summary>gen0或gen1會后.</summary>
Ephemeral = 1,
/// <summary>阻塞的gen2回收.</summary>
FullBlocking = 2,
/// <summary>后台GC回收</summary>
/// <remarks>這始終是一個gen2回收</remarks>
Background = 3
};
GCMemoryInfo提供了與這個GC相關的各種信息,比如它的暫停時間、提交的字節數、提升的字節數、它是壓縮的還是並發的,以及每一代被收集的信息。請參閱GCMemoryInfo.cs了解完整的列表。你可以調用這個API,在過程中以你希望的頻率對GCs進行采樣。
顯示頂級的GC指標
在PerfView的 "GCStats "視圖中,這些數據與你剛剛收集的軌跡一起被方便地顯示。
在PerfView中打開PerfViewGCCollectOnly.etl.zip文件,即通過運行PerfView並瀏覽到該目錄,雙擊該文件;或者運行 "PerfView PerfViewGCCollectOnly.etl.zip "命令行。你會看到該文件名下有多個節點。我們感興趣的是 "Memory Group"節點下的 "GCStats "視圖。雙擊它來打開它。在頂部,我們有這樣的內容
我運行了Visual Studio,它是一個托管應用程序--這就是頂部的devenv進程。
對於每一個進程,你都會得到以下細節--我對那些命令行的進程添加了注釋。
Summary
– 這包括像命令行、CLR啟動標志、GC的%暫停時間等。
GC stats rollup by generation
– 對於gen0/1/2,它有一些東西,如該gen的多少個GCs被完成,它們的平均/平均停頓,等等。
GC stats for GCs whose pause time was > 200ms
暫停時間大於200ms的GC的統計數字
LOH Allocation Pause (due to background GC) > 200 Msec for this process
- Gen2 GC stats該進程的LOH分配暫停(由於后台GC)>200Msec
對於大型對象的分配,有一個注意事項,即在BGC進行時,我們不允許過多的LOH分配。如果你在BGC期間分配了太多的對象,你會看到一個表格出現在這里,告訴你哪些線程在BGC期間被阻塞了(以及多久),因為有太多的LOH分配。這通常是一個信號,告訴你減少LOH分配將有助於不使你的線程被阻塞。
All GC stats
所有GC統計資料
Condemned reasons for GCs
GC被觸發的原因
Finalized Object Counts
終結器對象數量
Summary
explanation "摘要 "解釋
· Commandline
self-explanatory. 命令行
· Runtime version
運行時版本是相當無用的,因為我們只是顯示一些通用版本。然而,你可以使用事件視圖中的FileVersion事件來獲得你所使用的運行時的確切版本。
· CLR Startup Flags
GC啟動標志, 在GC調查中,主要是尋找CONCURRENT_GC和SERVER_GC。如果你沒有這兩個標志,通常意味着你使用的是工作站GC,並且禁用了並發的GC。不幸的是,這不是絕對的,因為在我們捕獲這個事件之后,它可能會被改變。你可以用其他東西來驗證這一點。注意:請注意,目前.NET Core/.NET 5沒有這些標志,所以你在這里不會看到任何東西。
· Total CPU Time and Total GC CPU Time
總的CPU時間和總的GC CPU時間這些總是0,除非你真的在收集CPU樣本。
· Total Allocs
你在這次追蹤中為這個進程所做的總分配。
· MSec/MB Alloc
是0,除非你收集CPU樣本(它將是GC CPU總時間/分配的總字節數)。
· Total GC pause
被GC停頓的總時間。注意,這包括暫停時間,即在GC開始之前暫停被管理的線程所需的時間。
· % Time paused for Garbage Collection
暫停垃圾收集的時間這是 "GC中暫停時間的%"指標。
· % CPU Time spent Garbage Collecting
花在垃圾收集上的CPU時間%這是 "GC中的CPU時間%"指標。它是NaN%,除非你收集CPU樣本。
· Max GC Heap Size
在本次跟蹤過程中,該進程的最大托管堆尺寸。
· 其余的都是鏈接,我們將在本文件中介紹其中一些。
所有 All GC stats
表 中顯示了在跟蹤收集過程中發生的每一個GC(如果有太多的話,它會有一個鏈接到沒有顯示的GC)。在這個表中有很多列。由於這是一個非常廣泛的表格,我將只顯示這個表格中與每個主題有關的列。
其他頂級的GC指標,個別暫停和堆大小,作為這個表格的一部分被顯示出來,就像這樣(Peak MB指的是該GC進入時的GC堆大小,After是退出時的大小)。
GC | Pause | Peak | After |
---|---|---|---|
Index | MSec | MB | MB |
804 | 5.743 | 1,796.84 | 1,750.63 |
805 | 6.984 | 1,798.19 | 1,742.18 |
806 | 5.557 | 1,794.52 | 1,736.69 |
807 | 6.748 | 1,798.73 | 1,707.85 |
808 | 5.437 | 1,798.42 | 1,762.68 |
809 | 7.109 | 1,804.95 | 1,736.88 |
現在,這是一個html表格,你不能進行排序,所以如果你確實想進行排序(例如,找出最長的單個GC停頓),你可以點擊每個過程開始時的 "在Excel中查看 "鏈接 --
· Individual GC Events
o View in Excel
這將在Excel中打開上面的表格,所以你可以對你喜歡的任何一列進行排序。在GC團隊中,由於我們需要對數據進行更多的切分,我們有自己的性能基礎設施 ,直接使用TraceEvent。
PerfView中的其他相關視圖
除了GCStats視圖之外,介紹PerfView中的其他幾個視圖也很有用,因為我們會用到它們。
CPU Stacks是你所期望的--如果你在追蹤中收集了CPU的樣本事件,這個視圖就會亮起來。有一點值得一提的是,我總是先清除3個高亮的文本框--在你這樣做之后,你需要點擊更新來刷新。
我很少發現這3個文本框有用。偶爾我會想按模塊分組,你可以閱讀PerfView的幫助文檔,看看如何做到這一點。
Events就是我們上面提到的 - 原始事件視圖。由於是原始的,它可能聽起來很不友好,但它有一些功能,使它相當方便。你可以通過 "過濾器 "文本框過濾事件。如果你需要用多個字符串進行過濾,你可以使用"|"。如果我想獲得所有名稱中帶有file的事件和GC/Start事件,我使用file|GC/Start(沒有空格)。
雙擊一個事件的名稱會顯示該事件的所有發生情況,你可以搜索具體細節。例如,如果我想找出coreclr.dll的確切版本,我可以在查找文本框中輸入coreclr。
然后你可以看到你正在使用的coreclr.dll的確切版本信息。
我還經常使用 "開始/結束 "來限制事件的時間范圍,使用 "進程過濾器 "將其限制在某個進程中,使用 "顯示列 "來限制要顯示的事件字段(這使我能夠對事件的某個字段進行排序)。
內存組下的GC Heap Alloc Ignore Free是我們要用來查看分配的東西。
Any Stacks顯示所有的事件和它們的堆棧(如果堆棧被收集)。如果我想看一個特定的事件,但還沒有一個既定的視圖,或者既定的視圖沒有提供我所要的東西,我覺得這很方便。
對比堆棧視圖
像CPU堆棧視圖一樣的視圖(即堆快照視圖或GC堆分配視圖)提供了一個Diff-ing功能。如果你在同一個PerfView實例中打開2個跟蹤,當你為每個跟蹤打開一個堆棧視圖時,並且Diff菜單提供了一個 "with Baseline "選項(在Help/Users Guide中搜索 "Diffing Two Traces")。
關於對比2個運行的問題--當你對比2個運行以查看什么是退步時,最好是讓工作負載盡可能的相似。比方說,你做了一個改變以減少分配,與其在相同的時間內運行2個運行,不如用相同數量的請求來運行它們,因為你知道你的新構建正在處理相同數量的工作。否則,一個運行可能運行得更快,因此處理更多的請求,這本來就意味着它已經需要做不同數量的分配。這只是意味着它更難做比較。
GC暫停時間百分比高
如果你不知道如何收集GC暫停時間數據,請按照"如何收集頂級GC指標"中的說明進行。
如果總的暫停時間很高,可能是由於GC太多(即GC觸發太頻繁),GC暫停時間太長或兩者都有。
-
太多的停頓,即太多的GC
根據我們的一條規則的一部分,觸發GC的頻率是由不存活的東西決定的。因此,如果你正在做大量的臨時對象分配(意味着它們不能存活),這意味着你將觸發大量的GC。在這種情況下,看一下這些分配是有意義的。如果你能消除一些,那就太好了,但有時這並不實際。我們提到了3種方法來剖析分配。讓我們看看如何進行每個分析。
測量分配
獲得分配的字節數
我們已經知道,你可以在摘要中得到分配字節的總數。在GCStats視圖中,你還可以得到每個GC的分配字節數gen0。
GC Index(GC編號) | Gen (代) | Gen0 Alloc MB (Gen分配數量(MB)) |
---|---|---|
7 | 0N | 95.373 |
8 | 1N | 71.103 |
9 | 0N | 103.02 |
10 | 2B | 0 |
11 | 0N | 111.28 |
12 | 1N | 94.537 |
在Gen一欄中,'N'表示Nonconcurrent GC,'B'表示Background。所以完全阻塞的GC顯示為2N。因為只有gen2的GC可以是后台,所以你只能看到2B,而不是0B或1B。你也可能看到'F',這意味着前景GC--當BGC正在進行時發生的短暫GC。
注意,對於2B來說是0,因為如果gen0或gen1的分配預算被超過,我們會在BGC的開始做一個gen0或gen1的GC,所以gen0分配的字節數會顯示在gen0或gen1的GC上。
我們知道,當gen0的分配預算被超過時,就會觸發GC。這個數據在GCStats中默認是不顯示的(只是因為表格中已經有很多列了)。但你可以通過點擊GCStats中表格前的Raw Data XML file(用於調試)鏈接來獲得。一個xml文件將被生成並包含更詳細的數據。對於每個GC,你會看到這個(我把它修剪了一下,所以不會太寬)-
<GlobalHeapHistory FinalYoungestDesired="9,830,400" NumHeaps="12"/>
FinalYoungestDesired是為這個GC計算的最終gen0預算。由於我們對所有堆的預算進行了均衡,這意味着每個堆都有這個相同的預算。由於有12個堆,任何一個堆用完它的gen0預算都會導致下一次GC被觸發。所以在這種情況下,這意味着最多只有12*9,830,400=117MB的分配,直到下一次GC被觸發。我們可以看到下一個GC是一個BGC,它的Gen0 Alloc MB是0,因為我們把這個BGC開始時做的短暫GC歸結為GC#11,它在GC#9結束后在Gen0中分配了111.28 MB。
查看帶有堆棧信息的采樣分配
當然,你會想知道這些分配的情況。GC提供了一個叫做AllocationTick的事件,大約每100KB的分配就會被觸發。對於小對象堆來說,100KB意味着很多對象(這意味着對於SOH來說是采樣),但對於LOH來說,這實際上是准確的,因為每個對象至少有85000字節大。這個事件有一個叫做AllocationKind的字段--small意味着它是由分配為SOH而觸發的,而這個分配恰好使該SOH上的累積分配量超過了100KB(那么這個量會被重置)。所以你實際上不知道最后的分配量是多大。但是根據這個事件的頻率,看看哪些類型被分配的最多,以及分配它們的調用棧,仍然是一個非常好的近似值。
很明顯,與只收集GCCollectOnly跟蹤相比,收集這個會增加明顯的開銷,但這仍然是可以容忍的。
PerfView.exe /nogui /accepteula /KernelEvents=Process+Thread+ImageLoad /ClrEvents:GC+Stack /BufferSize:3000 /CircularMB:3000 collect
這將收集AllocationTick事件及其分配被采樣對象的調用棧。然后你可以在內存組下的 "GC Heap Alloc Ignore Free (Coarse Sampling) "視圖中打開它。
點擊一個類型,你就可以看到分配該類型實例的堆棧。
注意,當你在同一個PerfView實例中打開兩個追蹤時,你可以比較兩個GC對的分配視圖
而且你可以雙擊每一種類型來查看分配給它們的調用棧。
查看AllocationTick事件的另一種方法是使用Any Stacks視圖,因為它按大小分組。例如,這是我從一個客戶的跟蹤中看到的情況(類型名稱已匿名或縮短)。
Name | Inc |
---|---|
Event Microsoft-Windows-DotNETRuntime/GC/AllocationTick | 627,509 |
+ EventData TypeName Entry[CustomerType,CustomerCollection][] | 221,581 |
|+ EventData Size 106496 | 4,172 |
||+ EventData Kind Small | 4,172 |
|| + coreclr | 4,172 |
|| + corelib!System.Collections.Generic.Dictionary`2[CustomerType,System.__Canon].Resize(int32,bool) | 4,013 |
|| + corelib!System.Collections.Generic.Dictionary`2[CustomerType,System.__Canon].Initialize(int32) | 159 |
|+ EventData Size 114688 | 3,852 |
||+ EventData Kind Small | 3,852 |
|| + coreclr | 3,852 |
|| + corelib!System.Collections.Generic.Dictionary`2[CustomerType,System.__Canon].Resize(int32,bool) | 3,742 |
|| + corelib!System.Collections.Generic.Dictionary`2[CustomerType,System.__Canon].Initialize(int32) | 110 |
這說明大部分分配來自於字典的重新調整,你也可以從 GC Heap Alloc 視圖中看到,但樣本計數信息給了你更多的線索(Resize 有 4013 次,而 Initialize 有 159 次)。所以,如果你能更好地預測字典會有多大,你可以把初始容量設置得更大,以大大減少這些分配。
**使用 CPU 樣本查看內存清理成本 **
如果你沒有這些AllocationTick事件的跟蹤,但有CPU樣本(這很常見),你也可以看一下內存清除的成本-
如果你看一下memset_repmovs的調用者,突出顯示的2個調用者來自GC在把新對象遞出之前的內存清除:
(這是在.NET 5下,如果你有舊版本,你會看到WKS::gc_heap::bgc_loh_alloc_clr而不是WKS::gc_heap::bgc_uoh_alloc_clr)。
在我的例子中,因為分配幾乎是測試的全部內容,所以分配成本非常高--占CPU總使用量的25.6%
理解為什么GC決定收集這一代
在GCStats中,每個GC都有一列叫做 "Trigger Reason"。這告訴你這個GC是如何被觸發的。可能的觸發原因在PerfView repo的ClrTraceEventParser.cs中定義為GCReason
。
public enum GCReason
{
AllocSmall = 0x0,
Induced = 0x1,
LowMemory = 0x2,
Empty = 0x3,
AllocLarge = 0x4,
OutOfSpaceSOH = 0x5,
OutOfSpaceLOH = 0x6,
InducedNotForced = 0x7,
Internal = 0x8,
InducedLowMemory = 0x9,
InducedCompacting = 0xa,
LowMemoryHost = 0xb,
PMFullGC = 0xc,
LowMemoryHostBlocking = 0xd
}
在這些原因中,最常見的是AllocSmall - 這是說gen0的預算被超過了。如果你看到的最常見的是AllocLarge,那么它很可能表明了一個問題--它是說你的GC被觸發了,因為你分配了大的對象,超過了LOH預算。正如我們所知,這意味着它將觸發gen2 GC
而我們知道,觸發頻繁的完全GC通常是性能問題的秘訣。其他由於分配引起的觸發原因是OutOfSpaceSOH和OutOfSpaceLOH - 你看到這些比AllocSmall和AllocLarge要少得多--這些是為你接近物理空間極限時准備的(例如,如果我們內存分配正在接近短暫段的終點)。
那些幾乎總是引起危險信號的事件是 "Induced",因為它們意味着一些代碼實際上是自己觸發了GC。我們有一個GCTriggered事件,專門用於發現什么代碼用其調用棧觸發了GC。你可以用堆棧和最小的內核事件收集一個非常輕量級的GC信息級別的跟蹤:
PerfView.exe /nogui /accepteula /KernelEvents=Process+Thread+ImageLoad /ClrEvents:GC+Stack /ClrEventLevel=Informational /BufferSize:3000 /CircularMB:3000 collect
然后在任意堆棧視圖中查看GCTriggered事件的堆棧:
因此,"觸發原因 "是指GC是如何開始或產生的。如果一個GC開始的最常見原因是由於在SOH上分配,那么這個GC將作為一個gen0的GC開始(因為gen0的預算被超過了)。現在在GC開始之后,我們再決定我們實際上會收集哪一代。它可能保持為0代GC,或者升級為1代甚至2代GC-這是我們在GC中最先決定的事情之一。導致我們升級到更高世代的GC的因素就是我們所說的 "派遣的原因"(所以對於一個GC來說,只有一個觸發的原因,但可以有多個派遣的原因)。
以下是出現在表格本身之前的 "GC的派遣理由 "部分的解釋文本
本表更詳細地說明了GC決定收集那一代的確切原因。將鼠標懸停在各列標題上,以獲得更多信息。
我不會在這里重復這些信息。最有趣的是那些升級到gen2的GC - 通常這些是由gen2的高內存負載或高碎片引起的。
-
個別的長時間停頓
如果你不知道如何收集GC暫停時間數據,請按照"如何收集頂級GC指標"中的說明進行。
如果你不熟悉是什么導致了單個GC暫停,請先閱讀GC暫停部分,它解釋了哪些因素導致了GC暫停時間。
我們知道,所有短暫的GCs都是阻塞的,而gen2 GC可以是阻塞的,也可以是背景的(BGC)。短暫的GC和BGC應該會產生短暫的停頓。但是事情可能出錯,我們將展示如何分析這些情況。
如果堆的大小很大,我們知道一個阻塞的gen2 GC會產生一個很長的停頓。但是當我們需要做gen2 GC的時候,我們一般傾向於BGC。所以長的GC暫停是由於阻塞的gen2 GC造成的,我們會想弄清楚為什么我們要做這些阻塞的gen2 GC。
如此長時間的個別停頓可能是由以下因素或它們的組合造成的—
· 在暫停期間有很多GC工作要做。
· GC正在嘗試執行工作,但無法執行,因為CPU被占用
讓我們看看如何分析每個場景。
首先,您是否存在托管內存泄漏?
如果你不知道什么是管理型內存泄露,請先回顧一下那一節。根據定義,這不是GC能幫你解決的問題。如果你有一個托管的內存泄漏,保證GC會有越來越多的工作要做。
在生產中觸發完全阻塞的GC可能是非常不可取的,所以在開發階段做盡職調查很重要。例如,如果你的產品處理請求,你可以在每個請求或每N個請求結束時觸發一個完全阻塞的GC。如果你認為內存使用量應該是相同的,你應該能夠用工具來驗證。在這種情況下可以使用很多工具,因為這是一個簡單的場景。所以PerfView當然也有這個功能。你可以通過Memory/Take Heap Snapshot來獲取堆快照。它確實有一些不完全直截了當的選項-
"Max Dump K Objs "是一個 "聰明 "的嘗試,所以它不會轉儲每一個對象。我總是把它增加到至少是默認值(250)的10倍。凍結選項是為生產診斷而設的,當你不想招致完全阻塞的GC暫停時。但是,如果你在開發過程中這樣做,並試圖了解你的應用程序的基本行為,你沒有理由不檢查它,這樣你就能得到一個准確的圖片,而不是用非Freeze選項試圖 "盡最大努力來跟蹤對象圖"。
然后在PerfView中打開生成的.gcDump文件,它有一個類似堆棧的視圖,顯示根信息(例如,GC句柄持有這個對象)和轉儲中的類型實例的聚合信息。由於這是一個類似於堆棧的視圖,它提供了差分功能,所以你可以在PerfView中取兩個gcDump文件並進行差分。
當你在生產中這樣做時,你可以先嘗試不使用凍結。
長時間的停頓是由於短暫的GCs、完全阻塞的GCs還是BGCs?
GCStats視圖在每個進程的頂部都有一個方便的滾動表,顯示了各代的最大/平均/總停頓時間(我確實應該把全阻塞的GC和BGC分開,但現在你可以參考Gen2的表格)。一個例子。
計算出gen2 GC的工作量
對於gen2 GCs,我們希望看到大部分或所有的GC都以BGC的形式完成。但是如果你看到一個完全阻塞的GC(在GCStats中表示為2N),如果你的堆很大的話,它的暫停時間有可能很長(你會看到gen2 GCs有非常高的升代內存數量 MB,與短暫的GC相比)。通常人們在這個時候要做的是進行堆快照,看看堆上有什么東西,並嘗試減少這些東西。然而,首先要弄清楚的是,為什么你首先要做完全阻塞的GCs。你可以查看Condemned Reasons表來了解這個問題。最常見的原因是高內存負載和gen2碎片化。要想知道每個GC觀察到的內存負載,點擊GCStats中 "GC Rollup By Generation "表格上方的 "Raw Data XML file (for debugging) "鏈接,它將生成一個xml文件,其中包括內存負載等額外信息。一個例子是(我剪掉了大部分的信息)-
<GCEvent GCNumber= "45" GCGeneration="2" >
<GlobalHeapHistory FinalYoungestDesired="69,835,328" NumHeaps="32"/>
<PerHeapHistories Count="32" MemoryLoad="47">
</PerHeapHistory>
</GCEvent>
這說明當GC#45發生時,它觀察到的內存負載為47%。
由於bug導致的長時間停頓
通常BGC的停頓都很小。唯一的一次是由於運行時的一個罕見的bug(例如,我們修復了一個bug,即模塊迭代器占用了一個鎖,當過程中有很多很多模塊時,這種鎖的爭奪意味着每個GC線程需要很長時間來迭代這些模塊),或者你正在做一些只在BGC的STW標記部分做的工作。由於這可能是由於非GC工作造成的,我們將在 "弄清長時間的GC是否是由於GC工作 "中討論如何診斷這個問題。
計算出短暫GC的工作量
GC的工作量大致與幸存者成正比,這由 "Promoted Bytes "指標表示,該指標是GCStats表格中的一列 -
這是有道理的--gen1 GCs比gen0 GCs升代的對象更多,因此它們需要更長的時間。而且它們不會進行太多升代,因為它們只收集(通常是一小部分)堆。
如果你看到短暫的GCs突然增加了很多,那么估計暫停時間會長很多。我所看到的一個原因是,它進入了一個不經常被調用的代碼路徑,對象存活下來,而這些對象是不應該存活的。不幸的是,我們用於找出導致短暫對象存活的原因的工具不是很好--我們已經在.NET 5中添加了運行時支持,你可以使用PerfView中一個特殊的視圖,稱為 "Generational Aware "視圖,以查看哪些老年代對象導致年輕代對象存活--我將很快寫出更多細節。你將看到的是這樣的情況:
我不知道有什么其他工具可以方便地告訴你這些信息(如果你知道有什么工具可以告訴你老一代的對象在年輕一代的對象上保持着什么,使它們在GC期間存活,請好心地告訴我!)。
請注意,如果你在gen2/LOH中有一個對象持有年輕gen對象的引用,如果你不再需要它們引用那些對象,你需要手動將這些引用字段設置為null。否則,它們將繼續持有那些對象的引用,並導致它們被升代。對於C#程序來說,這是導致短暫對象存活的一個主要原因(對於F#程序來說,就不是這樣了)。你可以從GCStats視圖生成的Raw XML中看到這一點(點擊 "Raw Data XML file (for debugging) "鏈接,就在 "GC Rollup By Generation "表的上方),我把大部分屬性從xml中修剪掉了 -
<GCEvent GCNumber="9" GCGeneration="0">
<PerHeapHistories Count="12" MemoryLoad="20">
<PerHeapHistory MarkStack="0.145(10430)" MarkFQ="0.001(0)"
MarkHandles="0.005(296)" MarkOldGen="2.373(755538)">
<PerHeapHistory MarkStack="0.175(14492)" MarkFQ="0.001(0)"
MarkHandles="0.003(72)" MarkOldGen="2.335(518580)">
每個GC線程由於各種根而升代的字節數是PerHeapHistory數據的一部分-MarkStack/FQ/Handles分別是標記堆棧變量、終結隊列和GC句柄,MarkOldGen表示由於來自老一代的引用而升代的字節數量。因此,舉例來說,如果你正在做一個gen1的GC,這就是gen2對象對gen0/gen1對象的持有數量,以使其存活。我們在.NET 5中對服務器GC所做的一個改進是,當我們標記OldGen根時,平衡GC線程的工作,因為這通常會導致最大的升代數量。因此,如果你在你的應用程序中看到這個數字非常不平衡,升級到.NET 5會有幫助。
弄清楚長的GC是否是由於GC工作造成的
如果一個GC很長,但卻不符合上述任何一種情況,也就是說,沒有很多工作需要GC去做,但還是會造成長時間的停頓,這意味着我們需要弄清楚為什么GC在它想做工作的時候卻沒有做到。而通常當這種情況發生時,它似乎是隨機發生的。
偶爾長停的一個例子 -
我們在PerfView中做了一個非常方便的功能,叫做停止觸發器,意思是 "當觀察到某些條件滿足時,盡快停止跟蹤,這樣我們就能捕捉到最相關的最后部分"。它已經有一些專門用於GC目的的內置停止觸發器。
GC事件發生的順序
為了了解它們是如何工作的,我們首先需要簡要地看一下GC的事件序列。這里有6個相關的事件-
Microsoft-Windows-DotNETRuntime/GC/SuspendEEStart //開始暫停托管線程運行
Microsoft-Windows-DotNETRuntime/GC/SuspendEEStop //暫停托管線程完成
Microsoft-Windows-DotNETRuntime/GC/Start // GC開始回收
Microsoft-Windows-DotNETRuntime/GC/Stop // GC回收結束
Microsoft-Windows-DotNETRuntime/GC/RestartEEStart //恢復之前暫停的托管線程
Microsoft-Windows-DotNETRuntime/GC/RestartEEStop //恢復托管線程運行完成
(你可以在事件視圖中看到這些內容)
在一個典型的阻塞式GC中(這意味着所有短暫的GC和完全阻塞的GC),事件發生順序非常簡單:
GC/SuspendEEStart
GC/SuspendEEEnd <– 暫停托管線程完成
GC/Start
GC/End <– actual GC is done
GC/RestartEEStart
GC/RestartEEEnd <– 恢復托管線程運行完成
GC/SuspendEEStart和GC/SuspendEEEnd是用於暫停;GC/RestartStart和GC/RestartEEEnd是用於恢復。恢復只需要很少的時間,所以我們不需要討論它。暫停是可能需要很長時間的。
BGC要復雜得多,一個完整的BGC事件序列看起來是這樣的
1) GC/SuspendEEStart
2) GC/SuspendEEStop
3) GC/Start <– BGC/ starts
<- there might be an ephemeral GC happen here, if so you'd see(這里可能有一個短暫的GC發生,如果是這樣,你會看到)
GC/Start
GC/Stop
4) GC/RestartEEStart
5) GC/RestartEEStop <– done with the initial suspension (完成了最初的暫停)
<- there might be 0 or more foreground ephemeral GC/s here, an example would be (這里可能有0個或更多的前台瞬時的GC/s,一個例子是)
GC/SuspendEEStart
GC/SuspendEEStop
GC/Start
GC/Stop
GC/RestartEEStart
GC/RestartEEStop
6) GC/SuspendEEStart
7) GC/SuspendEEStop
8) GC/RestartEEStart
9) GC/RestartEEStop <– done with BGC/'s 2nd suspension (完成了BGC/的第二次停牌)
<- there might be 0 or more foreground ephemeral GC/s here (這里可能有0個或更多的前台短暫GC/s)
10) GC/Stop <– BGC/ Stops
所以BGC在它的中間有兩對暫停/重啟。目前在GCStats視圖中,我們實際上是將這兩個暫停合並在一起(我正計划將它們分開),但如果你確實看到一個長的BGC暫停,你總是可以使用事件視圖來找出哪個暫停是長的。在下面的例子中,我從事件視圖中復制並粘貼了一個客戶跟蹤的事件序列,它遇到了我提到的bug導致長時間暫停。
Event Name | Time MSec | Reason | Count | Depth | Type | explanation |
---|---|---|---|---|---|---|
GC/Start | 160,551.74 | AllocSmall | 188 | 2 | BackgroundGC | |
GC/Start | 160,551.89 | AllocSmall | 189 | 0 | NonConcurrentGC | We are doing a gen0 at the beginning of this BGC(這個BGC開始時正在做一個gen0) |
GC/Stop | 160,577.48 | 189 | 0 | |||
GC/RestartEEStart | 160,799.87 | There's a long period of time here between last event and this one due to the bug(由於錯誤,在上次活動和這次活動之間,這里有一段很長的時間) | ||||
GC/RestartEEStop | 160,799.91 | |||||
GC/SuspendEEStart | 161,803.36 | SuspendForGC | 188 | A Foreground gen1 happens(前台gen1發生) | ||
GC/SuspendEEStop | 161,803.42 | |||||
GC/Start | 161,803.61 | AllocSmall | 190 | 1 | ForegroundGC | |
GC/Stop | 161,847.14 | 190 | 1 | |||
GC/RestartEEStart | 161,847.15 | |||||
GC/RestartEEStop | 161,847.23 | The Foreground gen1 ends(前台gen1結束) | ||||
GC/SuspendEEStart | 161,988.57 | SuspendForGCPrep | 188 | BGC's 2nd suspension starts with SuspendForGCPrep as its reason(BGC的第二次暫停開始,理由是SuspendForGCPrep) | ||
GC/SuspendEEStop | 161,988.71 | |||||
GC/RestartEEStart | 162,239.84 | |||||
GC/RestartEEStop | 162,239.94 | BGC's 2nd suspension ends, another long pause due to the same bug(BGC的第二次停頓結束,由於同樣的錯誤,又一次長時間停頓) | ||||
GC/Stop | 162,413.70 | 188 | 2 |
我所做的是在CPU堆棧視圖中查找那些長時間停頓的時間范圍(160,577.482-160,799.868和161,988.57-162,239.94),發現了這個錯誤。
PerfView GC停止觸發器
有3個GC特定的停止觸發器 -
Trigger name | What it measures |
---|---|
StopOnGCOverMsec | trigger if the time between GC/Start and GC/Stop is over this value, and it's not a BGC(如果GC/Start和GC/Stop之間的時間超過這個值,並且不是BGC,則觸發。) |
StopOnGCSuspendOverMSec | trigger if the time between GC/SuspendEEStart and GC/SuspendEEStop is over this value(如果GC/SuspendEEStart和GC/SuspendEEStop之間的時間超過這個值,則觸發。) |
StopOnBGCFinalPauseOverMSec | trigger if the time between GC/SuspendEEStart (with Reason SuspendForGCPrep) and GC/RestartEEStop is over this value(如果GC/SuspendEEStart(與原因是 SuspendForGCPrep)和GC/RestartEEStop之間的時間超過這個值,則觸發。) |
我通常與/StopOnGCOverMSec和/StopOnBGCFinalPauseOverMSec一起使用的命令行是 --
PerfView.exe /nogui /accepteula /StopOnGCOverMSec:15 /Process:A /DelayAfterTriggerSec:0 /CollectMultiple:3 /KernelEvents=default /ClrEvents:GC+Stack /BufferSize:3000 /CircularMB:3000 /Merge:TRUE /ZIP:True collect
如果你的進程被稱為A.exe,你會想指定/Process:A。我在這篇博客中對每個參數都有詳細解釋。
調試一個隨機的長GC
這里有一個例子,演示了如何調試一個突然比其他升代了類似數量的GC要長很多的GC。我用上面的命令行收集了一個跟蹤,我可以在GCStats中看到有一個GC比15個長 - 它是GC#4022,是20.963ms,而且它沒有比正常情況下更多的升代(你可以看到在它上面的gen0,升代的數量非常相似,但花的時間卻少很多)。
所以我在CPU堆棧視圖中輸入GC#4022的開始和結束時間戳(30633.741到30654.704),我看到對於執行實際GC工作的coreclr!SVR::gc_heap::gc_thread_function,有兩部分沒有CPU占用,而應該有很多--____ 部分意味着沒有CPU占用。
此,我們可以在CPU堆棧視圖中突出顯示第一個平面部分,右擊它並選擇 "設置時間范圍"。這將向我們顯示這個進程在這段時間內的CPU樣本,當然我們將看到沒有。
我們看到mpengine模塊,它來自MsMpEng.exe進程(雙擊mpengine單元會告訴你它屬於哪個進程)。要確認這個進程干擾了我們的進程,就是在Events中輸入開始和結束的時間戳,然后看一下原始的CPU樣本事件(如果你不知道如何使用這個視圖,請看PerfView中的其他相關視圖部分) -
你可以看到MsMpEng.exe進程的樣本的優先級非常高--15。服務器GC線程運行的優先級是11左右。
為了調試長時間的暫停,我通常采取ThreadTime跟蹤,其中包括ContextSwitch和ReadyThread事件--它們是大量的,但應該准確地告訴我們GC線程在調用SuspendEE時正在等待什么-
PerfView.exe /nogui /accepteula /StopOnGCSuspendOverMSec:200 /Process:A /DelayAfterTriggerSec:0 /CollectMultiple:3 /KernelEvents=ThreadTime /ClrEvents:GC+Stack /BufferSize:3000 /CircularMB:3000 /Merge:TRUE /ZIP:True collect
然而,ThreadTime追蹤可能太多,可能會導致你的應用程序運行得不夠 "正常",無法表現出你所調試的行為。在這種情況下,我會從默認的內核事件開始追蹤,這通常會揭示問題或給你足夠的線索。你可以簡單地把ThreadTime替換成Default -
PerfView.exe /nogui /accepteula /StopOnGCSuspendOverMSec:200 /Process:A /DelayAfterTriggerSec:0 /CollectMultiple:3 /KernelEvents=Default /ClrEvents:GC+Stack /BufferSize:3000 /CircularMB:3000 /Merge:TRUE /ZIP:True collect
我在這篇博客中有一個詳細的調試長懸掛問題的例子。
大尺寸的GC堆
如果你不知道如何收集GC堆大小數據,請按照"如何收集頂級GC指標"中的說明進行操作。
-
調試OOM
在我們談論大的GC堆大小作為一個一般的問題類別之前,我想特別提到調試OOM,因為這是一個大多數讀者都熟悉的例外。而且有些人可能已經使用了SoS !"AnalyzeOOM "命令,它可以顯示2個方面--1)是否確實存在一個托管堆的OOM。 因為GC堆只是你進程中的一種內存使用,OOM不一定是GC堆造成的;2)如果是托管堆OOM,什么操作造成的,例如,GC試圖保留一個新段,但做不到(你在64位上永遠不會真正看到這個)或在試圖做分配時無法提交。
在不使用SoS的情況下,你也可以通過簡單地查看GC堆使用多少內存與進程使用多少內存來驗證GC堆是否是OOM的罪魁禍首。我們將在下面討論堆大小的分析。如果你確認GC堆占用了大部分的內存,而且我們知道OOM只是在GC非常努力地減少堆的大小,但是不能之后才被拋出,這意味着有大量的內存由於用戶根而幸存下來,這意味着GC無法回收它。而你可以按照管理性內存泄露調查來弄清楚哪些內存幸存下來。
現在讓我們來談談這樣的情況:你沒有得到OOM,但需要看一下堆的大小,看看是否可以或如何優化它。在"如何正確看待GC堆的大小 "一節中,我們談到了堆的大小以及如何廣泛地測量。所以我們知道,堆的大小在很大程度上取決於你在GC發生時的測量和分配預算。GCStats視圖顯示了GC進入和退出時的大小,即峰值和后值。
剖析一下這些尺寸是有幫助的。After MB這一欄是以下的總和
Gen0 MB + Gen1 MB + Gen2 MB + LOH MB
還注意到Gen0 Frag %說的是99.99%。我們知道這是由於pinning。因此,部分gen0分配將適合於這種碎片化。所以對於GC #2來說,在GC #1結束時以26.497 MB開始,然后分配了101.04 MB,在GC #2開始時以108.659 MB的大小結束。
-
峰值尺寸太大,但GC后尺寸不大?
如果是這種情況,這通常意味着在觸發下一次GC之前有太多的gen0分配。在.NET Core 3.0中,我們啟用了一個限制這種情況的配置,叫做GCGen0MaxBudget--我通常不建議人們設置這個配置,因為你可能會把它設置得太小,從而導致GC過於頻繁。這是為了限制gen0的最大分配預算。當你使用Server GC時,GC在設置gen0預算時相當積極,因為它認為進程使用機器上的大量資源是可以的。這通常是可以的,因為如果一個進程使用了服務器GC,往往意味着它可以負擔得起使用大量的資源。但是,如果你確實有這樣的情況,你對更頻繁地觸發GC以交換到更小的堆大小沒有意見,你可以用這種配置來做。我的希望是,在未來,我們將使這成為一些高級配置的一部分,允許你向我們傳達你想做這種交換,這樣GC就可以為你自動調整,而不是你自己使用一個非常低級的配置。
在過去使用GC性能計數器的人認識到 "#Total Committed Bytes "計數器,他們問如何在.NET Core中獲得這個計數器。首先,如果你用這種方式測量已提交的字節,你可能會看到它更接近峰值大小,而不是之后的大小,這是因為在短暫段上對已提交的特殊處理。因為 "After size "沒有報告我們將要使用但還沒有使用的gen0預算部分。所以你可以直接使用GCStats中報告的峰值大小作為你的近似的總投入。但如果你使用的是.NET 5,你可以通過調用我們前面提到的GetGCMemoryInfoAPI得到這個數字--它是GCMemoryInfo結構上返回的屬性之一。
有一個不太方便的方法,就是每堆歷史事件中的ExtraGen0Commit字段。你可以在你已經得到的堆大小信息(即在GCHeapStats事件中)的基礎上添加這個字段(如果你使用的是服務器GC,它將是所有堆的ExtraGen0Commit之和)。但是我們沒有在PerfView的用戶界面中公開這一點,所以你需要自己去使用TraceEvent庫來獲得這些信息。
-
GC后尺寸很大?
如果是這樣,大部分的尺寸是在gen2/LOH中嗎?你是否主要在做后台GC(不壓縮)?如果你已經在做完全阻塞的GC,而After的大小還是太大,這僅僅意味着你有太多的數據存活下來。你可以按照管理性內存泄露調查來弄清楚存活的數據。
另一種可能的情況是有很大比例的堆在gen0中,但大部分是碎片。這種情況會發生,特別是當你把一些對象釘住了很久,而且它們在堆上足夠分散時。所以即使GC已經降代它們到了gen0,只要這些引腳沒有消失,堆的那一部分仍然不能被回收。你可以收集GCHandle事件來計算它們何時被釘住。PerfView的命令行是
perfview /nogui /KernelEvents=Process+Thread+ImageLoad /ClrEvents:GC+Stack+GCHandle /clrEventLevel=Informational collect
-
gen2 GC是否主要為后台GC?
如果GC主要是后台GC,那么需要看看碎片的使用是否高效,也就是說,當后台GC開始時,gen2 Frag %是否非常大?如果不是非常大,這意味着它的工作是最優化的。否則這表明后台GC的調度問題--請讓我知道。要看后台GC開始時的碎片情況,你可以使用GCStats視圖中的Raw XML鏈接來查看它。我已經把數據修剪成只有相關的部分-
<GCEvent GCNumber= "1174" GCGeneration="2" Type= "BackgroundGC" Reason= "AllocSmall">
<PerHeapHistory>
<GenData Name="Gen2" SizeBefore="187,338,680" SizeAfter="187,338,680" ObjSpaceBefore="177,064,416" FreeListSpaceBefore="10,200,120" FreeObjSpaceBefore="74,144"/>
<GenData Name="GenLargeObj" SizeBefore="134,424,656" SizeAfter="131,069,928" ObjSpaceBefore="132,977,592" FreeListSpaceBefore="1,435,640" FreeObjSpaceBefore="11,424"/>
SizeBefore = ObjSpaceBefore + FreeListSpaceBefore + FreeObjSpaceBefore
SizeBefore` 這一代的總規模
ObjSpaceBefore
這一代的有效對象所占的大小
FreeListSpaceBefore
這一代的自由列表所占的大小
FreeObjSpaceBefore
在這一代中,太小的自由對象所占用的大小,不能進入自由列表。
(FreeListSpaceBefore + FreeObjSpaceBefore) 就是我們所說的碎片化
在這種情況下,我們看到((FreeListSpaceBefore + FreeObjSpaceBefore)/ SizeBefore)是5%,這是相當小的,這意味着我們已經用掉了大部分BGC建立好的自由空間。當然,我們希望看到這個比例越小越好,但如果自由空間太小,就意味着GC可能無法使用它們。一般來說,如果這個比例是15%或更小,我不會擔心,除非我們看到自由空間足夠大但沒有被使用。
你也可以從我們前面提到的GetGCMemoryInfoAPI中獲得這些數據。
-
你看到的堆的大小從GC的角度來看是合理的,但仍然希望有一個更小的堆?
在你經歷了上述情況后,你可能會發現,從GC的角度來看,這一切都可以解釋。但如果你仍然希望堆的大小更小呢?
你可以把你的進程放在一個內存受限的環境中,也就是說,一個有內存限制的容器,這意味着GC會自動識別為它可以使用的內存。然而,如果你使用的是Server GC,你至少要升級到.NET Core 3.0,它對容器的支持更加強大。在該版本中,我們還添加了2個新的配置,允許你指定GC堆的內存限制 - GCHeapHardLimit和GCHeapHardLimitPercent。它們在本博客文章中得到了解釋。
當你的進程運行在有其他進程的機器上時,GC開始反應的默認內存負載可能不是每個進程都想要的。你可以考慮使用GCHighMemPercent配置,並將該閾值設置得更低--這將使GC更積極地進行完全阻塞的GC,所以即使有內存可用,它也不會使你的堆增長得那么多。
-
GC是否為自己的記賬工作使用了太多的內存?
偶爾我也收到一些人的報告,他們確實觀察到有一大塊內存被用於GC記賬。你可以通過GC的gc_heap::grow_brick_card_tables
的VirtualAlloc調用看到。這是因為,由於在地址空間中保留了一些意想不到的區域,堆的范圍被拉得太長了。如果你確實遇到了這個問題,並且無法防止意外的保留,你可以考慮用GCHeapHardLimit/GCHeapHardLimitPercent指定一個內存限制,那么整個限制將被提前保留,這樣你就不會遇到這個問題了。
性能問題的明確跡象
如果你看到以下任何情況,毫無疑問你有性能問題。與任何性能問題一樣,正確確定優先次序總是很重要的。例如,你可能有很長的GC暫停,但如果它們不影響你所關心的性能指標,你把時間花在其他地方會更有成效。
我使用PerfView中的GCStats視圖來顯示這些症狀。如果你不熟悉這個視圖,請看本節。你不一定要使用PerfView,只要能夠顯示下面的數據,使用任何工具都可以。
暫停時間太長
暫停通常在每次發生時都會少於1ms。如果你看到的是10秒或100秒的東西,你不需要懷疑你是否有寧問題--這是一個明確的信號,說明你有。
如果你看到你的大部分GC暫停都被暫停占用了,尤其是持續的暫停,而且你的總GC暫停太多,你肯定應該調試它。我在這篇博客中有一個詳細的調試長暫停問題的例子。
這可以通過GCStats視圖中的 "Suspend Msec "和 "Pause Msec "列來表示。我模擬了一個例子 --
GC Index(索引) | Suspend Msec(暫停時間) | Pause Msec(停頓時間) |
---|---|---|
10 | 150 | 180 |
11 | 190 | 200 |
兩個GCs的大部分停頓時間都是在暫停中度過的。
隨機的長時間GC停頓
"隨機長的GC停頓 "意味着你突然看到一個GC並沒有比平時升代更多,但卻需要更長的時間。下面是一個模擬的例子
GC Index(索引) | Suspend Msec(暫停時間) | Pause Msec(停頓時間) | Promoted MB(升代MB) |
---|---|---|---|
10 | 0.01 | 5 | 2.0 |
11 | 0.01 | 200 | 2.1 |
12 | 0.01 | 6 | 2.2 |
所有的GCs都升代了~2MB,但是GC#10和#12花了幾毫秒,而GC#11花了200。這就說明在GC#11期間出了問題。有時你可能會看到突然花了很長時間的GC也招致了很長時間的暫停,因為導致長時間暫停的原因也影響了GC的工作。
我已經給出了一個例子上面如何調試這個問題。
大多數GC是完全阻塞的GC
如果你看到大多數GC是完全阻塞的,如果你有一個大的堆,這通常需要相當長的時間,這就是一個性能問題。我們不應該一直做完全阻塞的GCs,就是這樣。即使你處於高內存負載的情況下,做完全阻塞GC的目的是為了減少堆的大小,這樣內存負載就不再高了。而GC有辦法應對有挑戰的情況,比如針對高內存負載的臨時模式+沉重的固定,以避免做更多的完全阻塞GC,而不是必要。我見過的最常見的原因實際上是誘導的完全阻塞的GCs,這對調試來說是很容易的,因為GCStats會告訴你觸發原因是誘導的。下面是一個模擬的例子
GC Index | Trigger Reason | Gen | Pause Msec |
---|---|---|---|
10 | Induced | 2NI | 1000 |
11 | Induced | 2NI | 1100 |
12 | Induced | 2NI | 1000 |
本節講述了如何找出誘發GC的原因。
有助於我們幫助你調試性能問題的信息
在某些時候,在你遵循本文件中的建議並做了詳盡的調查后,你仍然發現你的性能問題沒有得到解決。我們很願意幫助你! 為了節省你和我們的時間,我們建議你准備以下信息 -
運行時的文件版本
每個版本都會有新的GC變化,所以我們很自然地想知道你使用的是哪個版本的運行時,以便我們知道該版本的運行時有哪些GC變化。所以提供這些信息是非常必要的。版本如何映射到 "公共名稱",如.NET 4.7,是不容易追蹤的,所以提供dll的 "FileVersion "屬性會對我們有很大幫助,它可以告訴我們版本號與分支名稱(對於.NET Framework)或實際提交(對於.NET Core)。你可以通過像這樣的powerhell命令來獲得這些信息:
PS C:\Windows\Microsoft.NET\Framework64\v4.0.30319> (Get-Item C:\Windows\Microsoft.NET\Framework64\v4.0.30319\clr.dll).VersionInfo.FileVersion
4.8.4250.0 built by: NET48REL1LAST_C
PS C:\> (Get-Item C:\temp\coreclr.dll).VersionInfo.FileVersion
42,42,42,42424 @Commit: a545d13cef55534995115eb5f761fd0cecf66fc1
獲得這些信息的另一個方法是通過調試器通過lmvm命令(部分省略)-
0:000> lmvm coreclr
Browse full module list
start end module name
00007ff8`f1ec0000 00007ff8`f4397000 CoreCLR (deferred)
Image path: C:\runtime-reg\artifacts\tests\coreclr\windows.x64.Debug\Tests\Core_Root\CoreCLR.dll
Image name: CoreCLR.dll
Information from resource tables:
FileVersion: 42,42,42,42424 @Commit: a545d13cef55534995115eb5f761fd0cecf66fc1
如果你捕捉到ETW跟蹤,你也可以找到KernelTraceControl/ImageID/FileVersion
事件。它看起來像這樣(部分省略)。
ThreadID="-1" ProcessorNumber="2" ImageSize="10,412,032" TimeDateStamp="1,565,068,727" BuildTime="8/5/2019 10:18:47 PM" OrigFileName="clr.dll" FileVersion="4.7.3468.0 built by: NET472REL1LAST_C"
你已經進行了哪些診斷
如果你已經按照本文件中的技術,自己做了一些診斷,這是強烈建議的,請與我們分享你做了什么,得出了什么結論。這不僅可以節省我們的工作,還可以告訴我們我們提供的信息對你的診斷有什么幫助或沒有幫助,這樣我們就可以對我們提供給客戶的信息進行調整,使他們的生活更輕松。
性能數據
就像任何性能問題一樣,在沒有任何性能跟蹤數據的情況下,我們真的只能給出一些一般性的指導和建議。要真正找出問題所在,我們需要性能跟蹤數據。
正如本文檔中多次提到的,性能跟蹤是我們調試性能問題的主要方法,除非你已經進行了診斷,表明不需要頂級GC跟蹤,否則我們總是要求你收集這樣的跟蹤來開始。我通常也會要求你提供帶有CPU樣本的追蹤,特別是當我們要診斷長時間的GC暫停時。我們可能會要求你根據我們從最初的追蹤中得到的線索,收集更多的追蹤信息。
一般來說,轉儲不太適合調查性能問題。 但是,我們了解有時可能無法獲得跟蹤,而您所擁有的只是轉儲(dump)。 如果情況確實如此,請盡可能與我們分享(即,在沒有隱私問題的情況下,因為dump可能會泄漏源碼和內存數據)。
另外插播一個小廣告
[蘇州-同程旅行] - .NET后端研發工程師
招聘中級及以上工程師,優秀應屆生也可以,我會全程跟進,從職位匹配,到面試建議與准備,再到面試流程和每輪面試的結果等。大家可以直接發簡歷給我。
工作職責
負責全球前三中文在線旅游平台機票業務系統的研發工作,根據需求進行技術文檔編寫和編碼工作
任職要求
- 擁有至少1年以上的工作經驗,優秀的候選人可放寬
- 熟悉.NET Core和ASP.Net Core
- C#基礎扎實,了解CLR原理,包括多線程、GC等
- 有DDD 微服務拆分 重構經驗者優先
- 能對線上常見的性能問題進行診斷和處理
- 熟悉Mysql Redis MongoDB等數據庫中間件,並且進行調優
- 必須有扎實的計算機基礎知識,熟悉常用的數據結構與算法,並能在日常研發中靈活使用
- 熟悉分布式系統的設計和開發,包括但不限於緩存、消息隊列、RPC及一致性保證等技術
- 海量HC 歡迎投遞~
薪資福利
- 月薪:15K~30K 根據職級不同有所不同
- 年假:10天帶薪年假 春節提前1天放假 病假有補貼
- 年終:根據職級不同有 2-4 個月
- 餐補:有餐補,自有食堂
- 交通:有打車報銷
- 五險一金:基礎五險一金,12%的公積金、補充醫療、租房補貼等
- 節日福利:端午、中秋、春節有節日禮盒
- 通訊補貼:根據職級不同,每個月有話費補貼 50~400
簡歷投遞方式
大家把簡歷發到我郵箱即可,記得一定要附上聯系(微信 or 手機號)方式喲~
郵箱(這是啥格式大家都懂):aW5jZXJyeUBmb3htYWlsLmNvbQ==