聊一聊 C# 後臺GC 到底是怎麼回事?

2022-07-19 09:00:45

一:背景

寫這一篇的目的主要是因為.NET領域內幾本關於闡述GC方面的書,都是純理論,所以懂得人自然懂,不懂得人也沒法親自驗證,這一篇我就用 windbg + 原始碼 讓大家眼見為實。

二:為什麼要引入後臺GC

1. 後臺GC到底解決了什麼問題

解決什麼問題得先說有什麼問題,我們知道 阻塞版GC 有一個顯著得特點就是,在 GC 觸發期間,所有的使用者執行緒都被 暫停了,這裡的 暫停 是一個統稱,畫圖如下:

這種 STW(Stop The World) 模式相信大家都習以為常了,但這裡有一個很大的問題,不管當前 GC 是臨時代還是全量,還是壓縮或者標記,all in 全凍結,這種簡單粗暴的做法肯定是不可取的,也是 後臺GC 引入的先決條件。

那 後臺GC 到底解決了什麼問題?

解決在 FullGC 模式下的 標記清除 回收期間,放飛使用者執行緒。

雖然這是一個很好的 Idea,但複雜度絕對上了幾個檔次。

三:後臺GC 詳解

1. 後臺 GC程式碼 骨架圖

原始碼面前,了無祕密,在coreclr 專案的 garbage-collection.md 檔案中,描述了 後臺GC 的程式碼流程圖。


     GarbageCollectGeneration()
     {
         SuspendEE();
         garbage_collect();
         RestartEE();
     }
     
     garbage_collect()
     {
         generation_to_condemn();
         // decide to do a background GC
         // wake up the background GC thread to do the work
         do_background_gc();
     }
     
     do_background_gc()
     {
         init_background_gc();
         start_c_gc ();
     
         //wait until restarted by the BGC.
         wait_to_proceed();
     }
     
     bgc_thread_function()
     {
         while (1)
         {
             // wait on an event
             // wake up
             gc1();
         }
     }
     
     gc1()
     {
         background_mark_phase();
         background_sweep();
     }

可以清楚的看到就是在做 標記清除 且核心邏輯都在 background_mark_phase() 函數中,實現了標記的三個階段: 1.初始標記2.並行標記3.最終標記 , 其中 並行標記 階段,使用者執行緒是正常執行的,實現了將原來整個暫停 優化到了 2個小暫停。

2. 流程圖分析

為了方便說明,將三階段畫個圖如下:

特別宣告:階段2的重啟是在 background_sweep() 方法中,而不是 最終標記(background_mark_phase) 階段。

  1. 初始標記

這個階段使用者執行緒處於暫停狀態,bgc 要做的事情就是從 執行緒棧終端子佇列 中尋找使用者根實現參照圖遍歷,然後再讓所有使用者執行緒啟動,簡化後的程式碼如下:


void gc_heap::background_mark_phase()
{
	dprintf(3, ("BGC: stack marking"));
	GCScan::GcScanRoots(background_promote_callback,
		max_generation, max_generation,
		&sc);

	dprintf(3, ("BGC: finalization marking"));
	finalize_queue->GcScanRoots(background_promote_callback, heap_number, 0);

	restart_vm();
}

接下來怎麼驗證 階段1 是暫停狀態呢? 為了方便講述,先上一段測試程式碼:


    internal class Program
    {
        static List<string> list = new List<string>();

        static void Main(string[] args)
        {
            Debugger.Break();
            for (int i = 0; i < int.MaxValue; i++)
            {
                list.Add(String.Join(",", Enumerable.Range(0, 100)));

                if (i % 10 == 0) list.RemoveAt(0);
            }
        }
    }

然後用 windbg 在 background_mark_phase 函數下一個斷點:bp coreclr!WKS::gc_heap::background_mark_phase 即可。


