06. Small 对象分配:mcache 无锁路径

1. Small 对象分配概述

摘要:本文深入解析 Go 语言 Small 对象(16B~32KB)分配机制。核心路径通过 mcache.alloc 数组按 spanClass 索引直接获取 mspan,利用 allocCache 64位补码位图和 CPU CTZ 指令实现 O(1) 空闲槽查找。分配分为 nextFreeFast 快速路径(无锁)和 nextFreeIndex 慢速路径(可能触发 refill)。mcache 采用 per-P 无锁设计,仅 refill 时接触 mcentral 锁。文章还介绍了实验性特性 mallocgcSmallNoscanReuse 可重用链表机制,允许 noscan 对象立即复用以减少 GC 压力。

当对象大小在 16B ~ 32KB 之间时,走 Small 分配路径。它与 Tiny 路径的最大区别是:每个对象独占一个槽位,从对应的 size class span 中分配。

分界常量来自 internal/runtime/gc/sizeclasses.goMaxSmallSize = 32768(32KB,sizeclasses.go:86)。实际判据是 size > MaxSmallSize - MallocHeaderSize = 32760(见 04 篇),"超过 32KB"是近似说法——超过 32760 字节的请求就走 mallocgcLarge 直接分配整页。

Small 分配的核心链路:

mallocgc  查 size class 表  c.alloc[spc]  nextFreeFast(快速)
                                                失败
                                            c.nextFree  nextFreeIndex  c.refill

分配的核心就一句话:找到该 size class 的空闲槽位。难点在于如何又快又准地找。


2. mcache 结构详解:alloc 数组

mcache 是 Small 分配的主战场,定义在 mcache.go:20-66

type mcache struct {
    // 以下字段每次 malloc 都访问,故分组放在前面以获得更好的缓存局部性
    nextSample  int64   // 触发堆采样还需分配多少字节 (25)
    memProfRate int     // 缓存的 mem profile rate,用于检测变化 (26)
    scanAlloc   uintptr // 已分配的需扫描字节数 (27)

    tiny       uintptr // 当前 tiny block 起点 (41)
    tinyoffset uintptr // tiny block 内偏移 (42)
    tinyAllocs uintptr // tiny 分配计数 (43)

    alloc [numSpanClasses]*mspan // 按 spanClass 索引的分配 span 数组 (48)

    reusableNoscan [numSpanClasses]gclinkptr // 可重用 noscan 对象链表 (57)

    stackcache [_NumStackOrders]stackfreelist // 栈缓存 (59)

    flushGen atomic.Uint32 // 上次 flush 时的 sweepgen (65)
}

— mcache.go:20-66

在这里插入图片描述

图 6-1:mcache 结构——alloc 数组按 spanClass 索引,指向对应的 mspan

核心数组 alloc [numSpanClasses]*mspan

// alloc contains spans to allocate from, indexed by spanClass.
alloc [numSpanClasses]*mspan

— mcache.go:47-48

这是 Small 分配最核心的数据结构:

  • numSpanClasses = gc.NumSizeClasses << 1(mheap.go:576),即 136 项(68 个 size class × scan/noscan 两维)
  • 下标 = spanClassspanClass = sizeclass << 1 | noscan(mheap.go:580-581),偶数索引是 scan,奇数索引是 noscan
  • 每个元素是一个 *mspan 指针,指向该大小类当前正在"消费"的 span

一次分配发生了什么? 分配器拿着计算出的 spc,直接 span := c.alloc[spc] 拿到 span,再从 span 的空闲位图里取一个槽位——没有任何锁、没有任何遍历,两个操作就完成了。

alloc 数组与 mspan 的关系、以及热点字段分组,见上方图 6-1。注意源码把"每次 malloc 都访问"的字段(25-43 行)放在结构体最前面,利用 CPU 缓存行局部性——这是源码注释(so they are grouped here for better caching, mcache.go:23-24)明确说明的优化。


3. 三条分配路径:Noscan / ScanNoHeader / ScanHeader

mallocgcmalloc.go:1122-1164 按对象类型分派到三条 Small 路径:

