公司动态
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。// nextFree 慢速路径span 用尽时触发 refillfunc(c*mcache)nextFree(spc spanClass)(v gclinkptr,s*mspan,checkGCTriggerbool){sc.alloc[spc]...// 没有空闲槽位了需要 refillc.refill(spc)...}— malloc.go:996-1024示意一句话理解refill就是旧 span 用完 归还给 mcentral 从 mcentral 拿一个新的空 span。它是连接mcache无锁本地与mcentralclass 级锁的桥梁。2. refill 的完整流程refill的源码位于mcache.go:160-239核心逻辑清晰分为两半先归还旧 span再获取新 span。图 7-1mcache.refill 的完整流程// mcache.go:160func(c*mcache)refill(spc spanClass){// Return the current cached span to the central lists.s:c.alloc[spc]ifs.allocCount!s.nelems{throw(refill of span with free space remaining)}// 清理可重用 noscan 对象链表tiny span classifspctinySpanClass{c.reusableNoscan[spc]0}ifc.reusableNoscan[spc]!0{throw(refill of span with reusable pointers remaining on pointer free list)}ifs!emptymspan{// Mark this span as no longer cached.ifs.sweepgen!mheap_.sweepgen3{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)ifspctinySpanClass{atomic.Xadd64(stats.tinyAllocCount,int64(c.tinyAllocs))c.tinyAllocs0}memstats.heapStats.release()bytesAllocated:slotsUsed*int64(s.elemsize)gcController.totalAlloc.Add(bytesAllocated)s.allocCountBeforeCache0}// Get a new cached span from the central lists.smheap_.central[spc].mcentral.cacheSpan()ifsnil{throw(out of memory)}// Indicate that this span is cached and prevent asynchronous// sweeping in the next sweep phase.s.sweepgenmheap_.sweepgen3s.allocCountBeforeCaches.allocCount// Update heapLive and flush scanAlloc.usedBytes:uintptr(s.allocCount)*s.elemsize gcController.update(int64(s.npages*pageSize)-int64(usedBytes),int64(c.scanAlloc))c.scanAlloc0c.alloc[spc]s}— mcache.go:160-2392.1 触发前置条件span 必须已满函数开头有一个throw检查ifs.allocCount!s.nelems{throw(refill of span with free space remaining)}— mcache.go:164-166这说明refill被调用的前提是当前 span 已经 100% 分配完毕。如果还有空闲槽位却调用了 refill说明逻辑出现了 bugruntime 直接throw崩溃。2.2 清理可重用 noscan 链表ifspctinySpanClass{c.reusableNoscan[spc]0}— mcache.go:170-175这是FreeGCruntimeFreegcEnabled实验特性的一部分。noscan 对象可以挂在 per-P 的可重用链表reusableNoscan[spc]上下次分配直接复用。但在refill时旧 span 要被归还如果链表里还挂着指向该 span 内存的对象就会产生悬垂引用所以必须清空。2.3 归还旧 spanrefill的第一大步骤就是通过uncacheSpan把用尽的旧 span 交还给 mcentralmheap_.central[spc].mcentral.uncacheSpan(s)— mcache.go:182这一步是下一篇文章 08. mcentral 的主角这里只需要知道归还后这个旧 span 会被放回 mcentral 的 partial/full 列表供其他 P 复用。3. sweepgen 3已清扫后被缓存的标记在归还前refill检查了旧 span 的 sweepgenifs.sweepgen!mheap_.sweepgen3{throw(bad sweepgen in refill)}— mcache.go:179这里涉及 sweepgen 的核心语义详见大纲附录 C。相对mheap_.sweepgenspan 的 sweepgen 有 5 种状态sweepgen 值含义h.sweepgen - 2需要 sweeph.sweepgen - 1正在被 sweeph.sweepgen已 sweep可用h.sweepgen 1sweep 前被缓存仍被缓存需要 sweeph.sweepgen 3sweep 后被缓存仍被缓存为什么是 3 而不是 11表示在 sweep 开始之前就被 mcache 缓存了这种 span 还没被清扫过。而3表示这个 span 已经清扫过、随后才被 mcache 缓存——它的内存是干净的、可以直接分配。refill从 mcentral 拿到的一定是已清扫或新分配的 span所以标记为3。这个标记有两个作用防止异步清扫sweep 只会去清扫sweepgen h.sweepgen的 span。一个被 mcache 缓存3的 span 不在清扫范围内可以放心使用。校验状态refill归还旧 span 时旧 span 之前也是3这里做一次一致性校验防止状态被破坏。拿到新 span 后同样设置s.sweepgenmheap_.sweepgen3— mcache.go:2164. allocCountBeforeCache跨代统计的桥梁allocCountBeforeCache是一个很容易被忽略、却至关重要的字段。它的作用在refill的统计代码里体现slotsUsed:int64(s.allocCount)-int64(s.allocCountBeforeCache)atomic.Xadd64(stats.smallAllocCount[spc.sizeclass()],slotsUsed)— mcache.go:186-187它解决了什么问题一个 span 从 mcentral 被取到 mcache 时可能已经有一部分槽位被分配过了partial 列表里的 span 就是部分空闲的。因此这次缓存期间真正新分配了多少不能直接用allocCount而要减去缓存时的初始值。// 取 span 时记录初始分配数s.allocCountBeforeCaches.allocCount// mcache.go:219// 归还时计算差值 本次缓存期间真正分配的槽位数slotsUsed:s.allocCount-s.allocCountBeforeCache// mcache.go:186具体流程refill取到新 span时mcache.go:219allocCountBeforeCache allocCount记录缓存起点。使用过程中allocCount随每次分配增长allocCountBeforeCache不变。归还旧 span 时mcache.go:186两者之差就是这次缓存贡献的分配数计入smallAllocCount统计。// 归还后清空防止误用s.allocCountBeforeCache0// mcache.go:201为什么这样设计因为 mcentral 的partial列表允许部分空闲的 span被多个 P 先后缓存使用。如果只用allocCount会重复统计之前几个 P 分配的槽位。allocCountBeforeCache让每个 P 只统计自己缓存期间新增的部分统计才精确。5. heapLive 乐观更新为什么宁可高估refill的最后一步是对heapLive做乐观更新// 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, well fix up this overestimate// if necessary (see releaseAll).//// We pick an overestimate here because an underestimate leads// the pacer to believe that its 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.scanAlloc0— mcache.go:221-236为什么要高估这里的思想很精妙取到一个空 span 时我们假设它最终会被完全用完于是直接把整个 span 的空闲容量计入heapLive。// 预估整个 span 会被用完// 空闲字节 总页大小 - 已用字节// 这个空闲字节被当成即将被分配加进 heapLiveint64(s.npages*pageSize)-int64(usedBytes)为什么高估比低估好源码注释给出了答案指向 issue #53738低估的问题如果低估pacerGC 步调控制器会以为堆还有很大余量、状态很好从而推迟 GC。但实际上堆空间正在快速消耗最终导致 GC 触发太晚、内存峰值暴涨。低估 堆失控。高估的好处高估让 pacer 更保守更早触发 GC。即使高估后面releaseAll时还能修正回来见第 6 节。这是一个保守的工程权衡宁可让 GC 稍微早一点也不能让堆涨到失控。heapLive本身就是一个估算值允许有一定偏差。6. mcache 的刷新机制prepareForSweep 与 releaseAll前面提到refill的高估会在releaseAll时被修正。这一节看 mcache 的整体刷新机制。6.1 prepareForSweepGC 周期切换的入口// 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()ifflushGensg{return}elseifflushGen!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-369prepareForSweep利用flushGen记录上次 flush 发生在哪个 sweepgen。每次进入新的 sweep 阶段sweepgen 2每个 P 在首次分配前会调用它把旧周期的缓存 span 全部清空。6.2 releaseAll全量归还 修正高估func(c*mcache)releaseAll(){scanAlloc:int64(c.scanAlloc)c.scanAlloc0sg:mheap_.sweepgen dHeapLive:int64(0)fori:rangec.alloc{s:c.alloc[i]ifs!emptymspan{slotsUsed:int64(s.allocCount)-int64(s.allocCountBeforeCache)s.allocCountBeforeCache0// 统计本次缓存期间的分配stats:memstats.heapStats.acquire()atomic.Xadd64(stats.smallAllocCount[spanClass(i).sizeclass()],slotsUsed)memstats.heapStats.release()gcController.totalAlloc.Add(slotsUsed*int64(s.elemsize))ifs.sweepgen!sg1{// 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 dont 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.tiny0c.tinyoffset0// Flush tinyAllocs.stats:memstats.heapStats.acquire()atomic.Xadd64(stats.tinyAllocCount,int64(c.tinyAllocs))c.tinyAllocs0memstats.heapStats.release()// Clear the reusable linked lists.clear(c.reusableNoscan[:])// Update heapLive and heapScan.gcController.update(dHeapLive,scanAlloc)}— mcache.go:290-345关键点修正 heapLive 高估ifs.sweepgen!sg1{// refill 时乐观计入的空闲槽位现在要撤销dHeapLive-int64(s.nelems-s.allocCount)*int64(s.elemsize)}— mcache.go:312-319refill时我们预估整个 span 会用完把空闲容量加进了 heapLive。但归还时 span 里可能还剩不少空闲槽位没被用到releaseAll就把这部分扣回来dHeapLive为负让heapLive重新贴近真实值。注意sg1的特判如果这个 span 是在 sweep 之前被缓存的sweepgen sg1即 stale那么自缓存以来heapLive已经被完全重新计算过此时不能再次扣除否则会重复修正。这就是为什么只有sweepgen ! sg1时才撤销。6.3 与 refill 的统计呼应注意到releaseAll和refill用相同的公式统计分配量// 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处理单个 spanreleaseAll遍历c.alloc全部 136 个 span。7. 调用链串联把refill放回整体分配链路中它的位置非常清晰分配小对象小/中对象 └─ mallocgc mallocgcSmall* └─ nextFreeFast(c.alloc[spc]) // 快速路径CTZ 无锁 └─ c.nextFree(spc) // 慢速路径span 用尽 └─ c.refill(spc) // 本文主角 ├─ uncacheSpan(旧 span) 归还给 mcentral └─ cacheSpan() // 从 mcentral 取新 span下一篇 └─ grow() mheap.alloc()— 参见大纲附录 B.1refill是 mcache 与 mcentral 之间的交接点它把本地的、无锁的分配和中心的、class 级锁定的 span 管理连接起来。理解了refill下一篇 08. mcentral 中cacheSpan的四级查找就是顺理成章的事了。小结机制用途源码位置触发条件仅当allocCount nelems时才允许 refillmcache.go:164sweepgen 3标记已清扫后被缓存防止异步清扫mcache.go:216allocCountBeforeCache统计本次缓存期间真正分配的槽位数mcache.go:186,219heapLive 乐观更新预估整个 span 会用完宁可高估mcache.go:234-236prepareForSweepGC 周期切换时刷新 mcachemcache.go:350releaseAll批量归还 span 修正 heapLive 高估mcache.go:290