.NET8頂級除錯lldb觀察FOH堆字串分配

江湖評談發表於2023-12-08

前言

好久沒有動用LLDB了,本篇透過它來看下FOH也就是.NET8裡面最佳化字串,為了提高其效能增加的FOH堆分配過程。關於FOH可以參考:.NET8極致效能最佳化Non-GC Heap

詳細

來看一個簡單的例子:

public static string GetPrefix() => "https://";
static void Main(string[] args)
{
   GetPrefix();
   GC.Collect();
   Console.ReadLine();
}

函式GetPrefix裡面的字串“https://”就是被分配到FOH堆裡面的,如何驗證呢?

首先透過LLDB把CLR執行到託管Main入口

(lldb) b RunMainInternal
Breakpoint 7: where = libcoreclr.so`RunMainInternal(Param*) at assembly.cpp:1257, address = 0x00007ffff6d43930
(lldb) r
Process 2697 launched: '/home/tang/opt/dotnet/debug_clr/clrrun' (x86_64)
Process 2697 stopped
* thread #1, name = 'clrrun', stop reason = breakpoint 6.1 7.1
    frame #0: 0x00007ffff6d43930 libcoreclr.so`RunMainInternal(pParam=0x00007ffff7faaab6) at assembly.cpp:1257
   1254  } param;
   1255  
   1256  static void RunMainInternal(Param* pParam)
