08. mcentral:中心缓存的 span 管理

08. mcentral:中心缓存的 span 管理

摘要 :mcentral 是 TCMalloc 内存分配器的中心缓存层,作为 mcache(线程缓存)和 mheap(全局堆)之间的桥梁。它通过 partial[2]/full[2] 双缓冲设计实现零数据搬移的角色互换,采用四级查找策略(partialSwept → partialUnswept → fullUnswept → grow)高效分配 span,并通过 spanBudget=100 机制平衡清扫开销与空间浪费。mcentral 的核心价值在于为同 size class 的所有 P 提供共享的 span 池,减少对全局堆锁的竞争。

1. mcentral 是什么?

mcentral 是 TCMalloc 分级缓存架构中的第二级缓存 。在上一篇 07. mcache.refill 中我们提到,mcache 的 span 用尽后,会调用 mcentral.cacheSpan() 获取新 span。现在我们把视角移到 mcentral 内部。

mcentral.go 开头的注释解释了它的本质:

go 复制代码
// Central free lists.
//
// See malloc.go for an overview.
//
// The mcentral doesn't actually contain the list of free objects; the mspan does.
// Each mcentral is two lists of mspans: those with free objects (c->nonempty)
// and those that are completely allocated (c->empty).

--- mcentral.go:5-11
一句话理解 :mcentral 不直接管理空闲对象 ------空闲对象仍然躺在 mspan 自己的分配位图里。mcentral 只负责管理一批 mspan ,按"有没有空闲槽位"分成 partial(部分空闲)和 full(完全分配)两类集合。

mheap 中,每种 spanClass 对应一个 mcentral,构成一个数组:

go 复制代码
// mheap.go(示意)
central [numSpanClasses]struct {
    mcentral mcentral
    pad      [cpu.CacheLinePadSize - unsafe.Sizeof(mcentral{})%cpu.CacheLinePadSize]byte
}

--- mheap.go(central 数组,含 cache line padding 防伪共享)
每个 P 有一个 mcache,但 mcentral 是全局的、按 class 划分的 。也就是说,同一种 size class 的所有 mcentral 只有一份,多个 P 会竞争同一个 mcentral,因此 mcentral 内部需要并发控制(spanSet 的无锁操作 + sweep 期间的锁)。这就是为什么它比 mcache 慢、但比 mheap 快。


2. 结构全解:partial2 / full2 双缓冲

mcentral 结构体只有 4 个字段:

go 复制代码
// Central list of free objects of a given size.
type mcentral struct {
    _         sys.NotInHeap
    spanclass spanClass

    // partial and full contain two mspan sets: one of swept in-use
    // spans, and one of unswept in-use spans. These two trade
    // roles on each GC cycle. The unswept set is drained either by
    // allocation or by the background sweeper in every GC cycle,
    // so only two roles are necessary.
    //
    // sweepgen is increased by 2 on each GC cycle, so the swept
    // spans are in partial[sweepgen/2%2] and the unswept spans are in
    // partial[1-sweepgen/2%2]. Sweeping pops spans from the
    // unswept set and pushes spans that are still in-use on the
    // swept set. Likewise, allocating an in-use span pushes it
    // on the swept set.
    partial [2]spanSet // list of spans with a free object
    full    [2]spanSet // list of spans with no free objects
}

--- mcentral.go:22-46

字段 类型 含义
spanclass spanClass 该 mcentral 负责的 size class(含 scan/noscan 维度)
partial[2] spanSet 部分空闲的 span 集合(还有空闲槽位)
full[2] spanSet 完全分配的 span 集合(无空闲槽位)

为什么 partial 和 full 各有两个?

因为每个集合内部又按 GC 状态分成两半:已清扫(swept)未清扫(unswept)

  • partial[0] / partial[1]:部分空闲的 span,其中一个是"已清扫",另一个是"未清扫"
  • full[0] / full[1]:完全分配的 span,同样一个是"已清扫",一个是"未清扫"

spanSet 是什么? spanSet 是并发安全的无锁 span 集合,用 spine + block 两级结构 + 原子索引实现无锁 Push/Pop。具体见 15. spanSet。这里只需知道它支持并发的 push / pop

四个访问器函数封装了索引计算:

go 复制代码
func (c *mcentral) partialUnswept(sweepgen uint32) *spanSet { return &c.partial[1-sweepgen/2%2] }
func (c *mcentral) partialSwept(sweepgen uint32) *spanSet   { return &c.partial[sweepgen/2%2] }
func (c *mcentral) fullUnswept(sweepgen uint32) *spanSet    { return &c.full[1-sweepgen/2%2] }
func (c *mcentral) fullSwept(sweepgen uint32) *spanSet      { return &c.full[sweepgen/2%2] }