0:009> bp coreclr!WKS::gc_heap::background_mark_phase
0:009> g
Breakpoint 1 hit
coreclr!WKS::gc_heap::background_mark_phase:
00007ff9`e7bf73f4 488bc4          mov     rax,rsp
0:008> !t -special
                                                                                                            Lock  
 DBG   ID     OSID ThreadOBJ           State GC Mode     GC Alloc Context                  Domain           Count Apt Exception
   0    1     55d8 00000000006336B0    2a020 Preemptive  0000000000000000:0000000000000000 000000000062d650 -00001 MTA (GC) 
   6    2     568c 0000000000662F40    21220 Preemptive  0000000000000000:0000000000000000 000000000062d650 -00001 Ukn (Finalizer) 
   8    4     5730 0000000000676A90    21220 Preemptive  0000000000000000:0000000000000000 000000000062d650 -00001 Ukn 

          OSID Special thread type
        0 55d8 SuspendEE 
        5 5688 DbgHelper 
        6 568c Finalizer 
        8 5730 GC 

可以清楚的看到,0號執行緒顯示了 SuspendEE 字樣,表示此時所有託管執行緒處於凍結狀態。

  1. 並行標記

這個階段就是各玩各的,使用者執行緒在正常執行,bgc在後臺進一步標記,因為是並行,所以存在 bgc 已標記好的物件參照關係被 使用者執行緒 破壞,所以 bgc 用 reset_write_watch 函數借助 windows 的記憶體頁監控,目的就是把那些髒頁找出來,在下一個階段來修正,簡化後的程式碼如下:


void gc_heap::background_mark_phase()
{
	disable_preemptive(true);
	
    //髒頁監控
	reset_write_watch(TRUE);
	revisit_written_pages(TRUE, TRUE);

	dprintf(3, ("BGC: handle table marking"));
	GCScan::GcScanHandles(background_promote,
		max_generation, max_generation,
		&sc);
	
    disable_preemptive(false);
}

要想驗證此時的使用者執行緒是放飛的,可以在 revisit_written_pages 函數下一個斷點即可,使用命令:bp coreclr!WKS::gc_heap::revisit_written_pages


0:008> bp coreclr!WKS::gc_heap::revisit_written_pages
0:008> g
coreclr!WKS::gc_heap::revisit_written_pages:
0:008> !t -special
                                                                                                            Lock  
 DBG   ID     OSID ThreadOBJ           State GC Mode     GC Alloc Context                  Domain           Count Apt Exception
   0    1     55d8 00000000006336B0    2a020 Cooperative 000000000D1FD920:000000000D1FE120 000000000062d650 -00001 MTA 
   6    2     568c 0000000000662F40    21220 Preemptive  0000000000000000:0000000000000000 000000000062d650 -00001 Ukn (Finalizer) 
   8    4     5730 0000000000676A90    21220 Cooperative 0000000000000000:0000000000000000 000000000062d650 -00001 Ukn 

          OSID Special thread type
        5 5688 DbgHelper 
        6 568c Finalizer 
        8 5730 GC 


看到沒有,那個 SuspendEE 神奇的消失了,而且 0 號執行緒的 GC 模式也改成了 Cooperative,表示可允許操控 託管堆。

  1. 最終標記

等 bgc 在後臺做的差不多了,就可以再來一次 SupendEE,將 並行標記 期間由使用者執行緒造成的髒參照進行最終一次修正,修正的資料來源就是監控到的 Windows髒頁,程式碼就不上了,我們聊下怎麼去驗證階段二又回到了 SuspendEE 狀態?可以在 background_sweep() 函數下一個斷點, 命令: bp coreclr!WKS::gc_heap::background_sweep


0:000> bp coreclr!WKS::gc_heap::background_sweep
0:000> g
coreclr!WKS::gc_heap::background_sweep:
00007ff9`e7b7a2e0 4053            push    rbx
0:008> !t -special
                                                                                                            Lock  
 DBG   ID     OSID ThreadOBJ           State GC Mode     GC Alloc Context                  Domain           Count Apt Exception
   0    1     55d8 00000000006336B0    2a020 Preemptive  0000000000000000:0000000000000000 000000000062d650 -00001 MTA 
   6    2     568c 0000000000662F40    21220 Preemptive  0000000000000000:0000000000000000 000000000062d650 -00001 Ukn (Finalizer) 
   8    4     5730 0000000000676A90    21220 Preemptive  0000000000000000:0000000000000000 000000000062d650 -00001 Ukn (GC) 

          OSID Special thread type
        5 5688 DbgHelper 
        6 568c Finalizer 
        8 5730 GC SuspendEE 

哈哈,可以看到那個 SuspendEE 又回來了。

3. 後臺GC 只會在 fullGC 模式下嗎?

這是最後一個要讓大家眼見為實的問題,在gc觸發期間,內部會維護一個 gc_mechanisms 結構體,其中就記錄了當前 GC 觸發的種種資訊,可以用 windbg 把它匯出來看看便知。


0:008> x coreclr!*settings*
00007ff9`e7f82e90 coreclr!WKS::gc_heap::settings = class WKS::gc_mechanisms
0:008> dt coreclr!WKS::gc_heap::settings 00007ff9`e7f82e90
   +0x000 gc_index         : 0xb3
   +0x008 condemned_generation : 0n2
   +0x00c promotion        : 0n1
   +0x010 compaction       : 0n0
   +0x014 loh_compaction   : 0n0
   +0x018 heap_expansion   : 0n0
   +0x01c concurrent       : 1
   +0x020 demotion         : 0n0
   +0x024 card_bundles     : 0n1
   +0x028 gen0_reduction_count : 0n0
   +0x02c should_lock_elevation : 0n0
   +0x030 elevation_locked_count : 0n0
   +0x034 elevation_reduced : 0n0
   +0x038 minimal_gc       : 0n0
   +0x03c reason           : 0 ( reason_alloc_soh )
   +0x040 pause_mode       : 1 ( pause_interactive )
   +0x044 found_finalizers : 0n1
   +0x048 background_p     : 0n0
   +0x04c b_state          : 0 ( bgc_not_in_process )
   +0x050 allocations_allowed : 0n1
   +0x054 stress_induced   : 0n0
   +0x058 entry_memory_load : 0x49
   +0x060 entry_available_physical_mem : 0x00000001`0a50d000
   +0x068 exit_memory_load : 0

condemned_generation=2 可知當前觸發的是 2 代GC,原因是代滿了 reason : 0 ( reason_alloc_soh )

四:總結

看的再多還不如實操一遍,如果覺得手工編譯 coreclr 原始碼麻煩,可以考慮下 windbg,好了,本篇就聊這麼多,希望對你有幫助。

圖片名稱