# 7.2 組件
本節獨立地討論內存分配器中的幾個組件:`fixalloc`、`linearAlloc`、`mcache`。
## fixalloc
`fixalloc`是一個基于自由列表的固定大小的分配器。其核心原理是將若干未分配的內存塊連接起來, 將未分配的區域的第一個字為指向下一個未分配區域的指針使用。
Go 的主分配堆中 malloc(span、cache、treap、finalizer、profile、arena hint 等) 均 圍繞它為實體進行固定分配和回收。
fixalloc 作為抽象,非常簡潔,只包含三個基本操作:初始化、分配、回收
### 結構
```
// fixalloc 是一個簡單的固定大小對象的自由表內存分配器。
// Malloc 使用圍繞 sysAlloc 的 fixalloc 來管理其 MCache 和 MSpan 對象。
//
// fixalloc.alloc 返回的內存默認為零,但調用者可以通過將 zero 標志設置為 false
// 來自行負責將分配歸零。如果這部分內存永遠不包含堆指針,則這樣的操作是安全的。
//
// 調用方負責鎖定 fixalloc 調用。調用方可以在對象中保持狀態,
// 但當釋放和重新分配時第一個字會被破壞。
//
// 考慮使 fixalloc 的類型變為 go:notinheap.
type fixalloc struct {
size uintptr
first func(arg, p unsafe.Pointer) // 首次調用時返回 p
arg unsafe.Pointer
list *mlink
chunk uintptr // 使用 uintptr 而非 unsafe.Pointer 來避免 write barrier
nchunk uint32
inuse uintptr // 正在使用的字節
stat *uint64
zero bool // 歸零的分配
}
```
### 初始化
Go 語言對于零值有自己的規定,自然也就體現在內存分配器上。而`fixalloc`作為內存分配器內部組件的來源于 操作系統的內存,自然需要自行初始化,因此,`fixalloc`的初始化也就不可避免的需要將自身的各個字段歸零:
```
// 初始化 f 來分配給定大小的對象。
// 使用分配器來按 chunk 獲取
func (f *fixalloc) init(size uintptr, first func(arg, p unsafe.Pointer), arg unsafe.Pointer, stat *uint64) {
f.size = size
f.first = first
f.arg = arg
f.list = nil
f.chunk = 0
f.nchunk = 0
f.inuse = 0
f.stat = stat
f.zero = true
}
```
### 分配
`fixalloc`基于自由表策略進行實現,分為兩種情況:
1. 存在被釋放、可復用的內存
2. 不存在可復用的內存
對于第一種情況,也就是在運行時內存被釋放,但這部分內存并不會被立即回收給操作系統, 我們直接從自由表中獲得即可,但需要注意按需將這部分內存進行清零操作。
對于第二種情況,我們直接向操作系統申請固定大小的內存,然后扣除分配的大小即可。
```
const _FixAllocChunk = 16 << 10 // FixAlloc 一個 Chunk 的大小
func (f *fixalloc) alloc() unsafe.Pointer {
// fixalloc 的個字段必須先被 init
if f.size == 0 {
print("runtime: use of FixAlloc_Alloc before FixAlloc_Init\n")
throw("runtime: internal error")
}
// 如果 f.list 不是 nil, 則說明還存在已經釋放、可復用的內存,直接將其分配
if f.list != nil {
// 取出 f.list
v := unsafe.Pointer(f.list)
// 并將其指向下一段區域
f.list = f.list.next
// 增加使用的(分配)大小
f.inuse += f.size
// 如果需要對內存清零,則對取出的內存執行初始化
if f.zero {
memclrNoHeapPointers(v, f.size)
}
// 返回分配的內存
return v
}
// f.list 中沒有可復用的內存
// 如果此時 nchunk 不足以分配一個 size
if uintptr(f.nchunk) < f.size {
// 則向操作系統申請內存,大小為 16 << 10 pow(2,14)
f.chunk = uintptr(persistentalloc(_FixAllocChunk, 0, f.stat))
f.nchunk = _FixAllocChunk
}
// 指向申請好的內存
v := unsafe.Pointer(f.chunk)
if f.first != nil { // first 只有在 fixalloc 作為 spanalloc 時候,才會被設置為 recordspan
f.first(f.arg, v) // 用于為 heap.allspans 添加新的 span
}
// 扣除并保留 size 大小的空間
f.chunk = f.chunk + f.size
f.nchunk -= uint32(f.size)
f.inuse += f.size // 記錄已經使用的大小
return v
}
```
我們在稍后討論`memclrNoHeapPointers`和`persistentalloc`。
### 回收
回收就更加簡單了,直接將回收的地址指針放回到自由表中即可:
```
func (f *fixalloc) free(p unsafe.Pointer) {
// 減少使用的字節數
f.inuse -= f.size
// 將要釋放的內存地址作為 mlink 指針插入到 f.list 內,完成回收
v := (*mlink)(p)
v.next = f.list
f.list = v
}
```
## linearAlloc
`linearAlloc`是一個基于線性分配策略的分配器,但由于它只作為`mheap_.heapArenaAlloc`和`mheap_.arena`在 32 位系統上使用,這里不做詳細分析。
```
// linearAlloc 是一個簡單的線性分配器,它預留一塊內存區域并按需將其映射到 Ready 狀態。
// 調用方有責任對齊進行加鎖。
type linearAlloc struct {
next uintptr // 下一個可用的字節
mapped uintptr // 映射空間后的一個字節
end uintptr // 保留空間的末尾
}
func (l *linearAlloc) init(base, size uintptr) {
l.next, l.mapped = base, base
l.end = base + size
}
func (l *linearAlloc) alloc(size, align uintptr, sysStat *uint64) unsafe.Pointer {
p := round(l.next, align)
if p+size > l.end {
return nil
}
l.next = p + size
if pEnd := round(l.next-1, physPageSize); pEnd > l.mapped {
// We need to map more of the reserved space.
sysMap(unsafe.Pointer(l.mapped), pEnd-l.mapped, sysStat)
l.mapped = pEnd
}
return unsafe.Pointer(p)
}
```
## mcache
`mcache`是一個 per-P 的緩存,因此每個線程都只訪問自身的`mcache`,因此也就不會出現 并發,也就省去了對其進行加鎖步驟。
```
//go:notinheap
type mcache struct {
// 下面的成員在每次 malloc 時都會被訪問
// 因此將它們放到一起來利用緩存的局部性原理
next_sample uintptr // 分配這么多字節后觸發堆樣本
local_scan uintptr // 分配的可掃描堆的字節數
// 沒有指針的微小對象的分配器緩存。
// 請參考 malloc.go 中的 "小型分配器" 注釋。
//
// tiny 指向當前 tiny 塊的起始位置,或當沒有 tiny 塊時候為 nil
// tiny 是一個堆指針。由于 mcache 在非 GC 內存中,我們通過在
// mark termination 期間在 releaseAll 中清除它來處理它。
tiny uintptr
tinyoffset uintptr
local_tinyallocs uintptr // 不計入其他統計的極小分配的數量
// 下面的不在每個 malloc 時被訪問
alloc [numSpanClasses]*mspan // 用來分配的 spans,由 spanClass 索引
stackcache [_NumStackOrders]stackfreelist
// 本地分配器統計,在 GC 期間被刷新
local_largefree uintptr // bytes freed for large objects (>maxsmallsize)
local_nlargefree uintptr // number of frees for large objects (>maxsmallsize)
local_nsmallfree [_NumSizeClasses]uintptr // number of frees for small objects (<=maxsmallsize)
// flushGen indicates the sweepgen during which this mcache
// was last flushed. If flushGen != mheap_.sweepgen, the spans
// in this mcache are stale and need to the flushed so they
// can be swept. This is done in acquirep.
flushGen uint32
}
```
### 分配
運行時的`runtime.allocmcache`從`mheap`上分配一個`mcache`。 由于`mheap`是全局的,因此在分配期必須對其進行加鎖,而分配通過 fixAlloc 組件完成:
```
// 虛擬的MSpan,不包含任何對象。
var emptymspan mspan
func allocmcache() *mcache {
var c *mcache
systemstack(func() {
lock(&mheap_.lock)
c = (*mcache)(mheap_.cachealloc.alloc())
c.flushGen = mheap_.sweepgen
unlock(&mheap_.lock)
}
for i := range c.alloc {
c.alloc[i] = &emptymspan // 暫時指向虛擬的 mspan 中
}
// 返回下一個采樣點,是服從泊松過程的隨機數
c.next_sample = nextSample()
return c
}
```
由于運行時提供了采樣過程堆分析的支持, 由于我們的采樣的目標是平均每個`MemProfileRate`字節對分配進行采樣, 顯然,在整個時間線上的分配情況應該是完全隨機分布的,這是一個泊松過程。 因此最佳的采樣點應該是服從指數分布`exp(MemProfileRate)`的隨機數,其中`MemProfileRate`為均值。
```
func nextSample() uintptr {
if GOOS == "plan9" {
// Plan 9 doesn't support floating point in note handler.
if g := getg(); g == g.m.gsignal {
return nextSampleNoFP()
}
}
return uintptr(fastexprand(MemProfileRate))
}
```
`MemProfileRate`是一個公共變量,可以在用戶態代碼進行修改:
1
var MemProfileRate int = 512 * 1024
### 釋放
由于`mcache`從非 GC 內存上進行分配,因此出現的任何堆指針都必須進行特殊處理。 所以在釋放前,需要調用`mcache.releaseAll`將堆指針進行處理:
```
func (c *mcache) releaseAll() {
for i := range c.alloc {
s := c.alloc[i]
if s != &emptymspan {
// 將 span 歸還
mheap_.central[i].mcentral.uncacheSpan(s)
c.alloc[i] = &emptymspan
}
}
// 清空 tinyalloc 池.
c.tiny = 0
c.tinyoffset = 0
}
```
```
func freemcache(c *mcache) {
systemstack(func() {
// 歸還 span
c.releaseAll()
// 釋放 stack
stackcache_clear(c)
lock(&mheap_.lock)
// 記錄局部統計
purgecachedstats(c)
// 將 mcache 釋放
mheap_.cachealloc.free(unsafe.Pointer(c))
unlock(&mheap_.lock)
})
}
```
### per-P? per-M?
mcache 其實早在[調度器: 調度循環](https://golang.design/under-the-hood/zh-cn/part2runtime/ch06sched/exec)中與 mcache 打過照面了。
首先,mcache 是一個 per-P 的 mcache,我們很自然的疑問就是,這個 mcache 在 p/m 這兩個結構體上都有成員:
```
type p struct {
(...)
mcache *mcache
(...)
}
type m struct {
(...)
mcache *mcache
(...)
}
```
那么 mcache 是跟著誰跑的?結合調度器的知識不難發現,m 在執行時需要持有一個 p 才具備執行能力。 有利的證據是,當調用`runtime.procresize`時,初始化新的 P 時,mcache 是直接分配到 p 的; 回收 p 時,mcache 是直接從 p 上獲取:
```
func procresize(nprocs int32) *p {
(...)
// 初始化新的 P
for i := int32(0); i < nprocs; i++ {
pp := allp[i]
(...)
// 為 P 分配 cache 對象
if pp.mcache == nil {
if old == 0 && i == 0 {
if getg().m.mcache == nil {
throw("missing mcache?")
}
pp.mcache = getg().m.mcache
} else {
// 創建 cache
pp.mcache = allocmcache()
}
}
(...)
}
// 釋放未使用的 P
for i := nprocs; i < old; i++ {
p := allp[i]
(...)
// 釋放當前 P 綁定的 cache
freemcache(p.mcache)
p.mcache = nil
(...)
}
(...)
}
```
因而我們可以明確:
* mcache 會被 P 持有,當 M 和 P 綁定時,M 同樣會保留 mcache 的指針
* mcache 直接向操作系統申請內存,且常駐運行時
* P 通過 make 命令進行分配,會分配在 Go 堆上
## 其他
### memclrNoHeapPointers
`memclrNoHeapPointers`用于清理不包含堆指針的內存區塊:
```
// memclrNoHeapPointers 清除從 ptr 開始的 n 個字節
// 通常情況下你應該使用 typedmemclr,而 memclrNoHeapPointers 應該僅在調用方知道 *ptr
// 不包含堆指針的情況下使用,因為 *ptr 只能是下面兩種情況:
// 1. *ptr 是初始化過的內存,且其類型不是指針。
// 2. *ptr 是未初始化的內存(例如剛被新分配時使用的內存),則指包含 "junk" 垃圾內存
// 見 memclr_*.s
//
//go:noescape
func memclrNoHeapPointers(ptr unsafe.Pointer, n uintptr)
```
清理過程是匯編實現的,就是一些內存的歸零工作,簡單瀏覽一下:
```
TEXT runtime·memclrNoHeapPointers(SB), NOSPLIT, $0-8
MOVL ptr+0(FP), DI
MOVL n+4(FP), BX
XORL AX, AX
// MOVOU 好像總是比 REP STOSL 快
tail:
(...)
loop:
MOVOU X0, 0(DI)
MOVOU X0, 16(DI)
MOVOU X0, 32(DI)
MOVOU X0, 48(DI)
MOVOU X0, 64(DI)
MOVOU X0, 80(DI)
MOVOU X0, 96(DI)
(...)
```
## 系統級內存管理調用
系統級的內存管理調用是平臺相關的,這里以 Linux 為例,運行時的`sysAlloc`、`sysUnused`、`sysUsed`、`sysFree`、`sysReserve`、`sysMap`和`sysFault`都是系統級的調用。
其中`sysAlloc`、`sysReserve`和`sysMap`都是向操作系統申請內存的操作,他們均涉及關于內存分配的系統調用就是`mmap`,區別在于:
* `sysAlloc`是從操作系統上申請清零后的內存,調用參數是`_PROT_READ|_PROT_WRITE, _MAP_ANON|_MAP_PRIVATE`;
* `sysReserve`是從操作系統中保留內存的地址空間,并未直接分配內存,調用參數是`_PROT_NONE, _MAP_ANON|_MAP_PRIVATE`,;
* `sysMap`則是用于通知操作系統使用先前已經保留好的空間,參數是`_PROT_READ|_PROT_WRITE, _MAP_ANON|_MAP_FIXED|_MAP_PRIVATE`。
不過`sysAlloc`和`sysReserve`都是操作系統對齊的內存,但堆分配器可能使用更大的對齊方式,因此這部分獲得的內存都需要額外進行一些重排的工作。
```
// runtime/mem_linux.go
//go:nosplit
func sysAlloc(n uintptr, sysStat *uint64) unsafe.Pointer {
p, err := mmap(nil, n, _PROT_READ|_PROT_WRITE, _MAP_ANON|_MAP_PRIVATE, -1, 0)
if err != 0 {
if err == _EACCES {
print("runtime: mmap: access denied\n")
exit(2)
}
if err == _EAGAIN {
print("runtime: mmap: too much locked memory (check 'ulimit -l').\n")
exit(2)
}
return nil
}
(...)
return p
}
func sysReserve(v unsafe.Pointer, n uintptr) unsafe.Pointer {
p, err := mmap(v, n, _PROT_NONE, _MAP_ANON|_MAP_PRIVATE, -1, 0)
if err != 0 {
return nil
}
return p
}
func sysMap(v unsafe.Pointer, n uintptr, sysStat *uint64) {
(...)
p, err := mmap(v, n, _PROT_READ|_PROT_WRITE, _MAP_ANON|_MAP_FIXED|_MAP_PRIVATE, -1, 0)
if err == _ENOMEM {
throw("runtime: out of memory")
}
if p != v || err != 0 {
throw("runtime: cannot map pages in arena address space")
}
}
```
Linux 下內存分配調用有多個:
* brk: 可以讓進程的堆指針增長,從邏輯上消耗一塊虛擬地址空間
* mmap: 可以讓進程的虛擬地址空間切分出一塊指定大小的虛擬地址空間,mmap 映射返回的地址也是從邏輯上被消耗的,需要通過 unmap 進行回收。
熟悉 C 語言的讀者應該知道 malloc,它只是 C 語言的標準庫函數,本質上是通過上述兩個系統調用完成, 當分配內存較小時調用 brk,反之則會調用 mmap。不過 64 位系統上的 Go 運行時并沒有使用 brk,目的很明顯, 是為了能夠更加靈活的控制虛擬地址空間。
而對于 unmap 操作,它被封裝在了`sysFree`中:
```
//go:nosplit
func sysFree(v unsafe.Pointer, n uintptr, sysStat *uint64) {
(...)
munmap(v, n)
}
```
`sysUnused`、`sysUsed`是`madvice`的封裝,我們知道`madvice`用于向操作系統通知某段內存區域是否被應用所使用。`sysFault`用于將`sysAlloc`獲得的內存區域標記為故障,只用于運行時調試。
最后我們來理一下這些系統級調用的關系:
1. 當開始保留內存地址時,調用`sysReserve`;
2. 當需要使用或不適用保留的內存區域時通知操作系統,調用`sysUnused`、`sysUsed`;
3. 正式使用保留的地址,使用`sysMap`;
4. 釋放時使用`sysFree`以及調試時使用`sysFault`;
5. 非用戶態的調試、堆外內存則使用`sysAlloc`直接向操作系統獲得清零的內存
- 第一部分 :基礎篇
- 第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去向何方?