--- mcentral.go:59-79


3. 双缓冲设计精髓:角色互换,零数据搬移

这是 mcentral 最优雅的设计。每个 GC 周期 mheap_.sweepgen += 2,于是:

  • sweepgen/2%2 在 0 和 1 之间交替
  • 已清扫和未清扫的集合自动互换角色
  • 数组里的数据不需要移动一个字节

图 8-1:mcentral 双缓冲设计:swept / unswept 角色互换

为什么只需要两个角色?

源码注释给出了精辟的解释:

go 复制代码
// The unswept set is drained either by allocation or by the background
// sweeper in every GC cycle, so only two roles are necessary.

--- mcentral.go:28-30

未清扫集合在每个 GC 周期内必定会被清空(要么被分配路径清扫,要么被后台清扫器清扫),所以最多只需要区分"当前周期的未清扫集合"和"当前周期的已清扫集合"两个角色。多一个都浪费,少一个都不够。

具体机制

假设当前 sweepgen = 0

  1. partial[0] = 已清扫(partialSwept),partial[1] = 未清扫(partialUnswept
  2. 分配路径从 partial[0] 直接 pop 使用;从未清扫集合 pop 出来的 span 需要先 sweep
  3. 后台清扫器从 partial[1] / full[1] 弹出 span 清扫,扫完有空的推入 partial[0],仍满的推入 full[0]
  4. GC 结束,sweepgen += 2 现在 sweepgen/2%2 = 1
  5. 于是 partial[0] 变成了"未清扫"(它里面装的是上代清扫过的、被缓存使用的 span),partial[1] 变成了"已清扫"

为什么被缓存使用的 span 会出现在"未清扫"里? 下一节 uncacheSpan 会讲到:一个 span 在 sweep 之后被某个 P 缓存,sweepgen 设为 +3。这个 span 离开缓存时,它的 sweepgen 会先被"降回"当前代,而它在上一代 sweep 时的旧集合索引(1-sweepgen/2%2)在新周期 解读下就成了"未清扫"。这就是为什么"上代已清扫的 span"会出现在"本代未清扫"集合里------它需要在新的 GC 周期里重新被清扫验证(因为缓存期间可能又有对象被分配/回收)。
零数据搬移的代价:数组本身不动,但"未清扫集合里可能混着其实已经扫过的 span"。因此注释里特别提醒:

go 复制代码
// Some parts of the sweeper can sweep arbitrary spans, and hence
// can't remove them from the unswept set, but will add the span
// to the appropriate swept list. As a result, the parts of the
// sweeper and mcentral that do consume from the unswept list may
// encounter swept spans, and these should be ignored.

--- mcentral.go:39-43

这就是为什么 cacheSpan 里对 unswept 集合的 pop 要做 tryAcquire 校验(见第 4 节)。


4. cacheSpan:四级查找策略

cacheSpan 是 mcentral 的核心分配函数 ,被 mcache 的 refill 调用。它按"由快到慢"的顺序做四级查找:

图 8-2:cacheSpan 的四级查找策略

go 复制代码
// Allocate a span to use in an mcache.
func (c *mcentral) cacheSpan() *mspan {
    // Deduct credit for this span allocation and sweep if necessary.
    spanBytes := uintptr(gc.SizeClassToNPages[c.spanclass.sizeclass()]) * pageSize
    deductSweepCredit(spanBytes, 0)
    ...
    spanBudget := 100

    var s *mspan
    var sl sweepLocker

    // ① Try partial swept spans first.
    sg := mheap_.sweepgen
    if s = c.partialSwept(sg).pop(); s != nil {
        goto havespan
    }

    sl = sweep.active.begin()
    if sl.valid {
        // ② Now try partial unswept spans.
        for ; spanBudget >= 0; spanBudget-- {
            s = c.partialUnswept(sg).pop()
            if s == nil {
                break
            }
            if s, ok := sl.tryAcquire(s); ok {
                // 我们抢到了这个 span,清扫后使用
                s.sweep(true)
                sweep.active.end(sl)
                goto havespan
            }
            // 没抢到:它正在/已经被异步清扫器处理,忽略
        }

        // ③ Now try full unswept spans, sweeping them...
        for ; spanBudget >= 0; spanBudget-- {
            s = c.fullUnswept(sg).pop()
            if s == nil {
                break
            }
            if s, ok := sl.tryAcquire(s); ok {
                s.sweep(true)
                // 检查清扫后有没有空闲
                freeIndex := s.nextFreeIndex()
                if freeIndex != s.nelems {
                    s.freeindex = freeIndex
                    sweep.active.end(sl)
                    goto havespan
                }
                // 扫完还是满的,放回 fullSwept
                c.fullSwept(sg).push(s.mspan)
            }
        }
        sweep.active.end(sl)
    }
    ...
    // ④ We failed to get a span from the mcentral so get one from mheap.
    s = c.grow()
    if s == nil {
        return nil
    }

havespan:
    // 初始化 allocCache
    n := int(s.nelems) - int(s.allocCount)
    if n == 0 || s.freeindex == s.nelems || s.allocCount == s.nelems {
        throw("span has no free objects")
    }
    freeByteBase := s.freeindex &^ (64 - 1)
    whichByte := freeByteBase / 8
    s.refillAllocCache(whichByte)

    // Adjust the allocCache so that s.freeindex corresponds to the low bit in
    // s.allocCache.
    s.allocCache >>= s.freeindex % 64
    return s
}

--- mcentral.go:82-199

四级查找的详细分析

级别 来源 是否清扫 成本 源码位置
partialSwept 否(已清扫) O(1) pop mcentral.go:114-116
partialUnswept 是(sweep 后复用) sweep + 尝试 mcentral.go:121-138
fullUnswept 是(sweep 后可能复用) sweep + 尝试 mcentral.go:141-160
grow() mheap 新分配 全局锁 + 可能 mmap mcentral.go:171-174

① partialSwept:最快的路径

go 复制代码
if s = c.partialSwept(sg).pop(); s != nil {
    goto havespan
}

--- mcentral.go:114-116

已清扫且部分空闲的 span 是最理想的候选------内存干净、有现成的空闲槽位,直接拿走即可。这是每次 GC 后大量存在的最常见场景。

② ③ partialUnswept / fullUnswept:清扫后复用

这两级的关键是 sweepLocker.tryAcquire

go 复制代码
if s, ok := sl.tryAcquire(s); ok {
    // 我们抢到了这个 span,清扫后使用
    s.sweep(true)
    ...
}

--- mcentral.go:126-131

为什么需要 tryAcquire? 因为第 3 节说过,未清扫集合里可能混着被其他清扫者抢占的 span。tryAcquire 用原子的方式尝试"认领"这个 span 的清扫权:

  • 成功:这个 P 负责清扫它,扫完直接使用(partial)或检查有没有空闲(full)
  • 失败 :它正在被异步清扫器处理,当前 P 直接跳过------绝不能重复清扫

第 ③ 级有个特殊处理:如果 full span 扫完之后仍然没有空闲槽位 (所有对象都存活),那就把它放回 fullSwept,继续尝试下一个:

go 复制代码
freeIndex := s.nextFreeIndex()
if freeIndex != s.nelems {
    s.freeindex = freeIndex
    goto havespan   // 扫出空闲了,用这个
}
c.fullSwept(sg).push(s.mspan)  // 扫完还是满的,放回已清扫的 full

--- mcentral.go:150-157

havespan:allocCache 重建

无论从哪一级拿到 span,最终都汇聚到 havespan 标签:

go 复制代码
havespan:
    freeByteBase := s.freeindex &^ (64 - 1)
    whichByte := freeByteBase / 8
    // 重建 64 位空闲缓存
    s.refillAllocCache(whichByte)
    // 让 freeindex 对应 allocCache 的最低位
    s.allocCache >>= s.freeindex % 64
    return s

--- mcentral.go:177-198

refillAllocCacheallocBits 位图重建 64 位补码缓存,allocCache >>= freeindex % 64 把 freeindex 对齐到低位------这样 mcache 的 nextFreeFast 就能直接用 CTZ(trailingzeros)指令 O(1) 找到第一个空闲槽位。这正好接上 06. Small 对象分配 讲的快速路径。


5. spanBudget = 100:清扫开销与空间浪费的权衡

spanBudgetcacheSpan 里最有意思的工程参数:

go 复制代码
// If we sweep spanBudget spans without finding any free
// space, just allocate a fresh span. This limits the amount
// of time we can spend trying to find free space and
// amortizes the cost of small object sweeping over the
// benefit of having a full free span to allocate from. By
// setting this to 100, we limit the space overhead to 1%.
spanBudget := 100

--- mcentral.go:94-107

它解决什么问题?

想象一个极端场景:堆里有很多 partial/full 的 unswept span,每个只有一两个空闲槽位。如果无限循环扫下去,一个 cacheSpan 调用可能清扫几十上百个 span,分配延迟飙高(虽然吞吐未必差)。

spanBudget = 100 设了一个上限:最多尝试清扫 100 个 unswept span(partial 和 full 各占额度),找不到就放弃,直接 grow() 分配新 span。

为什么 100 恰好对应"1% 空间开销"? 假设每个被扫的 span 平均只提供一个空闲槽位,而每个 span 通常有几十上百个对象。扫 100 个 span 只为拿到 1 个槽位,新分配的 span 里就会留下约 1% 的"浪费"空间(本来可以全用上的)。100 这个值正好把空间开销控制在 ~1% 以内。

权衡的本质

方向 选择 后果
增大 budget 多扫几个 span 空间浪费更小,但分配延迟更高
减小 budget 少扫 span,早分配新 span 延迟更低,但空间浪费更大
= 100 平衡点 延迟可控,空间开销 ~1%

已知局限:源码注释也承认这是个折中方案,最坏情况下可能扫 100 个 span 才拿到一个槽位(延迟被限制,但吞吐很差)。TODO 里提到将来可以用"持续的 free-to-used 预算"来替代。

go 复制代码
// TODO(austin,mknyszek): This still has bad worst-case
// throughput. For example, this could find just one free slot
// on the 100th swept span. That limits allocation latency, but
// still has very poor throughput. We could instead keep a
// running free-to-used budget and switch to fresh span
// allocation if the budget runs low.

--- mcentral.go:101-106


6. uncacheSpan:归还逻辑与 stale 判定

uncacheSpancacheSpan 的逆操作,被 mcache 的 refillreleaseAll 调用,用于把 mcache 用完的 span 归还给 mcentral。

go 复制代码
// Return span from an mcache.
//
// s must have a span class corresponding to this
// mcentral and it must not be empty.
func (c *mcentral) uncacheSpan(s *mspan) {
    if s.allocCount == 0 {
        throw("uncaching span but s.allocCount == 0")
    }

    sg := mheap_.sweepgen
    stale := s.sweepgen == sg+1

    // Fix up sweepgen.
    if stale {
        // Span was cached before sweep began. It's our
        // responsibility to sweep it.
        //
        // Set sweepgen to indicate it's not cached but needs
        // sweeping and can't be allocated from. sweep will
        // set s.sweepgen to indicate s is swept.
        atomic.Store(&s.sweepgen, sg-1)
    } else {
        // Indicate that s is no longer cached.
        atomic.Store(&s.sweepgen, sg)
    }

    // Put the span in the appropriate place.
    if stale {
        // 已 stale:直接清扫,清扫会把 span 放到正确的列表
        ss := sweepLocked{s}
        ss.sweep(false)
    } else {
        if int(s.nelems)-int(s.allocCount) > 0 {
            // 还有空闲:放回 partialSwept
            c.partialSwept(sg).push(s)
        } else {
            // 没有空闲:放回 fullSwept
            c.fullSwept(sg).push(s)
        }
    }
}

--- mcentral.go:205-248

stale 判定:这个 span 是"漏网之鱼"吗?

go 复制代码
stale := s.sweepgen == sg+1

--- mcentral.go:211

回顾 sweepgen 状态表:sg+1 表示"sweep 之前 就被缓存的 span"。当一个 span 在上一轮 GC 的 sweep 开始前就被 mcache 缓存了(sweepgen = 旧代+1),那么新 GC 周期开始时,它逃过了清扫------因为它当时在 mcache 手里,不在任何 mcentral 列表里。

这样的 span 归还时就是 stale(过期的)

  1. 它需要被清扫(内存里可能有垃圾对象)
  2. 直接调用 ss.sweep(false) 就地清扫
  3. 清扫逻辑会自动把它放到正确的 partial/full、swept/unswept 列表
go 复制代码
if stale {
    atomic.Store(&s.sweepgen, sg-1)   // 标记为"需要清扫"
    ss := sweepLocked{s}
    ss.sweep(false)                    // 立即清扫
}

--- mcentral.go:214-221, 236-237

非 stale 路径:直接归类

如果 span 不是 stale(sweepgen == sgsg+3),它已经清扫过了,只需按空闲情况归类:

go 复制代码
if int(s.nelems)-int(s.allocCount) > 0 {
    c.partialSwept(sg).push(s)   // 还有空闲槽位  partial
} else {
    c.fullSwept(sg).push(s)      // 全满了  full
}

--- mcentral.go:239-246

注意:永远只 push 到 swept 列表 。因为 uncacheSpan 归还的 span 都是清扫过的(或刚被当场清扫的),放进 swept 列表才能被 cacheSpan 的第 ① 级直接复用。

为什么不用 sweepLocker? 注释解释了原因:

go 复制代码
// We don't use a sweepLocker here. Stale cached spans
// aren't in the global sweep lists, so mark termination
// itself holds up sweep completion until all mcaches
// have been swept.

--- mcentral.go:232-235

stale span 不在全局清扫列表里,没有并发清扫者会碰它,因此不需要锁;mark termination 阶段会等所有 mcache 都清扫完才继续。


7. grow:从 mheap 获取新 span

四级查找全部落空时,cacheSpan 调用 grow() 直接从 mheap 要一个新的 span:

go 复制代码
// grow allocates a new empty span from the heap and initializes it for c's size class.
func (c *mcentral) grow() *mspan {
    npages := uintptr(gc.SizeClassToNPages[c.spanclass.sizeclass()])
    s := mheap_.alloc(npages, c.spanclass)
    if s == nil {
        return nil
    }
    s.initHeapBits()
    return s
}

--- mcentral.go:250-259

关键点

  1. 页数查询gc.SizeClassToNPages[spc.sizeclass()] 查到该 size class 对应的页数(1~10 页),然后调 mheap.alloc(npages, spanclass)
  2. 返回 nil 语义mheap_.alloc 失败(OOM)返回 nil,cacheSpan 会把它透传给 refill,后者 throw("out of memory")
  3. 初始化位图s.initHeapBits() 建立 span 的堆位图(heapBits),为后续 GC 扫描指针做准备。

grow 的意义grow 让 mcentral 永远有一个"兜底":即使堆里所有 span 都被用满,也总能从 mheap 拿到全新的页。这也把 mcentral 的复杂性控制在有限范围内------它不必保证 100% 找到旧 span,找不到就造一个新的。


8. 调用链串联

把 mcentral 的四个核心函数放回整体架构中:

scss 复制代码
mcache.refill(spc)                    [07]
  ├─ uncacheSpan(旧 span)             [08]  归还
  │    ├─ stale?  s.sweep(false) 直接清扫
  │    └─ 非 stale  partialSwept / fullSwept
  └─ cacheSpan()                      [08]  分配
       ├─ ① partialSwept.pop()    已清扫部分空闲(O(1))
       ├─ ② partialUnswept sweep  未清扫部分空闲(budget=100)
       ├─ ③ fullUnswept sweep     未清扫满 span(budget=100)
       └─ ④ grow()                 mheap.alloc()  [09]
scss 复制代码
mheap.central[spc].mcentral
  ├─ partial[2] / full[2]  spanSet 双缓冲
  ├─ cacheSpan()   四级查找分配
  ├─ uncacheSpan() 归还 + stale 清扫
  └─ grow()        mheap 兜底

--- 参见大纲附录 B.1

mcentral 把 mcache 的本地无锁mheap 的全局加锁 之间的鸿沟填上了:它对每个 size class 提供独立的、带双缓冲优化的 span 池,让大部分 span 周转不触碰全局堆锁。下一篇 09. mheap 将进入全局堆管理器,看 mheap.alloc 内部的页分配和 span 初始化。


小结

机制 用途 源码位置
partial[2] / full[2] 按 GC 状态分双缓冲 spanSet mcentral.go:22-46
角色互换 sweepgen/2%2 索引取反,零数据搬移 mcentral.go:59-79
四级查找 partialSwept partialUnswept fullUnswept grow mcentral.go:82-199
spanBudget = 100 限制清扫开销,空间浪费 ≤1% mcentral.go:107
tryAcquire 认领 unswept span 的清扫权,防止重复清扫 mcentral.go:126
stale 判定 sweepgen == sg+1 直接清扫归还 mcentral.go:211,236
grow 从 mheap 分配新 span + initHeapBits mcentral.go:250-259

相关推荐
用户330144867631 小时前
07. mcache.refill:缓存补充与 span 归还
go
程序员爱钓鱼3 小时前
Go 编程实战:匿名函数 Anonymous Function——没有名字的函数与灵活回调
后端·面试·go
用户330144867631 天前
03. Size Class 分级机制
go
用户330144867631 天前
04. mallocgc:分配总入口
go
程序员爱钓鱼1 天前
Go 编程实战:函数 Function——参数、返回值与代码复用
后端·面试·go
名字还没想好☜2 天前
Go 1.23 range-over-func 迭代器实战:自定义可迭代类型、提前退出与惰性求值
开发语言·后端·golang·go·迭代器
赫媒派2 天前
Go 1.27 来了:泛型方法补齐,JSON 提速不踩坑
后端·go·敏捷开发
小满zs2 天前
Go语言第九章(错误处理)
后端·go
程序员爱钓鱼2 天前
Go 编程实战:指针 Pointer——理解地址、取址与解引用
后端·面试·go