# 7.1 設計原則
到目前為止,我們已經分析了 Go 程序如何啟動、初始化需要進行的關鍵步驟、初始化結束后, 主 goroutine 如何被調度器進行調度。現在我們來看 Go 中另一重要的關鍵組件:內存分配器。
Go 的內存分配器基于 Thread-Cache Malloc (tcmalloc) \[1\],tcmalloc 為每個線程實現了一個本地緩存, 區分了小對象(小于 32kb)和大對象分配兩種分配類型,其管理的內存單元稱為 span。
我們不再介紹更多 tcmalloc 的具體細節,因為 Go 的內存分配器與 tcmalloc 存在一定差異。 這個差異來源于 Go 語言被設計為沒有顯式的內存分配與釋放, 完全依靠編譯器與運行時的配合來自動處理,因此也就造就了內存分配器、垃圾回收器兩大組件。
我們知道,在計算機領域中,無外乎時間換空間、空間換時間。統一管理內存會提前分配或一次性釋放一大塊內存, 進而減少與操作系統溝通造成的開銷,進而提高程序的運行性能。 支持內存管理另一個優勢就是能夠更好的支持垃圾回收,這一點我們留到垃圾回收器一節中進行討論。
## 主要結構
Go 的內存分配器主要包含以下幾個核心組件:
* heapArena: 保留整個虛擬地址空間
* mheap:分配的堆,在頁大小為 8KB 的粒度上進行管理
* mspan:是 mheap 上管理的一連串的頁
* mcentral:收集了給定大小等級的所有 span
* mcache:為 per-P 的緩存。
其中頁是向操作系統申請內存的最小單位,目前設計為 8KB。
每一個結構雖然不都像是調度器 M/P/G 結構那樣的大部頭,但初次閱讀這些結構時想要理清他們之間的關系還是比較麻煩的。 傳統意義上的棧被 Go 的運行時霸占,不開放給用戶態代碼;而傳統意義上的堆內存,又被 Go 運行時劃分為了兩個部分, 一個是 Go 運行時自身所需的堆內存,即堆外內存;另一部分則用于 Go 用戶態代碼所使用的堆內存,也叫做 Go 堆。 Go 堆負責了用戶態對象的存放以及 goroutine 的執行棧。
### Arena
#### heapArena
Go 堆被視為由多個 arena 組成,每個 arena 在 64 位機器上為 64MB,且起始地址與 arena 的大小對齊, 所有的 arena 覆蓋了整個 Go 堆的地址空間。
```
const (
pageSize = 8192 // 8KB
heapArenaBytes = 67108864 // 64MB
heapArenaBitmapBytes = heapArenaBytes / 32 // 2097152
pagesPerArena = heapArenaBytes / pageSize // 8192
)
//go:notinheap
type heapArena struct {
bitmap [heapArenaBitmapBytes]byte
spans [pagesPerArena]*mspan
pageInUse [pagesPerArena / 8]uint8
pageMarks [pagesPerArena / 8]uint8
zeroedBase uintptr
}
```
#### arenaHint
結構比較簡單,是 arenaHint 鏈表的節點結構,保存了 arena 的起始地址、是否為最后一個 arena,以及下一個 arenaHint 指針。
```
//go:notinheap
type arenaHint struct {
addr uintptr
down bool
next *arenaHint
}
```
### mspan
然而管理 arena 如此粒度的內存并不符合實踐,相反,所有的堆對象都通過 span 按照預先設定好的 大小等級分別分配,小于 32KB 的小對象則分配在固定大小等級的 span 上,否則直接從 mheap 上進行分配。
`mspan`是相同大小等級的 span 的雙向鏈表的一個節點,每個節點還記錄了自己的起始地址、 指向的 span 中頁的數量。它要么位于
```
//go:notinheap
type mspan struct { // 雙向鏈表
next *mspan // 鏈表中的下一個 span,如果為空則為 nil
prev *mspan // 鏈表中的前一個 span,如果為空則為 nil
...
startAddr uintptr // span 的第一個字節的地址,即 s.base()
npages uintptr // 一個 span 中的 page 數量
manualFreeList gclinkptr // mSpanManual span 的釋放對象鏈表
...
freeindex uintptr
nelems uintptr // span 中對象的數量
allocCache uint64
allocBits *gcBits
...
allocCount uint16 // 分配對象的數量
spanclass spanClass // 大小等級與 noscan (uint8)
incache bool // 是否被 mcache 使用
state mSpanState // mspaninuse 等等信息
...
}
```
### mcache
是一個 per-P 的緩存,它是一個包含不同大小等級的 span 鏈表的數組,其中 mcache.alloc 的每一個數組元素 都是某一個特定大小的 mspan 的鏈表頭指針。
```
//go:notinheap
type mcache struct {
...
tiny uintptr
tinyoffset uintptr
local_tinyallocs uintptr
alloc [numSpanClasses]*mspan // 用來分配的 spans,由 spanClass 索引
stackcache [_NumStackOrders]stackfreelist
...
}
```
當 mcache 中 span 的數量不夠使用時,會向 mcentral 的 nonempty 列表中獲得新的 span。
### mcentral
mcentral
```
//go:notinheap
type mcentral struct {
lock mutex
spanclass spanClass
nonempty mSpanList // 帶有自由對象的 span 列表,即非空閑列表
empty mSpanList // 沒有自由對象的 span 列表(或緩存在 mcache 中)
...
}
```
當 mcentral 中 nonempty 列表中也沒有可分配的 span 時,則會向 mheap 提出請求,從而獲得 新的 span,并進而交給 mcache。
### mheap
```
//go:notinheap
type mheap struct {
lock mutex
pages pageAlloc
...
allspans []*mspan // 所有 spans 從這里分配出去
scavengeGoal uint64
reclaimIndex uint64
reclaimCredit uintptr
arenas [1 << arenaL1Bits]*[1 << arenaL2Bits]*heapArena
heapArenaAlloc linearAlloc
arenaHints *arenaHint
arena linearAlloc
allArenas []arenaIdx
curArena struct {
base, end uintptr
}
central [numSpanClasses]struct {
mcentral mcentral
pad [cpu.CacheLinePadSize - unsafe.Sizeof(mcentral{})%cpu.CacheLinePadSize]byte
}
...
// 各種分配器
spanalloc fixalloc // span* 分配器
cachealloc fixalloc // mcache* 分配器
treapalloc fixalloc // treapNodes* 分配器,用于大對象
specialfinalizeralloc fixalloc // specialfinalizer* 分配器
specialprofilealloc fixalloc // specialprofile* 分配器
speciallock mutex // 特殊記錄分配器的鎖
arenaHintAlloc fixalloc // arenaHints 分配器
...
}
```
## 分配概覽
在分析具體的分配過程之前,我們需要搞清楚究竟什么時候會發生分配。
Go 程序的執行是基于 goroutine 的,goroutine 和傳統意義上的程序一樣,也有棧和堆的概念。只不過 Go 的運行時幫我們屏蔽掉了這兩個概念,只在運行時內部區分并分別對應:goroutine 執行棧以及 Go 堆。
goroutine 的執行棧與傳統意義上的棧一樣,當函數返回時,在棧上就會被回收,棧中的對象都會被回收,從而 無需 GC 的標記;而堆則麻煩一些,由于 Go 支持垃圾回收,只要對象生存在堆上,Go 的運行時 GC 就會在 后臺將對應的內存進行標記從而能夠在垃圾回收的時候將對應的內存回收,進而增加了開銷。
下面這個程序給出了四種情況:
```
package main
type smallobj struct {
arr [1 << 10]byte
}
type largeobj struct {
arr [1 << 26]byte
}
func f1() int {
x := 1
return x
}
func f2() *int {
y := 2
return &y
}
func f3() {
large := largeobj{}
println(&large)
}
func f4() {
small := smallobj{}
print(&small)
}
func main() {
x := f1()
y := f2()
f3()
f4()
println(x, y)
}
```
我們使用`-gcflags "-N -l -m"`編譯這段代碼能夠禁用編譯器與內聯優化并進行逃逸分析:
```
# alloc.go
# go build -gcflags "-N -l -m" -ldflags=-compressdwarf=false -o alloc.out alloc.go
# command-line-arguments
./alloc.go:18:9: &y escapes to heap
./alloc.go:17:2: moved to heap: y
./alloc.go:22:2: moved to heap: large
./alloc.go:23:10: f3 &large does not escape
./alloc.go:28:8: f4 &small does not escape
```
* 情況1:`f1`中`x`的變量被返回,沒有發生逃逸;
* 情況2:`f2`中`y`的指針被返回,進而發生了逃逸;
* 情況3:`f3`中`large`無法被一個執行棧裝下,即便沒有返回,也會直接在堆上分配;
* 情況4:`f4`中`small`對象能夠被一個執行棧裝下,變量沒有返回到棧外,進而沒有發生逃逸。
如果我們再仔細檢查一下他們的匯編:
```
TEXT main.f2(SB) /Users/changkun/dev/go-under-the-hood/demo/4-mem/alloc/alloc.go
...
alloc.go:17 0x104e086 488d05939f0000 LEAQ type.*+40256(SB), AX
alloc.go:17 0x104e08d 48890424 MOVQ AX, 0(SP)
alloc.go:17 0x104e091 e8cabffbff CALL runtime.newobject(SB)
alloc.go:17 0x104e096 488b442408 MOVQ 0x8(SP), AX
alloc.go:17 0x104e09b 4889442410 MOVQ AX, 0x10(SP)
alloc.go:17 0x104e0a0 48c70002000000 MOVQ $0x2, 0(AX)
...
TEXT main.f3(SB) /Users/changkun/dev/go-under-the-hood/demo/4-mem/alloc/alloc.go
...
alloc.go:22 0x104e0ed 488d05ecf60000 LEAQ type.*+62720(SB), AX
alloc.go:22 0x104e0f4 48890424 MOVQ AX, 0(SP)
alloc.go:22 0x104e0f8 e863bffbff CALL runtime.newobject(SB)
alloc.go:22 0x104e0fd 488b7c2408 MOVQ 0x8(SP), DI
alloc.go:22 0x104e102 48897c2418 MOVQ DI, 0x18(SP)
alloc.go:22 0x104e107 b900008000 MOVL $0x800000, CX
alloc.go:22 0x104e10c 31c0 XORL AX, AX
alloc.go:22 0x104e10e f348ab REP; STOSQ AX, ES:0(DI)
...
```
就會發現,對于產生在 Go 堆上分配對象的情況,均調用了運行時的`runtime.newobject`方法。 當然,關鍵字`new`同樣也會被編譯器翻譯為此函數,這個我們已經在實踐中知道了。 所以`runtime.newobject`就是內存分配的核心入口了。
### 分配入口
單看`runtime.newobject`其實非常簡單,他只是簡單的調用了`mallocgc`:
```
// 創建一個新的對象
func newobject(typ *_type) unsafe.Pointer {
return mallocgc(typ.size, typ, true) // true 內存清零
}
```
其中`_type`為 Go 類型的實現,通過其`size`屬性能夠獲得該類型所需要的大小。
```
func mallocgc(size uintptr, typ *_type, needzero bool) unsafe.Pointer {
// 創建大小為零的對象,例如空結構體
if size == 0 {
return unsafe.Pointer(&zerobase)
}
mp := acquirem()
mp.mallocing = 1
...
// 獲取當前 g 所在 M 所綁定 P 的 mcache
c := gomcache()
var x unsafe.Pointer
noscan := typ == nil || typ.kind&kindNoPointers != 0
if size <= maxSmallSize {
if noscan && size < maxTinySize {
// 微對象分配
...
} else {
// 小對象分配
...
}
} else {
// 大對象分配
...
}
...
mp.mallocing = 0
releasem(mp)
...
return x
```
在分配過程中,我們會發現需要持有 M 才可進行分配,這是因為分配不僅可能涉及 mcache,還需要將正在分配的 M 標記為`mallocing`,用于記錄當前 M 的分配狀態。
### 小對象分配
當對一個小對象(<32KB)分配內存時,會將該對象所需的內存大小調整到某個能夠容納該對象的大小等級(size class), 并查看 mcache 中對應等級的 mspan,通過掃描 mspan 的`freeindex`來確定是否能夠進行分配。
當沒有可分配的 mspan 時,會從 mcentral 中獲取一個所需大小空間的新的 mspan,從 mcentral 中分配會對其進行加鎖, 但一次性獲取整個 span 的過程均攤了對 mcentral 加鎖的成本。
如果 mcentral 的 mspan 也為空時,則它也會發生增長,從而從 mheap 中獲取一連串的頁,作為一個新的 mspan 進行提供。 而如果 mheap 仍然為空,或者沒有足夠大的對象來進行分配時,則會從操作系統中分配一組新的頁(至少 1MB), 從而均攤與操作系統溝通的成本。
### 微對象分配
對于過小的微對象(<16B),它們的分配過程與小對象的分配過程基本類似,但是是直接存儲在 mcache 上,并由其以 16B 的塊大小直接進行管理和釋放。
### 大對象分配
大對象分配非常粗暴,不與 mcache 和 mcentral 溝通,直接繞過并通過 mheap 進行分配。
## 小結
圖 1 展示了所有結構的關系。
**圖 1: Go 內存管理結構總覽**
heap 最中間的灰色區域 arena 覆蓋了 Go 程序的整個虛擬內存, 每個 arena 包括一段 bitmap 和一段指向連續 span 的指針; 每個 span 由一串連續的頁組成;每個 arena 的起始位置通過 arenaHint 進行記錄。
分配的順序從右向左,代價也就越來越大。 小對象和微對象優先從白色區域 per-P 的 mcache 分配 span,這個過程不需要加鎖(白色); 若失敗則會從 mheap 持有的 mcentral 加鎖獲得新的 span,這個過程需要加鎖,但只是局部(灰色); 若仍失敗則會從右側的 free 或 scav 進行分配,這個過程需要對整個 heap 進行加鎖,代價最大(黑色)。
## 進一步閱讀的參考文獻
1. [TCMalloc : Thread-Caching Malloc](http://goog-perftools.sourceforge.net/doc/tcmalloc.html)
- 第一部分 :基礎篇
- 第1章 Go語言的前世今生
- 1.2 Go語言綜述
- 1.3 順序進程通訊
- 1.4 Plan9匯編語言
- 第2章 程序生命周期
- 2.1 從go命令談起
- 2.2 Go程序編譯流程
- 2.3 Go 程序啟動引導
- 2.4 主Goroutine的生與死
- 第3 章 語言核心
- 3.1 數組.切片與字符串
- 3.2 散列表
- 3.3 函數調用
- 3.4 延遲語句
- 3.5 恐慌與恢復內建函數
- 3.6 通信原語
- 3.7 接口
- 3.8 運行時類型系統
- 3.9 類型別名
- 3.10 進一步閱讀的參考文獻
- 第4章 錯誤
- 4.1 問題的演化
- 4.2 錯誤值檢查
- 4.3 錯誤格式與上下文
- 4.4 錯誤語義
- 4.5 錯誤處理的未來
- 4.6 進一步閱讀的參考文獻
- 第5章 同步模式
- 5.1 共享內存式同步模式
- 5.2 互斥鎖
- 5.3 原子操作
- 5.4 條件變量
- 5.5 同步組
- 5.6 緩存池
- 5.7 并發安全散列表
- 5.8 上下文
- 5.9 內存一致模型
- 5.10 進一步閱讀的文獻參考
- 第二部分 運行時篇
- 第6章 并發調度
- 6.1 隨機調度的基本概念
- 6.2 工作竊取式調度
- 6.3 MPG模型與并發調度單
- 6.4 調度循環
- 6.5 線程管理
- 6.6 信號處理機制
- 6.7 執行棧管理
- 6.8 協作與搶占
- 6.9 系統監控
- 6.10 網絡輪詢器
- 6.11 計時器
- 6.12 非均勻訪存下的調度模型
- 6.13 進一步閱讀的參考文獻
- 第7章 內存分配
- 7.1 設計原則
- 7.2 組件
- 7.3 初始化
- 7.4 大對象分配
- 7.5 小對象分配
- 7.6 微對象分配
- 7.7 頁分配器
- 7.8 內存統計
- 第8章 垃圾回收
- 8.1 垃圾回收的基本想法
- 8.2 寫屏幕技術
- 8.3 調步模型與強弱觸發邊界
- 8.4 掃描標記與標記輔助
- 8.5 免清掃式位圖技術
- 8.6 前進保障與終止檢測
- 8.7 安全點分析
- 8.8 分代假設與代際回收
- 8.9 請求假設與實務制導回收
- 8.10 終結器
- 8.11 過去,現在與未來
- 8.12 垃圾回收統一理論
- 8.13 進一步閱讀的參考文獻
- 第三部分 工具鏈篇
- 第9章 代碼分析
- 9.1 死鎖檢測
- 9.2 競爭檢測
- 9.3 性能追蹤
- 9.4 代碼測試
- 9.5 基準測試
- 9.6 運行時統計量
- 9.7 語言服務協議
- 第10章 依賴管理
- 10.1 依賴管理的難點
- 10.2 語義化版本管理
- 10.3 最小版本選擇算法
- 10.4 Vgo 與dep之爭
- 第12章 泛型
- 12.1 泛型設計的演進
- 12.2 基于合約的泛型
- 12.3 類型檢查技術
- 12.4 泛型的未來
- 12.5 進一步閱讀的的參考文獻
- 第13章 編譯技術
- 13.1 詞法與文法
- 13.2 中間表示
- 13.3 優化器
- 13.4 指針檢查器
- 13.5 逃逸分析
- 13.6 自舉
- 13.7 鏈接器
- 13.8 匯編器
- 13.9 調用規約
- 13.10 cgo與系統調用
- 結束語: Go去向何方?