PerfView專題 (第十六篇): 如何洞察C#託管堆記憶體的 "黑洞現象"

2023-07-24 12:00:41

一:背景

1. 講故事

首先宣告的是這個 黑洞 是我定義的術語,它是用來表示 記憶體吞噬 的一種現象,何為 記憶體吞噬,我們來看一張圖。

從上面的 卦象圖 來看,GCHeap 的 Allocated=852MCommitted=16.6G,它們的差值就是 分配緩衝區=16G,緩衝區的好處就是用空間換時間,弊端就是會實實在在的侵佔記憶體,擠壓其他程式的生存空間。

二:黑洞現象

1. 為什麼會有黑洞現象

萬事皆有因果,今生的是前世種的,換句話說是程式曾經有大量及頻繁的建立臨時物件,讓GC不自主的痙攣,小攣傷神,大攣傷身,所以GC為了避免大攣的發生,就大量的囤積本應該釋放掉的記憶體,目的就是防止未來某個時刻再次有大記憶體分配的發生。

2. 重現今生的果

我相信因果關係大家都弄清楚了,但口說無憑,還得用程式碼證明一下不是?為了模擬GC痙攣,上一段測試程式碼。


    public class Program
    {
        public static void Main(string[] args)
        {
            var builder = WebApplication.CreateBuilder(args);

            // Add services to the container.
            builder.Services.AddAuthorization();
            var app = builder.Build();

            // Configure the HTTP request pipeline.
            app.UseAuthorization();

            app.MapGet("/mytest", (HttpContext httpContext) =>
            {
                return MyTest();
            });

            app.MapGet("/gc", (HttpContext httpContext) =>
            {
                GC.Collect();

                return 1;
            });

            app.Run();
        }

        public static string MyTest()
        {
            List<string> list = new List<string>();

            for (int i = 0; i < 100000000; i++)
            {
                list.Add(i.ToString());
            }

            return "ok";
        }
    }

程式碼非常簡單,每請求一次 /mytest 都會分配一個 1億 大小 List<string> 陣列,而這個 List<string> 又是一個臨時物件,後續會被 GC 回收,接下來我們多請求幾次來調戲一下 GC,看他如何痙攣,截圖如下:

從卦中看,我當前請求了 6 次,記憶體峰值達到了 12G,因為是臨時物件,稍稍有一點回落,但此時已經撐成一個大胖子了,接下來我們用 WinDbg 附加一下,觀察下 Allocated 和 Committed 閾值。


0:033> !eeheap -gc

========================================
Number of GC Heaps: 12
----------------------------------------
...
Heap 11 (0000023513f26c10)
generation 0 starts at 23351c3aab8
generation 1 starts at 233484c38e0
generation 2 starts at 233484c1000
ephemeral segment allocation context: none
Small object heap
         segment            begin        allocated        committed allocated size          committed size         
    0233484c0000     0233484c1000     02335c794ad0     023379ad2000 0x142d3ad0 (338508496)  0x31612000 (828448768) 
Large object heap starts at 234384c1000
         segment            begin        allocated        committed allocated size          committed size         
    0234384c0000     0234384c1000     0234384c1018     0234384e2000 0x18 (24)               0x22000 (139264)       
Pinned object heap starts at 234f84c1000
         segment            begin        allocated        committed allocated size          committed size         
    0234f84c0000     0234f84c1000     0234f84c1018     0234f84c2000 0x18 (24)               0x2000 (8192)          
------------------------------
GC Allocated Heap Size:    Size: 0x14f241378 (5622731640) bytes.
GC Committed Heap Size:    Size: 0x2b125c000 (11561975808) bytes.

從卦中看當前已經有 6G 的緩衝區了,為了讓緩衝區更誇張,我們故意手工觸發一次 GC 即請求 /gc,觸發了GC之後,記憶體從 10G 回落到了 7G 就不再降了,截圖如下:

從卦中看,這兩個指標就更誇張了,GC 堆只有 1.1M 的物件,但預留了 7.1G 的記憶體。

這個GC表現不管在 道德 還是 倫理 上都說不通的。

3. 找到前世的因

要想找到前世的因,手段有很多,比如用 WinDbg 觀察前世的託管堆,從殘留的 Committed - Allocated上就能找到因,也可以使用 PerfView 實時觀察,這裡我們採用後者來洞察,使用預設的 Command 引數。


PerfView.exe  "/DataFile:PerfViewData.etl" /BufferSizeMB:256 /StackCompression /CircularMB:500 /ClrEvents:GC,Binder,Security,AppDomainResourceManagement,Contention,Exception,Threading,JITSymbols,Type,GCHeapSurvivalAndMovement,GCHeapAndTypeNames,Stack,ThreadTransfer,Codesymbols,Compilation /NoGui /NoNGenRundown /Merge:True /Zip:True collect

採集一段時間後停止採集,接下來雙擊 GC Heap Net Mem (Coarse Sampling) Stacks 選項再選擇 WebApplication1 程序,通過 MaxMetric 指標看到曾經峰值達到了 10.9G,截圖如下:

毫無疑問的說,記憶體峰值的時候必有妖怪,可以將峰值填入到 End 文字方塊中,然後雙擊記憶體佔比最高的 System.String[],觀察下它是誰分配的,截圖如下:

從截圖中可以清晰的看到,原來是 Program.MyTest() 造的孽,至此真相大白。

4. 尋求化解之道

化解之道有很多:

  • 修改 GC 模式

簡而言之就是將 Server GC 改成 Workstation GC ,參考程式碼如下:


<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <ServerGarbageCollection>false</ServerGarbageCollection>
  </PropertyGroup>

</Project>

  • 修改 Heap 個數

預設情況一個 cpucore 有一個 heap,我們可以儘量的減少 heap.count 的個數,比如將 12 個改成 2 個。參考程式碼如下:


{
   "runtimeOptions": {
      "configProperties": {
         "System.GC.HeapCount": 2
      }
   }
}

  • 大事化小

導致今世的 是因為在記憶體中短時的出現大物件,可以將大物件拆分成多批次的小物件處理,這樣可以達到後浪推前浪的的記憶體複用,從源頭上繞過這個問題。

三:總結

記憶體黑洞 雖不算 CLR 的一個bug,但絕對是 CLR 可優化的一個空間,分析這類問題是需要經驗性的,分享出來供後來者少踩坑吧,畢竟在我的分析旅程中至少遇到了3次