if size <= maxSmallSize-gc.MallocHeaderSize {
    if typ == nil || !typ.Pointers() {
        // 不含指针  noscan 路径
        x, elemsize = mallocgcSmallNoscan(size, typ, needzero)
    } else {
        // 含指针  scan 路径,按是否需要 MallocHeader 再分两支
        if heapBitsInSpan(size) {
            x, elemsize = mallocgcSmallScanNoHeader(size, typ) // 指针位图在 span 内
        } else {
            x, elemsize = mallocgcSmallScanHeader(size, typ)   // 指针位图用对象头
        }
    }
} else {
    x, elemsize = mallocgcLarge(size, typ, needzero)
}

— malloc.go:1140-1163(简化)

三条路径的分工:

路径适用对象类型信息存放源码行号
mallocgcSmallNoscan无指针(数字、字符串 data 等)不需要malloc.go:1360-1454
mallocgcSmallScanNoHeader含指针且 heapBitsInSpan(size) 为真指针位图放在 span 的 heap bits 里malloc.go:1505-1594
mallocgcSmallScanHeader含指针但太大,位图放不下8 字节 MallocHeader 内嵌 *_typemalloc.go:1596-1687

MallocHeader 机制(Go ~1.24 新特性):当对象大到 span 内的 heap bits 装不下其指针位图时,改为在对象头部嵌入一个 8 字节的 *_type 指针,让 GC 通过类型信息推断指针布局。分界常量 MinSizeForMallocHeader = PtrSize × PtrBitsinternal/runtime/gc/malloc.go:47)。heapBitsInSpan(size) 判断哪种方案适用。

三条路径的公共骨架

虽然细节不同,三条路径的骨架完全一致(以 noscan 为例):

func mallocgcSmallNoscan(size uintptr, typ *_type, needzero bool) (unsafe.Pointer, uintptr) {
    mp := acquirem()                    // ① 防抢占 (1362)
    mp.mallocing = 1                    // ② 标记分配中 (1374)
    c := getMCache(mp)                  // ③ 取 mcache (1377)

    // ④ 查 size class 表 (1379-1384)
    var sizeclass uint8
    if size <= gc.SmallSizeMax-8 {
        sizeclass = gc.SizeToSizeClass8[divRoundUp(size, gc.SmallSizeDiv)]
    } else {
        sizeclass = gc.SizeToSizeClass128[divRoundUp(size-gc.SmallSizeMax, gc.LargeSizeDiv)]
    }
    size = uintptr(gc.SizeClassToSize[sizeclass])
    spc := makeSpanClass(sizeclass, true) // noscan  true
    span := c.alloc[spc]                  // ⑤ 直接取 span

    // ⑥ GreenTeaGC:先看有没有可重用对象 (1389-1395)
    if runtimeFreegcEnabled && c.hasReusableNoscan(spc) {
        x := mallocgcSmallNoscanReuse(c, span, spc, size, needzero)
        return x, size
    }

    // ⑦ 快速路径:nextFreeFast (1397-1400)
    v := nextFreeFast(span)
    if v == 0 {
        v, span, checkGCTrigger = c.nextFree(spc) // ⑧ 慢速路径
    }
    x := unsafe.Pointer(v)

    // ⑨ 按需清零 (1402-1404)
    if needzero && span.needzero != 0 {
        memclrNoHeapPointers(x, size)
    }
    // ... publicationBarrier、gcmarknewobject、采样、GC trigger 检查
    return x, size
}

— malloc.go:1360-1454(注释为笔者添加)

Size class 查表算法

if size <= gc.SmallSizeMax-8 { // SmallSizeMax = 1024
    sizeclass = gc.SizeToSizeClass8[divRoundUp(size, gc.SmallSizeDiv)]     // 8B 步长查表
} else {
    sizeclass = gc.SizeToSizeClass128[divRoundUp(size-gc.SmallSizeMax, gc.LargeSizeDiv)] // 128B 步长
}
size = uintptr(gc.SizeClassToSize[sizeclass])

— malloc.go:1379-1384

两段式查表:≤1024B 用 8 字节步长的查表(SizeToSizeClass8),>1024B 用 128 字节步长的查表(SizeToSizeClass128)。这样既保证小尺寸的精度,又控制表的大小。详见 Wiki 03。


4. nextFreeFast:CPU CTZ 指令的 O(1) 查找

找到 span 之后,最核心的问题是:如何在 span 的几百上千个槽位中,瞬间找到一个空闲槽?

答案是 nextFreeFast(malloc.go:969-985),它用一条 CPU 指令完成:

