CoreCLR源码探索(三) GC内存分配器的内部实现
在前一篇中我講解了new是怎么工作的, 但是卻一筆跳過了內存分配相關的部分.
在這一篇中我將詳細講解GC內存分配器的內部實現.
在看這一篇之前請必須先看完微軟BOTR文檔中的"Garbage Collection Design",
原文地址是: https://github.com/dotnet/coreclr/blob/master/Documentation/botr/garbage-collection.md
譯文可以看知平軟件的譯文或我后來的譯文
請務必先看完"Garbage Collection Design", 否則以下內容你很可能會無法理解
服務器GC和工作站GC
關于服務器GC和工作站GC的區別, 網上已經有很多資料講解這篇就不再說明了.
我們來看服務器GC和工作站GC的代碼是怎么區別開來的.
默認編譯CoreCLR會對同一份代碼以使用服務器GC還是工作站GC的區別編譯兩次, 分別在SVR和WKS命名空間中:
源代碼: https://github.com/dotnet/coreclr/blob/release/1.1.0/src/gc/gcsvr.cpp
namespace SVR { }源代碼: https://github.com/dotnet/coreclr/blob/release/1.1.0/src/gc/gcwks.cpp
namespace WKS { }當定義了SERVER_GC時, MULTIPLE_HEAPS和會被同時定義.
定義了MULTIPLE_HEAPS會使用多個堆(Heap), 服務器GC每個cpu核心都會對應一個堆(默認), 工作站GC則全局使用同一個堆.
源代碼: https://github.com/dotnet/coreclr/blob/release/1.1.0/src/gc/gcimpl.h
后臺GC無論是服務器GC還是工作站GC都會默認支持, 但運行時不一定會啟用.
源代碼: https://github.com/dotnet/coreclr/blob/release/1.1.0/src/gc/gcpriv.h
我們從https://www.microsoft.com/net下回來的CoreCLR安裝包中已經包含了服務器GC和后臺GC的支持,但默認不會開啟.
開啟它們可以修改project.json中的·runtimeOptions·節, 例子如下:
設置后發布項目可以看到coreapp.runtimeconfig.json, 運行時會只看這個文件.
微軟官方的文檔: https://docs.microsoft.com/en-us/dotnet/articles/core/tools/project-json
GC相關的類和它們的關系
我先用兩張圖來解釋服務器GC和工作站GC下GC相關的類的關系
圖中一共有5個類型
GCHeap
實現了IGCHeap接口, 公開GC層的接口給EE(運行引擎)層調用
在工作站GC下只有一個實例, 不會關聯gc_heap對象, 因為工作站GC下gc_heap的所有成員都會被定義為靜態變量
在服務器GC下有1+cpu核心數個實例(默認), 第一個實例用于當接口, 其它對應cpu核心的實例都會各關聯一個gc_heap實例
gc_heap
內部的使用的堆類型, 用于負責內存的分配和回收
在工作站GC下無實例, 所有成員都會定義為靜態變量
在工作站GC下generation_table這個成員不會被定義, 而是使用全局變量generation_table
在服務器GC下有cpu核心數個實例(默認), 各關聯一個GCHeap實例
generation
儲存各個代的信息, 例如地址范圍和使用的段
儲存在generation_table中, 一個generation_table包含了5個generation, 前面的是0 1 2 3代, 最后一個不會被初始化和使用
在工作站GC下只有1個generation_table, 就是全局變量generation_table
在服務器GC下generation_table是gc_heap的成員, 有多少個gc_heap就有多少個generation_table
heap_segment
堆段, 供分配器使用的一段內存, 用鏈表形式保存
每個gc_heap中都有一個或一個以上的segment
每個gc_heap中都有一個ephemeral heap segment(用于存放最年輕對象)
每個gc_heap中都有一個large heap segment(用于存放大對象)
在工作站GC下segment的默認大小是256M(0x10000000字節)
在服務器GC下segment的默認大小是4G(0x100000000字節)
alloc_context
分配上下文, 指向segment中的一個范圍, 用于實際分配對象
每個線程都有自己的分配上下文, 因為指向的范圍不一樣所以只要當前范圍還有足夠空間, 分配對象時不需要線程鎖
分配上下文的默認范圍是8K, 也叫分配單位(Allocation Quantum)
分配小對象時會從這8K中分配, 分配大對象時則會直接從段(segment)中分配
代0(gen 0)還有一個默認的分配上下文供內部使用, 和線程無關
GCHeap的源代碼摘要:
GCHeap的定義: https://github.com/dotnet/coreclr/blob/release/1.1.0/src/gc/gcimpl.h#L61
全局的GCHeap實例: https://github.com/dotnet/coreclr/blob/release/1.1.0/src/gc/gc.h#L105
這里是1.1.0的代碼, 1.2.0全局GCHeap會分別保存到gcheaputilities.h(g_pGCHeap)和gc.cpp(g_theGCHeap), 兩處地方都指向同一個實例.
// 相當于extern GCHeap* g_pGCHeap;GPTR_DECL(GCHeap, g_pGCHeap);gc_heap的源代碼摘要:
gc_heap的定義: https://github.com/dotnet/coreclr/blob/release/1.1.0/src/gc/gcpriv.h#L1079
這個類有300多個成員(從ephemeral_low開始),
generation的源代碼摘要:
generation的定義: https://github.com/dotnet/coreclr/blob/release/1.1.0/src/gc/gcpriv.h#L754
這里我只列出這篇文章涉及到的成員
heap_segment的源代碼摘要:
heap_segment的定義: https://github.com/dotnet/coreclr/blob/release/1.1.0/src/gc/gcpriv.h#L4166
這里我只列出這篇文章涉及到的成員
alloc_context的源代碼摘要:
alloc_context的定義: https://github.com/dotnet/coreclr/blob/release/1.1.0/src/gc/gc.h#L162
這里是1.1.0的代碼, 1.2.0這些成員移動到了gcinterface.h的gc_alloc_context, 但是成員還是一樣的
堆段的物理結構
為了更好理解下面即將講解的代碼,請先看這兩張圖片
分配對象內存的代碼流程
還記得上篇我提到過的AllocateObject函數嗎? 這個函數由JIT_New調用, 負責分配一個普通的對象.
讓我們來繼續跟蹤這個函數的內部吧:
AllocateObject函數的內容: https://github.com/dotnet/coreclr/blob/release/1.1.0/src/vm/gchelpers.cpp#L931
AllocateObject的其他版本同樣也會調用AllocAlign8或Alloc函數, 下面就不再貼出其他版本的函數代碼了.
Alloc函數的內容: https://github.com/dotnet/coreclr/blob/release/1.1.0/src/vm/gchelpers.cpp#L931
GetGCHeap函數的內容: https://github.com/dotnet/coreclr/blob/release/1.1.0/src/gc/gc.h#L377
static GCHeap *GetGCHeap(){LIMITED_METHOD_CONTRACT; ? ?// 返回全局的GCHeap實例// 注意這個實例只作為接口使用,不和具體的gc_heap實例關聯_ASSERTE(g_pGCHeap != NULL); ? ?return g_pGCHeap; }GetThreadAllocContext函數的內容: https://github.com/dotnet/coreclr/blob/release/1.1.0/src/vm/gchelpers.cpp#L54
inline alloc_context* GetThreadAllocContext(){WRAPPER_NO_CONTRACT;assert(GCHeap::UseAllocationContexts()); ??// 獲取當前線程并返回m_alloc_context成員的地址return & GetThread()->m_alloc_context; }
GCHeap::Alloc函數的內容: https://raw.githubusercontent.com/dotnet/coreclr/release/1.1.0/src/gc/gc.cpp
分配小對象內存的代碼流程
讓我們來看一下小對象的內存是如何分配的
allocate函數的內容: https://raw.githubusercontent.com/dotnet/coreclr/release/1.1.0/src/gc/gc.cpp
這個函數嘗試從分配上下文分配內存, 失敗時調用allocate_more_space為分配上下文指定新的空間
這里的前半部分的處理還有匯編版本, 可以看上一篇分析的JIT_TrialAllocSFastMP_InlineGetThread函數
allocate_more_space函數的內容: https://raw.githubusercontent.com/dotnet/coreclr/release/1.1.0/src/gc/gc.cpp
這個函數會在有多個heap時調用balance_heaps平衡各個heap的使用量, 然后再調用try_allocate_more_space函數
try_allocate_more_space函數的內容: https://raw.githubusercontent.com/dotnet/coreclr/release/1.1.0/src/gc/gc.cpp
這個函數會獲取MSL鎖, 檢查是否有必要觸發GC, 然后根據gen_number參數調用allocate_small或allocate_large函數
allocate_small函數的內容: https://raw.githubusercontent.com/dotnet/coreclr/release/1.1.0/src/gc/gc.cpp
循環嘗試進行各種回收內存的處理和調用soh_try_fit函數, soh_try_fit函數分配成功或手段已經用盡時跳出循環
soh_try_fit函數的內容: https://raw.githubusercontent.com/dotnet/coreclr/release/1.1.0/src/gc/gc.cpp
這個函數會先嘗試調用a_fit_free_list_p從自由對象列表中分配, 然后嘗試調用a_fit_segment_end_p從堆段結尾分配
a_fit_free_list_p函數的內容: https://raw.githubusercontent.com/dotnet/coreclr/release/1.1.0/src/gc/gc.cpp
這個函數會嘗試從自由對象列表中找到足夠大小的空間, 如果找到則把分配上下文指向這個空間
a_fit_segment_end_p函數的內容: https://raw.githubusercontent.com/dotnet/coreclr/release/1.1.0/src/gc/gc.cpp
這個函數會嘗試在堆段的結尾找到一塊足夠大小的空間, 如果找到則把分配上下文指向這個空間
adjust_limit_clr函數的內容: https://raw.githubusercontent.com/dotnet/coreclr/release/1.1.0/src/gc/gc.cpp
這個函數會給分配上下文設置新的范圍
不管是從自由列表還是堆段的結尾分配都會調用這個函數, 從自由列表分配時seg參數會是nullptr
調用完這個函數以后分配上下文就有足夠的空間了, 回到gc_heap::allocate的retry就可以成功的分配到對象的內存
總結小對象內存的代碼流程
allocate: 嘗試從分配上下文分配內存, 失敗時調用allocate_more_space為分配上下文指定新的空間
try_allocate_more_space: 檢查是否有必要觸發GC, 然后根據gen_number參數調用allocate_small或allocate_large函數
soh_try_fit: 先嘗試調用a_fit_free_list_p從自由對象列表中分配, 然后嘗試調用a_fit_segment_end_p從堆段結尾分配
adjust_limit_clr: 給分配上下文設置新的范圍
adjust_limit_clr: 給分配上下文設置新的范圍
a_fit_free_list_p: 嘗試從自由對象列表中找到足夠大小的空間, 如果找到則把分配上下文指向這個空間
a_fit_segment_end_p: 嘗試在堆段的結尾找到一塊足夠大小的空間, 如果找到則把分配上下文指向這個空間
allocate_small: 循環嘗試進行各種回收內存的處理和調用soh_try_fit函數
allocate_more_space: 調用try_allocate_more_space函數
分配大對象內存的代碼流程
讓我們來看一下大對象的內存是如何分配的
分配小對象我們從gc_heap::allocate開始跟蹤, 這里我們從gc_heap::allocate_large_object開始跟蹤
allocate_large_object函數的內容: https://raw.githubusercontent.com/dotnet/coreclr/release/1.1.0/src/gc/gc.cpp
這個函數和allocate函數不同的是它不會嘗試從分配上下文中分配, 而是直接從堆段中分配
allocate_more_space這個函數我們在之前已經看過了, 忘掉的可以向前翻
這個函數會調用try_allocate_more_space函數
try_allocate_more_space函數在分配大對象時會調用allocate_large函數
allocate_large函數的內容: https://raw.githubusercontent.com/dotnet/coreclr/release/1.1.0/src/gc/gc.cpp
這個函數的結構和alloc_small相似但是內部處理的細節不一樣
loh_try_fit函數的內容: https://raw.githubusercontent.com/dotnet/coreclr/release/1.1.0/src/gc/gc.cpp
處理和soh_try_fit差不多, 先嘗試調用a_fit_free_list_large_p從自由對象列表中分配, 然后嘗試調用loh_a_fit_segment_end_p從堆段結尾分配
a_fit_free_list_large_p函數的內容: https://raw.githubusercontent.com/dotnet/coreclr/release/1.1.0/src/gc/gc.cpp
和a_fit_free_list_p的處理基本相同, 但是在支持LOH壓縮時會生成填充對象, 并且有可能會調用bgc_loh_alloc_clr函數
adjust_limit_clr這個函數我們在看小對象的代碼流程時已經看過
這里看bgc_loh_alloc_clr函數的內容: https://raw.githubusercontent.com/dotnet/coreclr/release/1.1.0/src/gc/gc.cpp
這個函數是在后臺GC運行時分配大對象使用的, 需要照顧到運行中的后臺GC
loh_a_fit_segment_end_p函數的內容: https://raw.githubusercontent.com/dotnet/coreclr/release/1.1.0/src/gc/gc.cpp
這個函數會遍歷第3代的堆段鏈表逐個調用a_fit_segment_end_p函數嘗試分配
總結大對象內存的代碼流程
allocate_large_object: 調用allocate_more_space為一個空的分配上下文指定新的空間, 空間大小會等于對象的大小
try_allocate_more_space: 檢查是否有必要觸發GC, 然后根據gen_number參數調用allocate_small或allocate_large函數
loh_try_fit: 先嘗試調用a_fit_free_list_large_p從自由對象列表中分配, 然后嘗試調用loh_a_fit_segment_end_p從堆段結尾分配
a_fit_segment_end_p: 嘗試在堆段的結尾找到一塊足夠大小的空間, 如果找到則把分配上下文指向這個空間
bgc_loh_alloc_clr: 給分配上下文設置新的范圍, 照顧到后臺GC
adjust_limit_clr: 給分配上下文設置新的范圍
bgc_loh_alloc_clr: 給分配上下文設置新的范圍, 照顧到后臺GC
adjust_limit_clr: 給分配上下文設置新的范圍
a_fit_free_list_large_p: 嘗試從自由對象列表中找到足夠大小的空間, 如果找到則把分配上下文指向這個空間
loh_a_fit_segment_end_p: 遍歷第3代的堆段鏈表逐個調用a_fit_segment_end_p函數嘗試分配
allocate_large: 循環嘗試進行各種回收內存的處理和調用soh_try_fit函數
allocate_more_space: 調用try_allocate_more_space函數
CoreCLR如何管理系統內存 (windows, linux)
看到這里我們應該知道分配上下文, 小對象, 大對象的內存都是來源于堆段, 那堆段的內存來源于哪里呢?
GC在程序啟動時會創建默認的堆段, 調用流程是init_gc_heap => get_initial_segment => make_heap_segment
如果默認的堆段不夠用會創建新的堆段
小對象的堆段會通過gc1 => plan_phase => soh_get_segment_to_expand => get_segment => make_heap_segment創建
大對象的堆段會通過allocate_large => loh_get_new_seg => get_large_segment => get_segment_for_loh => get_segment => make_heap_segment創建
默認的堆段會通過next_initial_memory分配內存, 這一塊內存在程序啟動時從reserve_initial_memory函數申請
reserve_initial_memory函數和make_heap_segment函數都會調用virtual_alloc函數
因為調用流程很長我這里就不一個個函數貼代碼了, 有興趣的可以自己去跟蹤
virtual_alloc函數的調用流程是
如果是windows, VirtualAlloc就是同名的windows api
如果是linux或者macosx, 調用流程是VirtualAlloc => VIRTUALReserveMemory => ReserveVirtualMemory
ReserveVirtualMemory函數會調用mmap函數
ReserveVirtualMemory函數的內容: https://github.com/dotnet/coreclr/blob/release/1.1.0/src/pal/src/map/virtual.cpp#L894
CoreCLR在從系統申請內存時會使用VirtualAlloc或mmap模擬的VirtualAlloc
申請后會得到一塊尚未完全提交到物理內存的虛擬內存(注意保護模式是PROT_NONE, 表示該塊內存不能讀寫執行, 內核無需設置它的PageTable)
如果你有興趣可以看一下CoreCLR的虛擬內存占用, 工作站GC啟動時就占了1G多, 服務器GC啟動時就占用了20G
之后CoreCLR會根據使用慢慢的把使用的部分提交到物理內存, 流程是
GCToOSInterface::VirtualCommit => ClrVirtualAlloc => CExecutionEngine::ClrVirtualAlloc => EEVirtualAlloc => VirtualAlloc如果是windows, VirtualAlloc是同名的windowsapi, 地址會被顯式指定且頁保護模式為可讀寫(PAGE_READWRITE)
如果是linux或者macosx, VirtualAlloc會調用VIRTUALCommitMemory, 且內部會調用mprotect來設置該頁為可讀寫(PROT_READ|PROT_WRITE)
當GC回收了垃圾對象, 不再需要部分內存時會把內存還給系統, 例如回收小對象后的流程是
gc1 => decommit_ephemeral_segment_pages => decommit_heap_segment_pages => GCToOSInterface::VirtualDecommitGCToOSInterface::VirtualDecommit的調用流程是
GCToOSInterface::VirtualDecommit => ClrVirtualFree => CExecutionEngine::ClrVirtualFree => EEVirtualFree => VirtualFree如果是windows, VirtualFree是同名的windowsapi, 表示該部分虛擬內存已經不再使用內核可以重置它們的PageTable
如果是linux或者macosx, VirtualFree通過mprotect模擬, 設置該頁的保護模式為PROT_NONE
VirtualFree函數的內容: https://github.com/dotnet/coreclr/blob/release/1.1.0/src/pal/src/map/virtual.cpp#L1291
我們可以看出, CoreCLR管理系統內存的方式比較底層
在windows上使用了VirtualAlloc和VirtualFree
在linux上使用了mmap和mprotect
而不是使用傳統的malloc和new
這樣會帶來更好的性能但同時增加了移植到其他平臺的成本
動態調試GC分配對象內存的過程
要深入學習CoreCLR光看代碼是很難做到的, 比如這次大部分來源的gc.cpp有接近37000行的代碼, 如果直接看可以把一個像我這樣的普通人看瘋
為了很好的了解CoreCLR的工作原理這次我自己編譯了CoreCLR并在本地用lldb進行了調試, 這里我分享一下編譯和調試的過程
這里我使用了ubuntu 16.04 LTS, 因為linux上部署編譯環境比windows要簡單很多
下載CORECLR:
git clone https://github.com/dotnet/coreclr.git切換到你正在使用的版本, 請務必切換不要直接去編譯master分支
git checkout v1.1.0參考微軟的幫助安裝好需要的包
echo "deb http://llvm.org/apt/trusty/ llvm-toolchain-trusty-3.6 main" | sudo tee /etc/apt/sources.list.d/llvm.listwget -O - http://llvm.org/apt/llvm-snapshot.gpg.key | sudo apt-key add -sudo apt-get update sudo apt-get install cmake llvm-3.5 clang-3.5 lldb-3.6 lldb-3.6-dev libunwind8 libunwind8-dev gettext libicu-dev liblttng-ust-dev libcurl4-openssl-dev libssl-dev uuid-dev cd coreclr ./build.sh執行build.sh會從微軟的網站下載一些東西, 如果很長時間都下載不成功你應該考慮掛點什么東西
編譯過程需要幾十分鐘, 完成以后可以在coreclr/bin/Product/Linux.x64.Debug下看到編譯結果
完成以后用dotnet創建一個新的可執行項目, 在project.json中添加runtimes節
{"runtimes": {"ubuntu.16.04-x64": {}} }Program.cs的代碼可以隨意寫, 想測哪部分就寫哪部分的代碼,我這里寫的是多線程分配內存然后釋放的代碼
寫完以后編譯并發布
dotnet restoredotnet publish發布后bin/Debug/netcoreapp1.1/ubuntu16.04-x64/publish會多出最終發布的文件
把剛才CoreCLR編譯出來的coreclr/bin/Product/Linux.x64.Debug下的所有文件復制到publish目錄下, 并覆蓋原有文件
微軟官方的調試文檔可見 https://github.com/dotnet/coreclr/blob/release/1.1.0/Documentation/building/debugging-instructions.md
使用lldb啟動進程, 這里我項目名稱是coreapp所以publish下的可執行文件名稱也是coreapp
lldb-3.6 ./coreapp啟動進程后可以打命令來調試, 需要中斷(暫停)程序運行可以按下ctrl+c
這張圖中的命令
gc_heap::allocate_small 但是lldb允許用短名稱下斷點, 碰到多個符合的函數會一并截取r 運行程序, 之前在pending中的斷點如果在程序運行后可以確定內存位置則實際的添加斷點bt 查看當前的堆棧調用樹, 可以看當前被調用的函數的來源是哪些函數
這張圖中的命令
這張圖中的命令
這張圖顯示的是線程列表中的第一個線程的分配上下文內容, 0x168可以通過p &((Thread*)nullptr)->m_Link計算得出(就是offsetof)
這張圖中的命令
lldb不僅能調試CoreCLR自身的代碼
還能用來調試用戶寫的程序代碼, 需要微軟的SOS插件支持
詳細可以看微軟的官方文檔 https://github.com/dotnet/coreclr/blob/release/1.1.0/Documentation/building/debugging-instructions.md
最后附上在這次分析中我常用的lldb命令
學習lldb可以查看官方的Tutorial和GDB and LLDB command examples
參考鏈接
https://github.com/dotnet/coreclr/blob/master/Documentation/botr/garbage-collection.md
https://github.com/dotnet/coreclr/blob/release/1.1.0/src/gc/gcsvr.cpp
https://github.com/dotnet/coreclr/blob/release/1.1.0/src/gc/gcwks.cpp
https://github.com/dotnet/coreclr/blob/release/1.1.0/src/gc/gcimpl.h
https://github.com/dotnet/coreclr/blob/release/1.1.0/src/gc/gcpriv.h
https://github.com/dotnet/coreclr/blob/release/1.1.0/src/gc/gc.h#L162
https://github.com/dotnet/coreclr/blob/release/1.1.0/src/vm/gchelpers.cpp#L931
https://raw.githubusercontent.com/dotnet/coreclr/release/1.1.0/src/gc/gc.cpp
https://github.com/dotnet/coreclr/blob/release/1.1.0/src/pal/src/map/virtual.cpp#L894
https://github.com/dotnet/coreclr/blob/master/Documentation/building/linux-instructions.md
https://github.com/dotnet/coreclr/blob/release/1.1.0/Documentation/building/debugging-instructions.md
https://docs.microsoft.com/en-us/dotnet/articles/core/tools/project-json
https://github.com/dotnet/coreclr/issues/8959
https://github.com/dotnet/coreclr/issues/8995
https://github.com/dotnet/coreclr/issues/9053
因為gc的代碼實在龐大并且注釋少, 這次的分析我不僅在官方的github上提問了還動用到lldb才能做到初步的理解
下一篇我將講解GC內存回收器的內部實現, 可能需要的時間更長, 請耐心等待吧
原文地址:http://www.cnblogs.com/zkweb/p/6379080.html
.NET社區新聞,深度好文,微信中搜索dotNET跨平臺或掃描二維碼關注
總結
以上是生活随笔為你收集整理的CoreCLR源码探索(三) GC内存分配器的内部实现的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 虚拟研讨会:.NET的未来在哪里?
- 下一篇: .Net基础体系和跨框架开发普及