-> 1257  {
   1258      MethodDescCallSite  threadStart(pParam->pFD);
   1259  
   1260      PTRARRAYREF StrArgArray = NULL;
(lldb) 

然後把其執行到JIT前置入口

(lldb) b PreStubWorker
Breakpoint 8: where = libcoreclr.so`::PreStubWorker(TransitionBlock *, MethodDesc *) at prestub.cpp:1865, address = 0x00007ffff6ee6c10
(lldb) c
Process 2697 resuming
Process 2697 stopped
* thread #1, name = 'clrrun', stop reason = breakpoint 8.1
    frame #0: 0x00007ffff6ee6c10 libcoreclr.so`::PreStubWorker(pTransitionBlock=0x00000000ffffcb38, pMD=0x0000000155608c70) at prestub.cpp:1865
   1862  // returns a pointer to the new code for the prestub's convenience.
   1863  //=============================================================================
   1864  extern "C" PCODE STDCALL PreStubWorker(TransitionBlock* pTransitionBlock, MethodDesc* pMD)
-> 1865  {
   1866      PCODE pbRetVal = NULL;
   1867  
   1868      BEGIN_PRESERVE_LAST_ERROR;

此時可以看下當前JIT編譯的函式是誰,這裡需要先n命令單步一下

(lldb) n
Process 2697 stopped
* thread #1, name = 'clrrun', stop reason = step over
    frame #0: 0x00007ffff6ee6c36 libcoreclr.so`::PreStubWorker(pTransitionBlock=0x00007fffffffc648, pMD=0x00007fff78f56b70) at prestub.cpp:1866:11
   1863  //=============================================================================
   1864  extern "C" PCODE STDCALL PreStubWorker(TransitionBlock* pTransitionBlock, MethodDesc* pMD)
   1865  {
-> 1866      PCODE pbRetVal = NULL;
   1867  
   1868      BEGIN_PRESERVE_LAST_ERROR;

然後透過微軟提供的sos.dll Dump下當前的函式描述結構體MethodDesc,pMD是傳過來的函式引數,也即是MethodDesc的變數

(lldb) sos dumpmd pMD
Method Name:          ConsoleApp1.Test+Program.Main(System.String[])
Class:                00007fff78f97530
MethodTable:          00007fff78f56c08
mdToken:              0000000006000008
Module:               00007fff78f542d0
IsJitted:             no
Current CodeAddr:     ffffffffffffffff
Version History:
  ILCodeVersion:      0000000000000000
  ReJIT ID:           0
  IL Addr:            00007ffff7faa2aa
     CodeAddr:           0000000000000000  (MinOptJitted)
     NativeCodeVersion:  0000000000000000

可以清晰的看到Method Name就是ConsoleApp1.Test+Program.Main,OK這一步確定了,我們下面繼續尋找字串分配到FOH,首先刪掉前面所有的斷點

(lldb) br del
About to delete all breakpoints, do you want to do that?: [Y/n] y
All breakpoints removed. (3 breakpoints)

在TryAllocateObject 函式上下斷,它是分配託管記憶體的函式

(lldb) b TryAllocateObject
Breakpoint 9: 2 locations.

執行到此處

(lldb) c
Process 2697 resuming
Process 2697 stopped
* thread #1, name = 'clrrun', stop reason = breakpoint 9.1
    frame #0: 0x00007ffff70300c0 libcoreclr.so`FrozenObjectHeapManager::TryAllocateObject(this=0x00007fffffff80b8, type=0x00000008017fa948, objectSize=140737488322759, publish=false) at frozenobjectheap.cpp:22
   19    // May return nullptr if object is too large (larger than FOH_COMMIT_SIZE)
   20    // in such cases caller is responsible to find a more appropriate heap to allocate it
   21    Object* FrozenObjectHeapManager::TryAllocateObject(PTR_MethodTable type, size_t objectSize, bool publish)
-> 22    {
   23        CONTRACTL
   24        {
   25            THROWS;

這個函式就是字串分配到FOH堆的地方,透過它的函式所在的類名即可看出FrozenObjectHeapManager,但是我們依然還是需要驗證下。繼續n單步這個函式的返回的地方,也就是如下程式碼:

 202
-> 203       return object;

此時的這個object變數就是示例裡面字串的“https://”的物件地址,看下它的地址值

(lldb) p/x object
(Object *) $14 = 0x00007fffe6bff8c0

記住這個值:0x00007fffe6bff8c0,後面會把它和GC堆的範圍進行一個比較。如果它不在GC堆範圍,說明.NET8的字串確實分配在了FOH堆裡面。

我們繼續單步向下,執行到這個物件被賦值字串的地方

STRINGREF AllocateStringObject(EEStringData *pStringData, bool preferFrozenObjHeap, bool* pIsFrozen)
{
    //此處省略
   memcpyNoGCRefs(strDest, pStringData->GetStringBuffer(), cCount*sizeof(WCHAR));

    //此處省略
}

然後看下它的記憶體:

(lldb) memory re 0x00007fffe6bff8c0
0x7fffe6bff8c0: 68 00 74 00 74 00 70 00 73 00 3a 00 2f 00 2f 00  h.t.t.p.s.:././.
0x7fffe6bff8dc: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00

它確實是https字串的物件地址。沒有問題。
避免干擾,此時我們再次刪除所有斷點

(lldb) br del
About to delete all breakpoints, do you want to do that?: [Y/n] y
All breakpoints removed. (1 breakpoint)

然後在函式is_in_find_object_range處下斷,它是在GC回收的時候,判斷當前的物件地址是否在GC堆裡面,如果是則進行物件標記,如果不是直接返回。可以透過這個獲取GC堆的範圍,執行到此處

(lldb) b is_in_find_object_range
(lldb) c
Process 2697 resuming
Process 2697 stopped
* thread #1, name = 'clrrun', stop reason = breakpoint 13.8
    frame #0: 0x00007ffff72d881d libcoreclr.so`WKS::GCHeap::Promote(Object**, ScanContext*, unsigned int) [inlined] WKS::gc_heap::is_in_find_object_range(o=0x0000000000000000) at gc.cpp:7906:11
   7903  inline
   7904  bool gc_heap::is_in_find_object_range (uint8_t* o)
   7905  {
-> 7906      if (o == nullptr)
   7907      {
   7908          return false;
   7909      }

單步n

(lldb) n
Process 2697 stopped
* thread #1, name = 'clrrun', stop reason = step over
    frame #0: 0x00007ffff72d8831 libcoreclr.so`WKS::GCHeap::Promote(Object**, ScanContext*, unsigned int) [inlined] WKS::gc_heap::is_in_find_object_range(o="@\x9b\xd6x\xff\U0000007f") at gc.cpp:7911:14
   7908          return false;
   7909      }
   7910  #if defined(USE_REGIONS) && defined(FEATURE_CONSERVATIVE_GC)
-> 7911      return ((o >= g_gc_lowest_address) && (o < bookkeeping_covered_committed));
   7912  #else //USE_REGIONS && FEATURE_CONSERVATIVE_GC
   7913      if ((o >= g_gc_lowest_address) && (o < g_gc_highest_address))
   7914      {

注意,此時我們看到了GC堆的一個範圍,也就是變數g_gc_lowest_address和變數g_gc_highest_address,看下它們的地址範圍

(lldb) p/x g_gc_lowest_address 
(uint8_t *) $17 = 0x00007fbf68000000 ""
(lldb) p/x g_gc_highest_address
(uint8_t *) $18 = 0x00007fff68000000 "0"

上面很明顯了,GC堆的範圍起始地址:0x00007fbf68000000 ,結束地址:0x00007fff68000000 。而字串“https://”的物件地址是0x00007fffe6bff8c0,很明顯它不在GC堆的範圍內。

以上透過分配一個字串到FOH堆,後呼叫一個GC.Collect()檢視GC堆的範圍,對FOH物件地址和GC堆範圍進行一個判斷,為一個非常簡單的FOH字串分配驗證。

歡迎加入C#12/.NET8最新技術交流群

結尾

作者:jianghupt
原文:.NET8頂級除錯lldb觀察FOH堆字串分配
公眾號:jianghupt,文章首發,歡迎關注

相關文章