func nextFreeFast(s *mspan) gclinkptr {
    theBit := sys.TrailingZeros64(s.allocCache) // Is there a free object in the allocCache?
    if theBit < 64 {
        result := s.freeindex + uint16(theBit)
        if result < s.nelems {
            freeidx := result + 1
            if freeidx%64 == 0 && freeidx != s.nelems {
                return 0
            }
            s.allocCache >>= uint(theBit + 1)
            s.freeindex = freeidx
            s.allocCount++
            return gclinkptr(uintptr(result)*s.elemsize + s.base())
        }
    }
    return 0
}

— malloc.go:969-985

逐行拆解

  1. sys.TrailingZeros64(s.allocCache)(malloc.go:970):CPU 的 CTZ(Count Trailing Zeros)指令,一条硬件指令返回 allocCache 中从低位起连续 0 的个数。因为 allocCachebit=1 表示空闲,所以 trailing zeros 的个数恰好是第一个空闲槽在窗口内的偏移
  2. result := s.freeindex + theBit(malloc.go:972):窗口起点 freeindex 加上偏移,得到绝对槽位号。
  3. result < s.nelems(malloc.go:973):槽位不能超出 span 的对象总数。
  4. freeidx%64 == 0 && freeidx != s.nelems(malloc.go:975-976):如果 freeindex 恰好越过了 64 位窗口边界,说明当前窗口缓存已不可用,必须交给慢速路径去重新加载(返回 0)。
  5. s.allocCache >>= theBit + 1(malloc.go:978):把这个空闲位"消费"掉(右移清除)。
  6. s.freeindex = freeidx / s.allocCount++(malloc.go:979-980):推进索引、计数。
  7. return gclinkptr(uintptr(result)*s.elemsize + s.base())(malloc.go:981):返回槽位地址 = base + 槽位号 × 槽位大小

在这里插入图片描述

图 6-2:nextFreeFast 用一条 CTZ 指令定位空闲槽位;失败时回退到 nextFreeIndex 慢速路径

为什么是 O(1)? 传统的空闲链表或逐位循环需要 O(n) 时间找到空闲位。而 CTZ 是一条硬件指令,无论窗口里有多少位、空闲槽在哪,都是固定周期完成。对 span 中大量空闲的情况,这就是平均 1-2 条指令就完成一次分配。


5. allocCache:64 位补码位图缓存

nextFreeFast 能 O(1) 的关键,在于 mspan 里的 allocCache 字段。

allocBits vs allocCache

  • allocBits(mheap.go:490):完整分配位图,每个对象槽位 1 bit,bit=1 表示已分配。一个 8KB 页、16B 槽位的 span 有 512 个对象,对应 512 bit = 64 字节的位图。
  • allocCache(mheap.go:466):当前窗口的 64 位补码缓存,即 allocCache = ^allocBits[freeindex ... freeindex+63]

为什么要补码?allocBits1=已分配、0=空闲;如果直接对 allocBits 做 trailing zeros,找到的是第一个已分配的槽,方向反了。取补码后 1=空闲,trailing zeros 找到的才是第一个空闲槽——正好是我们要的。这是利用位运算"省一次减法"的巧妙设计。

// allocCache is a cache of allocBits, with the low bits
// of the cache corresponding to freeindex. It is used
// to speed up allocation.
allocCache uint64

— mheap.go:465-467

窗口的移动

每次分配后 allocCache >>= theBit + 1(malloc.go:978),窗口内的位被逐个"消费"。当窗口耗尽(全部为 0,TrailingZeros64 返回 64)或跨越 64 位边界时,用 refillAllocCacheallocBits 重新加载下一段 64 位(mbitmap.go:1137、mbitmap.go:1159)。

为什么是 64 位窗口?恰好是 uint64 的宽度,正好对应一条 CTZ 指令的操作数大小,也和 CPU 的寄存器宽度对齐。64 位是"一次硬件操作能处理的最大窗口"。


6. nextFree 慢速路径:nextFreeIndex 逐位扫描

nextFreeFast 返回 0(窗口耗尽或跨边界)时,进入 mcache.nextFree(malloc.go:996-1024):

