1. 回顾:mcache 什么时候需要 refill?
摘要 :本文深入剖析 Go 语言内存管理中 mcache 的 refill 机制,这是连接本地无锁分配与中心化 span 管理的关键桥梁。当 mcache 中的 span 完全分配完毕时,refill 负责将旧 span 归还给 mcentral 并获取新 span。文章详细讲解了触发条件、完整流程、sweepgen +3 标记、allocCountBeforeCache 跨代统计、heapLive 乐观更新策略以及 mcache 的刷新机制(prepareForSweep 与 releaseAll)。理解 refill 是掌握 Go 内存分配器从 mcache 到 mcentral 过渡的核心。
在 06. Small 对象分配 中我们讲过,mcache 的每个 alloc[spc] 持有一个 *mspan,小对象分配时从该 span 的空闲位图(allocCache)里无锁地取槽位。
但 span 的空间是有限的。当 c.alloc[spc] 这个 span 的所有槽位都被分配出去(allocCount == nelems)时,nextFree 慢速路径就会调用 refill 来换一个新的空 span。
go
// nextFree 慢速路径:span 用尽时触发 refill
func (c *mcache) nextFree(spc spanClass) (v gclinkptr, s *mspan, checkGCTrigger bool) {
s = c.alloc[spc]
...
// 没有空闲槽位了,需要 refill
c.refill(spc)
...
}
--- malloc.go:996-1024(示意)
一句话理解 :refill就是"旧 span 用完 归还给 mcentral 从 mcentral 拿一个新的空 span"。它是连接 mcache(无锁本地) 与 mcentral(class 级锁) 的桥梁。
2. refill 的完整流程
refill 的源码位于 mcache.go:160-239,核心逻辑清晰分为两半:先归还旧 span,再获取新 span。

