簡單易懂的 Go 記憶體分配原理解讀
1. 前言
編寫過C語言程式的肯定知道通過malloc()方法動態申請記憶體,其中記憶體分配器使用的是glibc提供的ptmalloc2。
除了glibc,業界比較出名的記憶體分配器有Google的tcmalloc和Facebook的jemalloc。二者在避免記憶體碎片和效能上均比glic有比較大的優勢,在多執行緒環境中效果更明顯。
Golang中也實現了記憶體分配器,原理與tcmalloc類似,簡單的說就是維護一塊大的全域性記憶體,每個執行緒(Golang中為P)維護一塊小的私有記憶體,私有記憶體不足再從全域性申請。
另外,記憶體分配與GC(垃圾回收)關係密切,所以瞭解GC前有必要了解記憶體分配的原理。
2. 基礎概念
為了方便自主管理記憶體,做法便是先向系統申請一塊記憶體,然後將記憶體切割成小塊,通過一定的記憶體分配演算法管理記憶體。
以64位系統為例,Golang程式啟動時會向系統申請的記憶體如下圖所示:
預申請的記憶體劃分為spans、bitmap、arena三部分。其中arena即為所謂的堆區,應用中需要的記憶體從這裡分配。其中spans和bitmap是為了管理arena區而存在的。
arena的大小為512G,為了方便管理把arena區域劃分成一個個的page,每個page為8KB,一共有512GB/8KB個頁;
spans區域存放span的指標,每個指標對應一個page,所以span區域的大小為(512GB/8KB)*指標大小8byte = 512M
bitmap區域大小也是通過arena計算出來,不過主要用於GC。
2.1 span
span是用於管理arena頁的關鍵資料結構,每個span中包含1個或多個連續頁,為了滿足小物件分配,span中的一頁會劃分更小的粒度,而對於大物件比如超過頁大小,則通過多頁實現。
2.1.1 class
跟據物件大小,劃分了一系列class,每個class都代表一個固定大小的物件,以及每個span的大小。如下表所示:
// class bytes/obj bytes/span objects waste bytes // 1 8 8192 1024 0 // 2 16 8192 512 0 // 3 32 8192 256 0 // 4 48 8192 170 32 // 5 64 8192 128 0 // 6 80 8192 102 32 // 7 96 8192 85 32 // 8 112 8192 73 16 // 9 128 8192 64 0 // 10 144 8192 56 128 // 11 160 8192 51 32 // 12 176 8192 46 96 // 13 192 8192 42 128 // 14 208 8192 39 80 // 15 224 8192 36 128 // 16 240 8192 34 32 // 17 256 8192 32 0 // 18 288 8192 28 128 // 19 320 8192 25 192 // 20 352 8192 23 96 // 21 384 8192 21 128 // 22 416 8192 19 288 // 23 448 8192 18 128 // 24 480 8192 17 32 // 25 512 8192 16 0 // 26 576 8192 14 128 // 27 640 8192 12 512 // 28 704 8192 11 448 // 29 768 8192 10 512 // 30 896 8192 9 128 // 31 1024 8192 8 0 // 32 1152 8192 7 128 // 33 1280 8192 6 512 // 34 1408 16384 11 896 // 35 1536 8192 5 512 // 36 1792 16384 9 256 // 37 2048 8192 4 0 // 38 2304 16384 7 256 // 39 2688 8192 3 128 // 40 3072 24576 8 0 // 41 3200 16384 5 384 // 42 3456 24576 7 384 // 43 4096 8192 2 0 // 44 4864 24576 5 256 // 45 5376 16384 3 256 // 46 6144 24576 4 0 // 47 6528 32768 5 128 // 48 6784 40960 6 256 // 49 6912 49152 7 768 // 50 8192 8192 1 0 // 51 9472 57344 6 512 // 52 9728 49152 5 512 // 53 10240 40960 4 0 // 54 10880 32768 3 128 // 55 12288 24576 2 0 // 56 13568 40960 3 256 // 57 14336 57344 4 0 // 58 16384 16384 1 0 // 59 18432 73728 4 0 // 60 19072 57344 3 128 // 61 20480 40960 2 0 // 62 21760 65536 3 256 // 63 24576 24576 1 0 // 64 27264 81920 3 128 // 65 28672 57344 2 0 // 66 32768 32768 1 0
上表中每列含義如下:
- class: class ID,每個span結構中都有一個class ID, 表示該span可處理的物件型別
- bytes/obj:該class代表物件的位元組數
- bytes/span:每個span佔用堆的位元組數,也即頁數*頁大小
- objects: 每個span可分配的物件個數,也即(bytes/spans)/(bytes/obj)
- waste bytes: 每個span產生的記憶體碎片,也即(bytes/spans)%(bytes/obj)
上表可見最大的物件是32K大小,超過32K大小的由特殊的class表示,該class ID為0,每個class只包含一個物件。
2.1.2 span資料結構
span是記憶體管理的基本單位,每個span用於管理特定的class物件, 跟據物件大小,span將一個或多個頁拆分成多個塊進行管理。
src/runtime/mheap.go:mspan定義了其資料結構:
type mspan struct {
next *mspan //連結串列前向指標,用於將span連結起來
prev *mspan //連結串列前向指標,用於將span連結起來
startAddr uintptr // 起始地址,也即所管理頁的地址
npages uintptr // 管理的頁數
nelems uintptr // 塊個數,也即有多少個塊可供分配
allocBits *gcBits //分配點陣圖,每一位代表一個塊是否已分配
allocCount uint16 // 已分配塊的個數
spanclass spanClass // class表中的class ID
elemsize uintptr // class表中的物件大小,也即塊大小
}
以class 10為例,span和管理的記憶體如下圖所示:
spanclass為10,參照class表可得出npages=1,nelems=56,elemsize為144。其中startAddr是在span初始化時就指定了某個頁的地址。allocBits指向一個位圖,每位代表一個塊是否被分配,本例中有兩個塊已經被分配,其allocCount也為2。
next和prev用於將多個span連結起來,這有利於管理多個span,接下來會進行說明。
2.2 cache
有了管理記憶體的基本單位span,還要有個資料結構來管理span,這個資料結構叫mcentral,各執行緒需要記憶體時從mcentral管理的span中申請記憶體,為了避免多執行緒申請記憶體時不斷的加鎖,Golang為每個執行緒分配了span的快取,這個快取即是cache。
src/runtime/mcache.go:mcache定義了cache的資料結構:
type mcache struct {
alloc [67*2]*mspan // 按class分組的mspan列表
}
alloc為mspan的指標陣列,陣列大小為class總數的2倍。陣列中每個元素代表了一種class型別的span列表,每種class型別都有兩組span列表,第一組列表中所表示的物件中包含了指標,第二組列表中所表示的物件不含有指標,這麼做是為了提高GC掃描效能,對於不包含指標的span列表,沒必要去掃描。
根據物件是否包含指標,將物件分為noscan和scan兩類,其中noscan代表沒有指標,而scan則代表有指標,需要GC進行掃描。
mcache和span的對應關係如下圖所示:
mchache在初始化時是沒有任何span的,在使用過程中會動態的從central中獲取並快取下來,跟據使用情況,每種class的span個數也不相同。上圖所示,class 0的span數比class1的要多,說明本執行緒中分配的小物件要多一些。
2.3 central
cache作為執行緒的私有資源為單個執行緒服務,而central則是全域性資源,為多個執行緒服務,當某個執行緒記憶體不足時會向central申請,當某個執行緒釋放記憶體時又會回收進central。
src/runtime/mcentral.go:mcentral定義了central資料結構:
type mcentral struct {
lock mutex //互斥鎖
spanclass spanClass // span class ID
nonempty mSpanList // non-empty 指還有空閒塊的span列表
empty mSpanList // 指沒有空閒塊的span列表
nmalloc uint64 // 已累計分配的物件個數
}
- lock: 執行緒間互斥鎖,防止多執行緒讀寫衝突
- spanclass : 每個mcentral管理著一組有相同class的span列表
- nonempty: 指還有記憶體可用的span列表
- empty: 指沒有記憶體可用的span列表
- nmalloc: 指累計分配的物件個數
執行緒從central獲取span步驟如下:
- 加鎖
- 從nonempty列表獲取一個可用span,並將其從連結串列中刪除
- 將取出的span放入empty連結串列
- 將span返回給執行緒
- 解鎖
- 執行緒將該span快取進cache
執行緒將span歸還步驟如下:
- 加鎖
- 將span從empty列表刪除
- 將span加入noneempty列表
- 解鎖
上述執行緒從central中獲取span和歸還span只是簡單流程,為簡單起見,並未對具體細節展開。
2.4 heap
從mcentral資料結構可見,每個mcentral物件只管理特定的class規格的span。事實上每種class都會對應一個mcentral,這個mcentral的集合存放於mheap資料結構中。
src/runtime/mheap.go:mheap定義了heap的資料結構:
type mheap struct {
lock mutex
spans []*mspan
bitmap uintptr //指向bitmap首地址,bitmap是從高地址向低地址增長的
arena_start uintptr //指示arena區首地址
arena_used uintptr //指示arena區已使用地址位置
central [67*2]struct {
mcentral mcentral
pad [sys.CacheLineSize - unsafe.Sizeof(mcentral{})%sys.CacheLineSize]byte
}
}
- lock: 互斥鎖
- spans: 指向spans區域,用於對映span和page的關係
- bitmap:bitmap的起始地址
- arena_start: arena區域首地址
- arena_used: 當前arena已使用區域的最大地址
- central: 每種class對應的兩個mcentral
從資料結構可見,mheap管理著全部的記憶體,事實上Golang就是通過一個mheap型別的全域性變數進行記憶體管理的。
mheap記憶體管理示意圖如下:
系統預分配的記憶體分為spans、bitmap、arean三個區域,通過mheap管理起來。接下來看記憶體分配過程。
3. 記憶體分配過程
針對待分配物件的大小不同有不同的分配邏輯:
- (0, 16B) 且不包含指標的物件: Tiny分配
- (0, 16B) 包含指標的物件:正常分配
- [16B, 32KB] : 正常分配
- (32KB, -) : 大物件分配 其中Tiny分配和大物件分配都屬於記憶體管理的優化範疇,這裡暫時僅關注一般的分配方法。
以申請size為n的記憶體為例,分配步驟如下:
- 獲取當前執行緒的私有快取mcache
- 跟據size計算出適合的class的ID
- 從mcache的alloc[class]連結串列中查詢可用的span
- 如果mcache沒有可用的span則從mcentral申請一個新的span加入mcache中
- 如果mcentral中也沒有可用的span則從mheap中申請一個新的span加入mcentral
- 從該span中獲取到空閒物件地址並返回
4. 總結
Golang記憶體分配是個相當複雜的過程,其中還摻雜了GC的處理,這裡僅僅對其關鍵資料結構進行了說明,瞭解其原理而又不至於深陷實現細節。
- Golang程式啟動時申請一大塊記憶體,並劃分成spans、bitmap、arena區域
- arena區域按頁劃分成一個個小塊
- span管理一個或多個頁
- mcentral管理多個span供執行緒申請使用
- mcache作為執行緒私有資源,資源來源於mcentral
本文來自雲棲社群合作伙伴“開源中國” 本文作者:達爾文 原文連結