func (c *mcache) nextFree(spc spanClass) (v gclinkptr, s *mspan, checkGCTrigger bool) {
    s = c.alloc[spc]
    checkGCTrigger = false
    freeIndex := s.nextFreeIndex()          // 逐位扫描(可能触发窗口 refill)
    if freeIndex == s.nelems {
        // The span is full.
        c.refill(spc)                       // ① span 用完了  从 mcentral 换新 span
        checkGCTrigger = true
        s = c.alloc[spc]
        freeIndex = s.nextFreeIndex()
    }
    v = gclinkptr(uintptr(freeIndex)*s.elemsize + s.base())
    s.allocCount++
    return
}

— malloc.go:996-1024

nextFreeIndex:逐位扫描 + 窗口重载

nextFreeIndex(mbitmap.go:1115-1163)是真正"找遍整个 span"的函数:

func (s *mspan) nextFreeIndex() uint16 {
    ...
    aCache := s.allocCache
    bitIndex := sys.TrailingZeros64(aCache)
    for bitIndex == 64 {                    // 当前窗口全 0
        // Move index to start of next cached bits.
        sfreeindex = (sfreeindex + 64) &^ (64 - 1)
        if sfreeindex >= snelems {
            return snelems                  // 整个 span 都满了
        }
        whichByte := sfreeindex / 8
        s.refillAllocCache(whichByte)       // 从 allocBits 重新加载下一段 64 位
        aCache = s.allocCache
        bitIndex = sys.TrailingZeros64(aCache)
    }
    result := sfreeindex + uint16(bitIndex)
    ...
    return result
}

— mbitmap.go:1115-1163

它和 nextFreeFast 的区别nextFreeFast 只检查当前窗口(一次 CTZ,不循环);nextFreeIndex 在窗口耗尽时循环重载 allocCache,一窗口一窗口地扫完整个 span。二者共用同一套 CTZ 逻辑。

为什么有两条路径? nextFreeFast 是热路径,必须极简——一次 CTZ、几条赋值就返回。nextFreeIndex 是冷路径,允许循环和内存加载。把"常见情况"和"少见情况"分离,是 Go runtime 优化的核心手法。

整个 span 都用完时:refill

freeIndex == s.nelems 表示当前 span 已满(malloc.go:1000-1008),调用 c.refill(spc)

  • 把已满的 span 归还给 mcentral
  • 从 mcentral 取一个新的有空间 span 放到 c.alloc[spc]
  • checkGCTrigger = true,让调用方检查是否需要触发 GC(因为这是一次"重量级"分配)

refill 是 Small 分配里唯一会碰到的地方(mcentral 的 class 级锁)。这也是为什么"慢速路径才可能加锁"——绝大多数分配都在 nextFreeFast 无锁完成。refill 的详细流程见 Wiki 07。


7. Per-P 的无锁设计:为什么 mcache 不需要锁

这是整个分配器性能的灵魂问题:为什么 c.alloc[spc] 可以直接读写而不加锁?

答案:mcache 与 P 一一绑定

// Per-thread (in Go, per-P) cache for small objects.
// This includes a small object cache and local allocation stats.
// No locking needed because it is per-thread (per-P).

— mcache.go:14-16

关键约束链:

  1. 每个 P 拥有唯一的 mcache(P 结构体里持有 mcache 指针)。
  2. 同一时刻一个 P 上只能有一个 M 在跑(Go 调度器的 P-M 绑定关系)。
  3. 分配时通过 acquirem 锁定当前 M,防止分配中途被抢占、P 被切换(malloc.go:1206、malloc.go:1362)。
  4. 因此,访问 c.alloc[spc] 的线程,必然是持有该 P 的线程——不存在并发访问,自然不需要锁。

一句话理解mcache 是"每个人口袋里的钱包",GOMAXPROCS 个钱包各归各的,谁也不会抢谁的。只有钱包空了(refill)才需要去"银行的保险库"(mcentral/mheap)取钱——那里才有锁。

// No locking needed because it is per-thread (per-P).
// mcaches are allocated from non-GC'd memory, so any heap pointers
// must be specially handled.

— mcache.go:16-19

为什么不用"每线程一个缓存"?

传统 TCMalloc 是 per-thread 缓存。Go 选择 per-P 而非 per-M(线程),原因是:

  • M 的数量可变(可多于 GOMAXPROCS),per-thread 缓存会导致内存浪费
  • P 的数量固定(= GOMAXPROCS),缓存数量确定、内存可控
  • goroutine 可在 M 之间迁移,如果缓存挂在 M 上,goroutine 换线程就换缓存,缓存命中率低

