07. mcache.refill:缓存补充与 span 归还

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

这是 FreeGCruntimeFreegcEnabled)实验特性的一部分。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

这个标记有两个作用:

  1. 防止异步清扫 :sweep 只会去清扫 sweepgen <= h.sweepgen 的 span。一个被 mcache 缓存(+3)的 span 不在清扫范围内,可以放心使用。
  2. 校验状态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

具体流程

  1. refill 取到新 span 时(mcache.go:219):allocCountBeforeCache = allocCount,记录缓存起点。
  2. 使用过程中allocCount 随每次分配增长,allocCountBeforeCache 不变。
  3. 归还旧 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 的统计呼应

注意到 releaseAllrefill相同的公式统计分配量:

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

相关推荐
程序员爱钓鱼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
Flynt3 天前
Go 1.27 升了一波,泛型方法和 JSON v2 真香但有个坑
后端·go