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:
partial[0]= 已清扫(partialSwept),partial[1]= 未清扫(partialUnswept)- 分配路径从
partial[0]直接 pop 使用;从未清扫集合 pop 出来的 span 需要先 sweep - 后台清扫器从
partial[1]/full[1]弹出 span 清扫,扫完有空的推入partial[0],仍满的推入full[0] - GC 结束,
sweepgen += 2现在sweepgen/2%2 = 1 - 于是
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
refillAllocCache 从 allocBits 位图重建 64 位补码缓存,allocCache >>= freeindex % 64 把 freeindex 对齐到低位------这样 mcache 的 nextFreeFast 就能直接用 CTZ(trailingzeros)指令 O(1) 找到第一个空闲槽位。这正好接上 06. Small 对象分配 讲的快速路径。
5. spanBudget = 100:清扫开销与空间浪费的权衡
spanBudget 是 cacheSpan 里最有意思的工程参数:
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 判定
uncacheSpan 是 cacheSpan 的逆操作,被 mcache 的 refill 和 releaseAll 调用,用于把 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(过期的):
- 它需要被清扫(内存里可能有垃圾对象)
- 直接调用
ss.sweep(false)就地清扫 - 清扫逻辑会自动把它放到正确的 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 == sg 或 sg+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
关键点
- 页数查询 :
gc.SizeClassToNPages[spc.sizeclass()]查到该 size class 对应的页数(1~10 页),然后调mheap.alloc(npages, spanclass)。 - 返回 nil 语义 :
mheap_.alloc失败(OOM)返回 nil,cacheSpan会把它透传给refill,后者throw("out of memory")。 - 初始化位图 :
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 |