代价:P 会被调度器定时刷新(flush)。GC 标记阶段需要 releaseAll 清理 mcache 中的 span(尤其是 tiny 指针,因为 mcache 在非 GC 内存里,mheap.go 相关处理见 acquirep/releaseAll)。flushGen 字段(mcache.go:65)就是用来追踪"这个 mcache 的 span 是否还新鲜"的。

无锁分配的多级防护

机制作用源码
P 独占 mcache数据结构层面的隔离mcache.go:14-16
acquirem/releasem分配期间禁止抢占malloc.go:1206
mp.mallocing标记分配中,GC 不可抢占malloc.go:1218
refill 才加锁把锁限制在慢速路径refill 定义于 mcache.go:160,调用点 malloc.go:1006

8. 新特性:mallocgcSmallNoscanReuse 可重用链表

这是基于 GOEXPERIMENT=runtimeFreegc 实验特性的新代码路径(GreenTeaGC 的一部分)。它让 noscan 对象在释放后可以直接复用,避免重新分配。

动机

常规分配中,对象释放后,槽位要等 GC 清扫后才会再次可用。但 noscan 对象没有指针,可以安全地立即复用——不用等 GC。这能显著减少分配次数和 GC 压力。

数据结构:reusableNoscan 链表

// reusableNoscan contains linked lists of reusable noscan heap objects, indexed by spanClass.
// The next pointers are stored in the first word of the heap objects.
reusableNoscan [numSpanClasses]gclinkptr

— mcache.go:55-57

每个 spanClass 一个无锁链表头,链表节点就是空闲的 noscan 对象本身——next 指针存在对象的第一个字里(不额外占内存)。

分配时的复用检查

// First, check for a reusable object.
if runtimeFreegcEnabled && c.hasReusableNoscan(spc) {
    // We have a reusable object, use it.
    x := mallocgcSmallNoscanReuse(c, span, spc, size, needzero)
    return x, size
}

— malloc.go:1388-1395

func (c *mcache) hasReusableNoscan(spc spanClass) bool {
    if !runtimeFreegcEnabled {
        return false
    }
    return c.reusableNoscan[spc] != 0
}

— mcache.go:384-391

复用路径的差异

mallocgcSmallNoscanReuse(malloc.go:1456-1503)与普通分配有几处关键差异:

  1. 不检查 span.needzero,只检查 needzero(malloc.go:1474-1478):对象曾被使用过,但当前调用方要求零值才清零。
  2. 补偿 GC assist 信用(malloc.go:1470-1472):复用不是"新分配",但 mallocgc 入口已扣除过 assist 信用,这里要加回来,保持记账平衡。
  3. 不做 freeIndexForScan 更新、不做 profile、不检查 GC trigger(malloc.go:1483-1484):这些是"新分配"特有的副作用,复用路径一律跳过。

实验性限制(源码 TODO 注释,malloc.go:1485-1494):复用对象不计入 allocs/frees 统计,会导致 pprof 的堆画像有偏差。这是该特性目前停留在 GOEXPERIMENT 阶段、尚未转正的原因之一。

释放端:谁来把对象放进链表?

真正把 noscan 对象挂进链表的是释放入口 freegcmalloc.go:2007 附近,GOEXPERIMENT=runtimeFreegc)调用 c.addReusableNoscanmcache.go:371-382):

// mcache.go:373-382
func (c *mcache) addReusableNoscan(spc spanClass, ptr uintptr) {
	if !runtimeFreegcEnabled {
		return
	}
	// Add to the reusable pointers free list.
	v := gclinkptr(ptr)
	v.ptr().next = c.reusableNoscan[spc]
	c.reusableNoscan[spc] = v
}

noscan 对象被释放时,若满足条件,会被 push 进对应 spanClass 的 reusableNoscan 链表;下次同大小类的分配直接复用。而 refillmcache.go:168-175)在换 span 时会做相反的清理:若 spc == tinySpanClass 直接清空 reusableNoscan[spc]——因为 tiny 对象是合并分配的,不能单独复用(源码 TODO 也点名了这一限制)。


评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

互联网中的一颗神经元

君若认可拙文,薄赏便是前行微光

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值