图 7-1:mcache.refill 的完整流程
go
// mcache.go:160
func (c *mcache) refill(spc spanClass) {
// Return the current cached span to the central lists.
s := c.alloc[spc]
if s.allocCount != s.nelems {
throw("refill of span with free space remaining")
}
// 清理可重用 noscan 对象链表(tiny span class)
if spc == tinySpanClass {
c.reusableNoscan[spc] = 0
}
if c.reusableNoscan[spc] != 0 {
throw("refill of span with reusable pointers remaining on pointer free list")
}
if s != &emptymspan {
// Mark this span as no longer cached.
if s.sweepgen != mheap_.sweepgen+3 {
throw("bad sweepgen in refill")
}
mheap_.central[spc].mcentral.uncacheSpan(s)
// 统计使用量
stats := memstats.heapStats.acquire()
slotsUsed := int64(s.allocCount) - int64(s.allocCountBeforeCache)
atomic.Xadd64(&stats.smallAllocCount[spc.sizeclass()], slotsUsed)
if spc == tinySpanClass {
atomic.Xadd64(&stats.tinyAllocCount, int64(c.tinyAllocs))
c.tinyAllocs = 0
}
memstats.heapStats.release()
bytesAllocated := slotsUsed * int64(s.elemsize)
gcController.totalAlloc.Add(bytesAllocated)
s.allocCountBeforeCache = 0
}
// Get a new cached span from the central lists.
s = mheap_.central[spc].mcentral.cacheSpan()
if s == nil {
throw("out of memory")
}
// Indicate that this span is cached and prevent asynchronous
// sweeping in the next sweep phase.
s.sweepgen = mheap_.sweepgen + 3
s.allocCountBeforeCache = s.allocCount
// Update heapLive and flush scanAlloc.
usedBytes := uintptr(s.allocCount) * s.elemsize
gcController.update(int64(s.npages*pageSize)-int64(usedBytes), int64(c.scanAlloc))
c.scanAlloc = 0
c.alloc[spc] = s
}
--- mcache.go:160-239
2.1 触发前置条件:span 必须已满
函数开头有一个 throw 检查:
go
if s.allocCount != s.nelems {
throw("refill of span with free space remaining")
}
--- mcache.go:164-166
这说明 refill 被调用的前提是当前 span 已经 100% 分配完毕 。如果还有空闲槽位却调用了 refill,说明逻辑出现了 bug,runtime 直接 throw 崩溃。
2.2 清理可重用 noscan 链表
go
if spc == tinySpanClass {
c.reusableNoscan[spc] = 0
}
--- mcache.go:170-175
这是 FreeGC (runtimeFreegcEnabled)实验特性的一部分。noscan 对象可以挂在 per-P 的可重用链表 reusableNoscan[spc] 上,下次分配直接复用。但在 refill 时,旧 span 要被归还,如果链表里还挂着指向该 span 内存的对象,就会产生悬垂引用,所以必须清空。
2.3 归还旧 span
refill 的第一大步骤,就是通过 uncacheSpan 把用尽的旧 span 交还给 mcentral:
go
mheap_.central[spc].mcentral.uncacheSpan(s)
--- mcache.go:182
这一步是下一篇文章 08. mcentral 的主角,这里只需要知道:归还后,这个旧 span 会被放回 mcentral 的 partial/full 列表,供其他 P 复用。
3. sweepgen +3:已清扫后被缓存的标记
在归还前,refill 检查了旧 span 的 sweepgen:
go
if s.sweepgen != mheap_.sweepgen+3 {
throw("bad sweepgen in refill")
}
--- mcache.go:179
这里涉及 sweepgen 的核心语义(详见大纲附录 C)。相对 mheap_.sweepgen,span 的 sweepgen 有 5 种状态:
| sweepgen 值 | 含义 |
|---|---|
h.sweepgen - 2 |
需要 sweep |
h.sweepgen - 1 |
正在被 sweep |
h.sweepgen |
已 sweep,可用 |
h.sweepgen + 1 |
sweep 前被缓存,仍被缓存,需要 sweep |
h.sweepgen + 3 |
sweep 后被缓存,仍被缓存 |
为什么是 +3 而不是 +1?
+1表示"在 sweep 开始之前就被 mcache 缓存了",这种 span 还没被清扫过。而+3表示"这个 span 已经清扫过、随后才被 mcache 缓存"------它的内存是干净的、可以直接分配。refill从 mcentral 拿到的一定是已清扫(或新分配)的 span,所以标记为+3。
这个标记有两个作用:
- 防止异步清扫 :sweep 只会去清扫
sweepgen <= h.sweepgen的 span。一个被 mcache 缓存(+3)的 span 不在清扫范围内,可以放心使用。 - 校验状态 :
refill归还旧 span 时,旧 span 之前也是+3,这里做一次一致性校验,防止状态被破坏。
拿到新 span 后同样设置:
go
s.sweepgen = mheap_.sweepgen + 3
--- mcache.go:216
4. allocCountBeforeCache:跨代统计的桥梁
allocCountBeforeCache 是一个很容易被忽略、却至关重要的字段。它的作用在 refill 的统计代码里体现:
go
slotsUsed := int64(s.allocCount) - int64(s.allocCountBeforeCache)
atomic.Xadd64(&stats.smallAllocCount[spc.sizeclass()], slotsUsed)
--- mcache.go:186-187
它解决了什么问题?
一个 span 从 mcentral 被取到 mcache 时,可能已经有一部分槽位被分配过了 (partial 列表里的 span 就是"部分空闲"的)。因此,这次缓存期间"真正新分配了多少"不能直接用 allocCount,而要减去缓存时的初始值。
go
// 取 span 时记录初始分配数
s.allocCountBeforeCache = s.allocCount // mcache.go:219
// 归还时计算差值 = 本次缓存期间真正分配的槽位数
slotsUsed := s.allocCount - s.allocCountBeforeCache // mcache.go:186
具体流程
refill取到新 span 时(mcache.go:219):allocCountBeforeCache = allocCount,记录缓存起点。- 使用过程中 :
allocCount随每次分配增长,allocCountBeforeCache不变。 - 归还旧 span 时 (mcache.go:186):两者之差就是"这次缓存贡献的分配数",计入
smallAllocCount统计。
go
// 归还后清空,防止误用
s.allocCountBeforeCache = 0 // mcache.go:201
为什么这样设计? 因为 mcentral 的
partial列表允许"部分空闲的 span"被多个 P 先后缓存使用。如果只用allocCount,会重复统计之前几个 P 分配的槽位。allocCountBeforeCache让每个 P 只统计自己缓存期间新增的部分,统计才精确。
5. heapLive 乐观更新:为什么宁可高估
refill 的最后一步,是对 heapLive 做乐观更新:
go
// Update heapLive and flush scanAlloc.
//
// We have not yet allocated anything new into the span, but we
// assume that all of its slots will get used, so this makes
// heapLive an overestimate.
//
// When the span gets uncached, we'll fix up this overestimate
// if necessary (see releaseAll).
//
// We pick an overestimate here because an underestimate leads
// the pacer to believe that it's in better shape than it is,
// which appears to lead to more memory used. See #53738 for
// more details.
usedBytes := uintptr(s.allocCount) * s.elemsize
gcController.update(int64(s.npages*pageSize)-int64(usedBytes), int64(c.scanAlloc))
c.scanAlloc = 0
--- mcache.go:221-236
为什么要"高估"?
这里的思想很精妙:取到一个空 span 时,我们假设它最终会被完全用完 ,于是直接把整个 span 的空闲容量计入 heapLive。
go
// 预估:整个 span 会被用完
// 空闲字节 = 总页大小 - 已用字节
// 这个空闲字节被当成"即将被分配"加进 heapLive
int64(s.npages*pageSize) - int64(usedBytes)
为什么高估比低估好?
源码注释给出了答案(指向 issue #53738):
- 低估的问题 :如果低估,pacer(GC 步调控制器)会以为堆还有很大余量、状态很好,从而推迟 GC。但实际上堆空间正在快速消耗,最终导致 GC 触发太晚、内存峰值暴涨。低估 堆失控。
- 高估的好处 :高估让 pacer 更保守,更早触发 GC。即使高估,后面
releaseAll时还能修正回来(见第 6 节)。
这是一个"保守"的工程权衡 :宁可让 GC 稍微早一点,也不能让堆涨到失控。
heapLive本身就是一个估算值,允许有一定偏差。
6. mcache 的刷新机制:prepareForSweep 与 releaseAll
前面提到,refill 的高估会在 releaseAll 时被修正。这一节看 mcache 的整体刷新机制。
6.1 prepareForSweep:GC 周期切换的入口
go
// prepareForSweep flushes c if the system has entered a new sweep phase
// since c was populated. This must happen between the sweep phase
// starting and the first allocation from c.
func (c *mcache) prepareForSweep() {
sg := mheap_.sweepgen
flushGen := c.flushGen.Load()
if flushGen == sg {
return
} else if flushGen != sg-2 {
println("bad flushGen", flushGen, "in prepareForSweep; sweepgen", sg)
throw("bad flushGen")
}
c.releaseAll()
stackcache_clear(c)
c.flushGen.Store(mheap_.sweepgen) // Synchronizes with gcStart
}
--- mcache.go:350-369
prepareForSweep 利用 flushGen 记录"上次 flush 发生在哪个 sweepgen"。每次进入新的 sweep 阶段(sweepgen += 2),每个 P 在首次分配前会调用它,把旧周期的缓存 span 全部清空。
6.2 releaseAll:全量归还 + 修正高估
go
func (c *mcache) releaseAll() {
scanAlloc := int64(c.scanAlloc)
c.scanAlloc = 0
sg := mheap_.sweepgen
dHeapLive := int64(0)
for i := range c.alloc {
s := c.alloc[i]
if s != &emptymspan {
slotsUsed := int64(s.allocCount) - int64(s.allocCountBeforeCache)
s.allocCountBeforeCache = 0
// 统计本次缓存期间的分配
stats := memstats.heapStats.acquire()
atomic.Xadd64(&stats.smallAllocCount[spanClass(i).sizeclass()], slotsUsed)
memstats.heapStats.release()
gcController.totalAlloc.Add(slotsUsed * int64(s.elemsize))
if s.sweepgen != sg+1 {
// refill conservatively counted unallocated slots in gcController.heapLive.
// Undo this.
//
// If this span was cached before sweep, then gcController.heapLive was totally
// recomputed since caching this span, so we don't do this for stale spans.
dHeapLive -= int64(s.nelems-s.allocCount) * int64(s.elemsize)
}
// Release the span to the mcentral.
mheap_.central[i].mcentral.uncacheSpan(s)
c.alloc[i] = &emptymspan
}
}
// Clear tinyalloc pool.
c.tiny = 0
c.tinyoffset = 0
// Flush tinyAllocs.
stats := memstats.heapStats.acquire()
atomic.Xadd64(&stats.tinyAllocCount, int64(c.tinyAllocs))
c.tinyAllocs = 0
memstats.heapStats.release()
// Clear the reusable linked lists.
clear(c.reusableNoscan[:])
// Update heapLive and heapScan.
gcController.update(dHeapLive, scanAlloc)
}
--- mcache.go:290-345
关键点:修正 heapLive 高估
go
if s.sweepgen != sg+1 {
// refill 时乐观计入的空闲槽位,现在要撤销
dHeapLive -= int64(s.nelems-s.allocCount) * int64(s.elemsize)
}
--- mcache.go:312-319
refill 时我们预估"整个 span 会用完",把空闲容量加进了 heapLive。但归还时 span 里可能还剩不少空闲槽位没被用到,releaseAll 就把这部分扣回来 (dHeapLive 为负),让 heapLive 重新贴近真实值。
注意
sg+1的特判 :如果这个 span 是在 sweep 之前被缓存的(sweepgen == sg+1,即 stale),那么自缓存以来heapLive已经被完全重新计算过,此时不能再次扣除,否则会重复修正。这就是为什么只有sweepgen != sg+1时才撤销。
6.3 与 refill 的统计呼应
注意到 releaseAll 和 refill 用相同的公式统计分配量:
go
// refill:归还单个 span 时
slotsUsed := int64(s.allocCount) - int64(s.allocCountBeforeCache)
// releaseAll:批量归还所有 span 时
slotsUsed := int64(s.allocCount) - int64(s.allocCountBeforeCache)
--- mcache.go:186 与 mcache.go:300
两条路径都依赖 allocCountBeforeCache 来计算"本次缓存期间的分配数",逻辑完全一致。区别只是 refill 处理单个 span,releaseAll 遍历 c.alloc 全部 136 个 span。
7. 调用链串联
把 refill 放回整体分配链路中,它的位置非常清晰:
scss
分配小对象(小/中对象)
└─ mallocgc mallocgcSmall*
└─ nextFreeFast(c.alloc[spc]) // 快速路径,CTZ 无锁
└─ c.nextFree(spc) // 慢速路径:span 用尽
└─ c.refill(spc) // 本文主角
├─ uncacheSpan(旧 span) 归还给 mcentral
└─ cacheSpan() // 从 mcentral 取新 span(下一篇)
└─ grow() mheap.alloc()
--- 参见大纲附录 B.1
refill 是 mcache 与 mcentral 之间的"交接点":它把本地的、无锁的分配 和中心的、class 级锁定的 span 管理 连接起来。理解了 refill,下一篇 08. mcentral 中 cacheSpan 的四级查找就是顺理成章的事了。
小结
| 机制 | 用途 | 源码位置 |
|---|---|---|
| 触发条件 | 仅当 allocCount == nelems 时才允许 refill |
mcache.go:164 |
| sweepgen +3 | 标记"已清扫后被缓存",防止异步清扫 | mcache.go:216 |
| allocCountBeforeCache | 统计本次缓存期间真正分配的槽位数 | mcache.go:186,219 |
| heapLive 乐观更新 | 预估整个 span 会用完,宁可高估 | mcache.go:234-236 |
| prepareForSweep | GC 周期切换时刷新 mcache | mcache.go:350 |
| releaseAll | 批量归还 span + 修正 heapLive 高估 | mcache.go:290 |