Golang 内存模型与分配机制(下) | 青训营
2023/8/28 ·雨辰login
书接上文
本篇内容引用自知乎用户@小徐先生.
这真的是一个宝藏博主,B站也有号:小徐先生1212
所以把他的笔记找来跟大家分享,希望大家都去看看,真的做的非常好。
3 对象分配流程
下面来串联 Golang 中分配对象的流程,不论是以下哪种方式,最终都会殊途同归步入 mallocgc 方法中,并且根据 3.1 小节中的策略执行分配流程:
- new(T)
- &T{}
- make(xxxx)
3.1 分配流程总览
Golang 中,依据 object 的大小,会将其分为下述三类:
不同类型的对象,会有着不同的分配策略,这些内容在 mallocgc 方法中都有体现.
核心流程类似于读多级缓存的过程,由上而下,每一步只要成功则直接返回. 若失败,则由下层方法兜底.
对于微对象的分配流程:
(1)从 P 专属 mcache 的 tiny 分配器取内存(无锁)
(2)根据所属的 spanClass,从 P 专属 mcache 缓存的 mspan 中取内存(无锁)
(3)根据所属的 spanClass 从对应的 mcentral 中取 mspan 填充到 mcache,然后从 mspan 中取内存(spanClass 粒度锁)
(4)根据所属的 spanClass,从 mheap 的页分配器 pageAlloc 取得足够数量空闲页组装成 mspan 填充到 mcache,然后从 mspan 中取内存(全局锁)
(5)mheap 向操作系统申请内存,更新页分配器的索引信息,然后重复(4).
对于小对象的分配流程是跳过(1)步,执行上述流程的(2)-(5)步;
对于大对象的分配流程是跳过(1)-(3)步,执行上述流程的(4)-(5)步.
3.2 主干方法 mallocgc
先上道硬菜,malloc 方法主干全流程展示.
如果觉得理解曲线太陡峭,可以先跳到后续小节,把拆解的各部分模块都熟悉后,再回过头来总览一遍.
代码位于 runtime/malloc.go 文件中:
func mallocgc(size uintptr, typ *_type, needzero bool) unsafe.Pointer {
// ...
// 获取 m
mp := acquirem()
// 获取当前 p 对应的 mcache
c := getMCache(mp)
var span *mspan
var x unsafe.Pointer
// 根据当前对象是否包含指针,标识 gc 时是否需要展开扫描
noscan := typ == nil || typ.ptrdata == 0
// 是否是小于 32KB 的微、小对象
if size <= maxSmallSize {
// 小于 16 B 且无指针,则视为微对象
if noscan && size < maxTinySize {
// tiny 内存块中,从 offset 往后有空闲位置
off := c.tinyoffset
// 如果大小为 5 ~ 8 B,size 会被调整为 8 B,此时 8 & 7 == 0,会走进此分支
if size&7 == 0 {
// 将 offset 补齐到 8 B 倍数的位置
off = alignUp(off, 8)
// 如果大小为 3 ~ 4 B,size 会被调整为 4 B,此时 4 & 3 == 0,会走进此分支
} else if size&3 == 0 {
// 将 offset 补齐到 4 B 倍数的位置
off = alignUp(off, 4)
// 如果大小为 1 ~ 2 B,size 会被调整为 2 B,此时 2 & 1 == 0,会走进此分支
} else if size&1 == 0 {
// 将 offset 补齐到 2 B 倍数的位置
off = alignUp(off, 2)
}
// 如果当前 tiny 内存块空间还够用,则直接分配并返回
if off+size <= maxTinySize && c.tiny != 0 {
// 分配空间
x = unsafe.Pointer(c.tiny + off)
c.tinyoffset = off + size
c.tinyAllocs++
mp.mallocing = 0
releasem(mp)
return x
}
// 分配一个新的 tiny 内存块
span = c.alloc[tinySpanClass]
// 从 mCache 中获取
v := nextFreeFast(span)
if v == 0 {
// 从 mCache 中获取失败,则从 mCentral 或者 mHeap 中获取进行兜底
v, span, shouldhelpgc = c.nextFree(tinySpanClass)
}
// 分配空间
x = unsafe.Pointer(v)
(*[2]uint64)(x)[0] = 0
(*[2]uint64)(x)[1] = 0
size = maxTinySize
} else {
// 根据对象大小,映射到其所属的 span 的等级(0~66)
var sizeclass uint8
if size <= smallSizeMax-8 {
sizeclass = size_to_class8[divRoundUp(size, smallSizeDiv)]
} else {
sizeclass = size_to_class128[divRoundUp(size-smallSizeMax, largeSizeDiv)]
}
// 对应 span 等级下,分配给每个对象的空间大小(0~32KB)
size = uintptr(class_to_size[sizeclass])
// 创建 spanClass 标识,其中前 7 位对应为 span 的等级(0~66),最后标识表示了这个对象 gc 时是否需要扫描
spc := makeSpanClass(sizeclass, noscan)
// 获取 mcache 中的 span
span = c.alloc[spc]
// 从 mcache 的 span 中尝试获取空间
v := nextFreeFast(span)
if v == 0 {
// mcache 分配空间失败,则通过 mcentral、mheap 兜底
v, span, shouldhelpgc = c.nextFree(spc)
}
// 分配空间
x = unsafe.Pointer(v)
// ...
}
// 大于 32KB 的大对象
} else {
// 从 mheap 中获取 0 号 span
span = c.allocLarge(size, noscan)
span.freeindex = 1
span.allocCount = 1
size = span.elemsize
// 分配空间
x = unsafe.Pointer(span.base())
}
// ...
return x
}
3.3 步骤(1):tiny 分配
每个 P 独有的 mache 会有个微对象分配器,基于 offset 线性移动的方式对微对象进行分配,每 16B 成块,对象依据其大小,会向上取整为 2 的整数次幂进行空间补齐,然后进入分配流程.
noscan := typ == nil || typ.ptrdata == 0
// ...
if noscan && size < maxTinySize {
// tiny 内存块中,从 offset 往后有空闲位置
off := c.tinyoffset
// ...
// 如果当前 tiny 内存块空间还够用,则直接分配并返回
if off+size <= maxTinySize && c.tiny != 0 {
// 分配空间
x = unsafe.Pointer(c.tiny + off)
c.tinyoffset = off + size
c.tinyAllocs++
mp.mallocing = 0
releasem(mp)
return x
}
// ...
}
3.4 步骤(2):mcache 分配
// 根据对象大小,映射到其所属的 span 的等级(0~66)
var sizeclass uint8
// get size class ....
// 对应 span 等级下,分配给每个对象的空间大小(0~32KB)
// get span class
spc := makeSpanClass(sizeclass, noscan)
// 获取 mcache 中的 span
span = c.alloc[spc]
// 从 mcache 的 span 中尝试获取空间
v := nextFreeFast(span)
if v == 0 {
// mcache 分配空间失败,则通过 mcentral、mheap 兜底
v, span, shouldhelpgc = c.nextFree(spc)
}
// 分配空间
x = unsafe.Pointer(v)
在 mspan 中,基于 Ctz64 算法,根据 mspan.allocCache 的 bitMap 信息快速检索到空闲的 object 块,进行返回.
代码位于 runtime/malloc.go 文件中:
func nextFreeFast(s *mspan) gclinkptr {
// 通过 ctz64 算法,在 bit map 上寻找到首个 object 空位
theBit := sys.Ctz64(s.allocCache)
if theBit < 64 {
result := s.freeindex + uintptr(theBit)
if result < s.nelems {
freeidx := result + 1
if freeidx%64 == 0 && freeidx != s.nelems {
return 0
}
s.allocCache >>= uint(theBit + 1)
// 偏移 freeindex
s.freeindex = freeidx
s.allocCount++
// 返回获取 object 空位的内存地址
return gclinkptr(result*s.elemsize + s.base())
}
}
return 0
}
3.5 步骤(3):mcentral 分配
当 mspan 无可用的 object 内存块时,会步入 mcache.nextFree 方法进行兜底.
代码位于 runtime/mcache.go 文件中:
func (c *mcache) nextFree(spc spanClass) (v gclinkptr, s *mspan, shouldhelpgc bool) {
s = c.alloc[spc]
// ...
// 从 mcache 的 span 中获取 object 空位的偏移量
freeIndex := s.nextFreeIndex()
if freeIndex == s.nelems {
// ...
// 倘若 mcache 中 span 已经没有空位,则调用 refill 方法从 mcentral 或者 mheap 中获取新的 span
c.refill(spc)
// ...
// 再次从替换后的 span 中获取 object 空位的偏移量
s = c.alloc[spc]
freeIndex = s.nextFreeIndex()
}
// ...
v = gclinkptr(freeIndex*s.elemsize + s.base())
s.allocCount++
// ...
return
}
倘若 mcache 中,对应的 mspan 空间不足,则会在 mcache.refill 方法中,向更上层的 mcentral 乃至 mheap 获取 mspan,填充到 mache 中:
代码位于 runtime/mcache.go 文件中:
func (c *mcache) refill(spc spanClass) {
s := c.alloc[spc]
// ...
// 从 mcentral 当中获取对应等级的 span
s = mheap_.central[spc].mcentral.cacheSpan()
// ...
// 将新的 span 添加到 mcahe 当中
c.alloc[spc] = s
}
mcentral.cacheSpan 方法中,会加锁(spanClass 级别的 sweepLocker),分别从 partial 和 full 中尝试获取有空间的 mspan:
代码位于 runtime/mcentral.go 文件中:
func (c *mcentral) cacheSpan() *mspan {
// ...
var sl sweepLocker
// ...
sl = sweep.active.begin()
if sl.valid {
for ; spanBudget >= 0; spanBudget-- {
s = c.partialUnswept(sg).pop()
// ...
if s, ok := sl.tryAcquire(s); ok {
// ...
sweep.active.end(sl)
goto havespan
}
// 通过 sweepLock,加锁尝试从 mcentral 的非空链表 full 中获取 mspan
for ; spanBudget >= 0; spanBudget-- {
s = c.fullUnswept(sg).pop()
// ...
if s, ok := sl.tryAcquire(s); ok {
// ...
sweep.active.end(sl)
goto havespan
}
// ...
}
}
// ...
}
// ...
// 执行到此处时,s 已经指向一个存在 object 空位的 mspan 了
havespan:
// ...
return
}
3.6 步骤(4):mheap 分配
在 mcentral.cacheSpan 方法中,倘若从 partial 和 full 中都找不到合适的 mspan 了,则会调用 mcentral 的 grow 方法,将事态继续升级:
func (c *mcentral) cacheSpan() *mspan {
// ...
// mcentral 中也没有可用的 mspan 了,则需要从 mheap 中获取,最终会调用 mheap_.alloc 方法
s = c.grow()
// ...
// 执行到此处时,s 已经指向一个存在 object 空位的 mspan 了
havespan:
// ...
return
}
经由 mcentral.grow 方法和 mheap.alloc 方法的周转,最终会步入 mheap.allocSpan 方法中:
func (c *mcentral) grow() *mspan {
npages := uintptr(class_to_allocnpages[c.spanclass.sizeclass()])
size := uintptr(class_to_size[c.spanclass.sizeclass()])
s := mheap_.alloc(npages, c.spanclass)
// ...
// ...
return s
}
代码位于 runtime/mheap.go
func (h *mheap) alloc(npages uintptr, spanclass spanClass) *mspan {
var s *mspan
systemstack(func() {
// ...
s = h.allocSpan(npages, spanAllocHeap, spanclass)
})
return s
}
代码位于 runtime/mheap.go
func (h *mheap) allocSpan(npages uintptr, typ spanAllocType, spanclass spanClass) (s *mspan) {
gp := getg()
base, scav := uintptr(0), uintptr(0)
// ...此处实际上还有一阶缓存,是从每个 P 的页缓存 pageCache 中获取空闲页组装 mspan,此处先略去了...
// 加上堆全局锁
lock(&h.lock)
if base == 0 {
// 通过基数树索引快速寻找满足条件的连续空闲页
base, scav = h.pages.alloc(npages)
// ...
}
// ...
unlock(&h.lock)
HaveSpan:
// 把空闲页组装成 mspan
s.init(base, npages)
// 将这批页添加到 heapArena 中,建立由页指向 mspan 的映射
h.setSpans(s.base(), npages, s)
// ...
return s
}
倘若对 mheap 空闲页分配器基数树 pageAlloc 分配空闲页的源码感兴趣,莫慌,3.8 小节见.
3.7 步骤(5):向操作系统申请
倘若 mheap 中没有足够多的空闲页了,会发起 mmap 系统调用,向操作系统申请额外的内存空间.
代码位于 runtime/mheap.go 文件的 mheap.grow 方法中:
func (h *mheap) grow(npage uintptr) (uintptr, bool) {
av, asize := h.sysAlloc(ask)
}
func (h *mheap) sysAlloc(n uintptr) (v unsafe.Pointer, size uintptr) {
v = sysReserve(unsafe.Pointer(p), n)
}
func sysReserve(v unsafe.Pointer, n uintptr) unsafe.Pointer {
return sysReserveOS(v, n)
}
func sysReserveOS(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
}
3.8 步骤(4)拓展:基数树寻页
核心源码位于 runtime/pagealloc.go 的 pageAlloc 方法中,要点都以在代码中给出注释:
func (p *pageAlloc) find(npages uintptr) (uintptr, offAddr) {
// 必须持有堆锁
assertLockHeld(p.mheapLock)
// current level.
i := 0
// ...
lastSum := packPallocSum(0, 0, 0)
lastSumIdx := -1
nextLevel:
// 1 ~ 5 层依次遍历
for l := 0; l < len(p.summary); l++ {
// ...
// 根据上一层的 index,映射到下一层的 index.
// 映射关系示例:上层 0 -> 下层 [0~7]
// 上层 1 -> 下层 [8~15]
// 以此类推
i <<= levelBits[l]
entries := p.summary[l][i : i+entriesPerBlock]
// ...
// var levelBits = [summaryLevels]uint{
// 14,3,3,3,3
// }
// 除第一层有 2^14 个节点外,接下来每层都只要关心 8 个 节点.
// 由于第一层有 2^14 个节点,所以 heap 内存上限为 2^14 * 16G = 256T
var base, size uint
for j := j0; j < len(entries); j++ {
sum := entries[j]
// ...
// 倘若当前节点对应内存空间首部即满足,直接返回结果
s := sum.start()
if size+s >= uint(npages) {
if size == 0 {
base = uint(j) << logMaxPages
}
size += s
break
}
// 倘若当前节点对应内存空间首部不满足,但是内部最长连续页满足,则到下一层节点展开搜索
if sum.max() >= uint(npages) {
i += j
lastSumIdx = i
lastSum = sum
continue nextLevel
}
// 即便内部最长连续页不满足,还可以尝试将尾部与下个节点的首部叠加,看是否满足
if size == 0 || s < 1<<logMaxPages {
size = sum.end()
base = uint(j+1)<<logMaxPages - size
continue
}
// The entry is completely free, so continue the run.
size += 1 << logMaxPages
}
// 根据 i 和 j 可以推导得到对应的内存地址,进行返回
ci := chunkIdx(i)
addr := chunkBase(ci) + uintptr(j)*pageSize
// ...
return addr, p.findMappedAddr(firstFree.base)
}
4 展望
祝贺,到此为止,整个 Golang 内存分配流程已经梳理完毕.