阅读契约:不需要把“堆”当成坏词。先学会区分三个问题:对象为什么必须活得比当前函数久;程序每秒制造多少新对象;其中多少对象在 GC 时仍然活着。后半篇再进入逃逸分析、分配器与并发 GC 源码。

语言并不承诺对象一定放栈或堆;具体判断固定到 Go 1.26.0 tagescape.gomalloc.gomgc.gomgcpacer.go。 测量接口来自官方 runtime/metricsruntime/pprofDiagnostics 文档

一、对象在堆上,不等于内存有问题

fetchd 里,局部变量如果只在当前函数计算一次,函数返回后就不再需要,编译器通常可以把它放在当前 goroutine 的栈上。 但一个结果被返回、被 channel 交给别的 goroutine,或被 slice 保存到函数外面,它就可能比当前调用活得更久。此时放到堆上是在保证正确生命周期,不是编译器“失败”。

真正值得追的是三件不同的事:分配速率——请求不断制造多少新对象;存活集合——GC 时还有多少对象不能回收; 延迟代价——分配和 GC 工作是否落到了用户请求上。只有这三者与性能目标发生冲突时,减少分配才有明确价值。

问题第一份证据不能单独推出
为什么在堆上-gcflags='-m=2' 与源码生命周期它是否值得优化
一条路径分配多少Benchmark + -benchmem生产中是谁长期存活
谁占住了堆heap profile(in-use)历史累计分配速率
谁制造分配流量allocs profile(累计)当前 live heap
GC 是否影响延迟metrics、gctrace、execution trace单个对象该放栈还是堆

二、先回答“它要活多久”,再看栈或堆

Go 规范描述值、地址与可达行为,却不要求实现把某种语法固定放在栈或堆。编译器 escape analysis 的两条核心不变量直接写在源码注释里: 指向 stack object 的指针不能被存入 heap;指针不能比它指向的 stack object 活得更久。 只要编译器能证明地址不越过 frame 生命周期,new(T) 也可以落栈;反过来,普通局部变量被返回地址、放入更长寿命对象或闭包后就可能进堆。

//go:noinline
func stackResultChecksum() int64 {
    result := fetchResult{
        URL: "https://go.dev", Status: 200, Bytes: 42,
    }
    return result.Bytes + int64(len(result.URL))
}

//go:noinline
func escapingResult() *fetchResult {
    return &fetchResult{
        URL: "https://go.dev", Status: 200, Bytes: 42,
    }
}

“返回指针一定慢”也不是可靠规则:inline、调用点传播与未来编译器版本都可能改变结果。这里加 //go:noinline 是为了让实验形状稳定,不是生产优化建议。 结论必须读当前构建的报告,再用 allocation count 验证;不能把某次 -m 输出当语言保证。

2.1 编译器建立带权 location graph

escape 包先把变量和隐式分配映射为 location,再把赋值建成有向边。边权是“解引用次数减取地址次数”: p = &q 为 -1,p = q 为 0,p = *q 为 +1。 随后沿图查找可能破坏两条不变量的路径;某个地址流到 heap 或可能活得更久的位置时,对应 location 被标为必须 heap allocate。 跨函数信息则压缩成 parameter tags,供静态调用点继续传播。

Go escape analysis 带权 location graph:取地址边为负一、普通赋值为零、解引用为正一;返回地址或 slice backing array 可把 result 连到 heap,编译器报告 moved to heap 与 append escapes
算法不变量、边权和过程:escape.go L19-L89;批量分析入口:Batch

图是保守近似,并不区分 struct 的不同字段或 slice 的不同元素;源码也明确说明分析通常不具备完整的 flow、path 或 context sensitivity。 “escapes to heap”因此表示编译器没有足够证明把它安全留在当前 frame,不表示这个对象一定泄漏,更不表示 GC 当前就有压力。

2.2 读 -m=2:看流向,不数关键词

对整个 fetchd 运行构建报告时, memory_lab_test.go 中会出现 &fetchResult{...} escapes to heap,以及带运行时长度的 make([]fetchResult, 0, count) backing array 逃逸;真实 handler 的 batch 聚合还会报告 append escapes to heap。重点不是“出现 heap 三次”,而是解释每条数据的 owner 和最长寿命: 指针离开函数、slice backing array 由返回值持有、append 可能发布一个新数组。

cd go-runtime/examples/fetchd
go test -run '^$' -gcflags='-m=2' ./...

编译器还可能报告 interface 装箱、闭包捕获或日志参数的逃逸;它们中有些在热路径值得处理,有些是一次性初始化噪声。 按函数调用链过滤,再与 benchmark 的 allocs/op 对齐。如果报告说可能逃逸而测量仍为 0 alloc/op,优先相信最终生成代码和稳定测量,同时检查 inline、常量折叠与 benchmark 是否被优化掉。

2.3 slice 预分配减少的是 backing array 迁移

slice header 只有 pointer、len、cap;预分配并没有把 header “改成堆对象”,而是让已知上界的结果集合一次拿到足够 backing array。 从空 slice 连续 append 时,容量不足会调用 growslice,新建更大数组并复制旧元素。增长策略是当前 runtime 实现,不能把“永远翻倍”写进业务正确性。

// growth path
var results []fetchResult
for i := 0; i < count; i++ {
    results = append(results, fetchResult{N: i})
}

// one backing allocation for a known upper bound
results := make([]fetchResult, 0, count)
for i := 0; i < count; i++ {
    results = append(results, fetchResult{N: i})
}

本实验聚合 64 个结果时,按需增长稳定为 7 allocs/op、约 9.1 KiB/op,预分配为 1 alloc/op、约 4.8 KiB/op;返回指针为 1 alloc/op、64 B/op,而只计算校验和的值路径为 0 alloc/op。 这些数字是该实验形状的证据,不是跨平台常量。预分配过大同样会扩大 live heap;更好的输入是可信上界,而不是“越大越安全”。

三、mallocgc 先按大小和指针形状分流

继续跟同一批 64 个结果:
append 容量不足 → 申请更大的 backing array → 复制旧结果
没有预分配时,这个实验发生 7 次分配
每次申请最终都进入分配器
累计的新内存更快逼近下一次 GC 的 heap goal

这里要分清两层:growslice 决定“业务为什么又需要一块内存”,mallocgc 决定“runtime 从哪里拿这块内存”。 调高 GOGC 也许会推迟下一轮回收,却不会消除这七次 backing array 申请;减少分配与调整回收频率是两种不同动作。

Go 1.26.0 的 mallocgc 先处理零大小请求:返回共享的 zerobase。常规路径中,小对象不超过约 32 KiB; 无指针且小于 16 B 的对象可进入 tiny allocator,把多个对象装进一个 16 B block。其他小对象按 size class 和 scan/noscan 分流, 大对象直接从 heap pages 分配。含指针对象还需要正确的 bitmap/zeroing,让 GC 知道哪些 word 需要扫描。

Go mallocgc 分配路径:零大小到 zerobase,tiny noscan 对象共享十六字节块,小对象按 scan 或 noscan size class 从 per-P mcache 的 mspan 取空闲 slot,满后 refill 到 mcentral,大对象从 mheap pages 分配
if size == 0 {
    return unsafe.Pointer(&zerobase)
}
if gcBlackenEnabled != 0 {
    deductAssistCredit(size)
}
// small noscan / small scan / large routing follows
入口与分流:mallocgc。Go 1.26 还包含受 experiment 与 sanitizer 条件控制的 size-specialized 路径;它属于当前内部实现,不应被应用代码依赖。

size class 让固定大小 slot 可以批量管理,却也引入 elemsize - requested size 的内部碎片。tiny allocator 则有另一项权衡: 同一 16 B block 里的任一对象仍可达,整块就不能回收,所以它只适用于 noscan 小对象。应用真正能控制的是对象形状、批量大小与复用边界,不是直接选择 runtime 的 span。

3.1 快路径是当前 P 的 mcachemspan

nextFreeFast 从当前 span 的 allocCache 找 trailing zero,推进 freeindex 并计算 slot 地址。 这条路径不需要去全局 heap 找空间。cached span 满时,mcache.nextFree 调用 refill; refill 把已满 span 归还给 central,再用 mcentral.cacheSpan 获取有空位的 span。源码要求这段运行在 non-preemptible context, 因为 mcache 的 owner 与 P 绑定,抢占后 P 可能改变。

func nextFreeFast(s *mspan) gclinkptr {
    theBit := sys.TrailingZeros64(s.allocCache)
    if theBit < 64 {
        result := s.freeindex + uint16(theBit)
        // advance cache and return s.base + result*s.elemsize
    }
    return 0
}
nextFreeFast 与 mcache.nextFreemcache.refill

“小对象分配很快”描述的是常见 fast path,不代表没有系统成本:refill 可能触发 sweep 工作与 GC trigger 检查,内存清零和 sanitizer 也有成本; 最重要的是累计 allocation rate 最终会推进 heap goal。一次只看纳秒很便宜的分配,在高 QPS 下仍可能把大量 CPU 变成标记与清扫。

四、GC 是两段短 STW 之间的并发标记

先把 GC 想成一次“清点仍被谁拿着的结果”。从当前 goroutine 的栈、全局变量等根开始,沿指针能找到的对象叫可达,本轮不能回收; 沿任何根都找不到的对象才可能被清扫。源码用颜色记录清点进度:白色是尚未确认,灰色是已经找到但还没检查它指向谁, 黑色是已经检查完。颜色不是对象长期属性,只是这一轮标记的工作状态。

runtime 文档把循环写得很清楚:先完成 sweep termination 并短暂停顿,切到 _GCmark、准备 root jobs、启用 write barrier 与 mark workers; mutator 恢复后并发标记。达到 mark completion 条件后再次 STW,进入 _GCmarktermination 完成标记与统计, 再切回 _GCoff,关闭 write barrier,让 sweep 在后台并发进行。

Go 并发 GC:heap goal 由 GOGC 目标与内存限制目标共同约束,短暂停顿后并发标记,后台 worker、混合写屏障和分配协助共同完成工作,标记终止短暂停顿后并发清扫复用

因此“Go GC 是并发的”和“Go GC 有 STW”都只说了一半。关键是把暂停、并发 CPU、assist 落在哪个请求、以及 live heap 变化一起看。 只记录 pause quantile,可能漏掉请求 goroutine 亲自做 GC work 的尾延迟;只看 GC CPU,也可能把业务分配激增误判为 collector 回归。

phase 与完整循环:mgc.go 概览gcStart

4.1 混合写屏障防止 mutator 隐藏白对象

concurrent mark 时,应用还在改 heap pointer。Go 的 hybrid write barrier 在发布新指针前 shade 旧 slot 指向的对象; 当当前 goroutine 的 stack 仍是 grey 时,也 shade 新指针。这样 mutator 不能通过把唯一指针从 heap 移到已扫描或未扫描 stack 的组合中,让白对象从标记器视野消失。 barrier 对当前 stack frame 的写入可由编译器省略,但 heap pointer 发布必须遵守 pre-publication 约束。

writePointer(slot, ptr):
    shade(*slot)
    if current stack is grey:
        shade(ptr)
    *slot = ptr
算法、正确性解释与省略条件:runtime/mbarrier.go

这也是 pointer-rich object 与 pointer-free buffer 成本不同的一部分:后者无需让 GC 扫描其内容。不要为了“少扫描”把指针偷偷编码成 uintptr; 那会破坏可达性与移动/生命周期规则。应通过紧凑数据结构、减少无意义对象图和缩短 owner 生命周期来改善扫描量。

4.2 分配过快时,用户 goroutine 要做 mark assist

并发标记不是免费后台服务。GC active 时,mallocgc 先按分配大小扣减 assist credit; 当前 goroutine 的 debt 为负时,gcAssistAlloc 尝试从全局 background credit 偿还,仍不足就直接扫描对象图。 这把“谁制造分配压力”与“谁承担部分标记工作”连接起来,帮助 collector 在 heap goal 之前完成标记。

allocation
  → deductAssistCredit(size)
  → assist debt below zero
  → steal background scan credit or gcAssistAlloc1
  → goroutine pays marking work before continuing
gcAssistAllocgcAssistAlloc1

execution trace 中 allocation-heavy goroutine 出现 GC assist,是比“GC 偶尔抖一下”更有行动价值的证据。优先减少请求路径上可避免的 allocation bytes, 再讨论调 GOGC;否则只是改变付款时间,没有改变账单。

4.3 GOGCGOMEMLIMIT 控制不同约束

GOGC 让下一轮 heap goal 随上一轮 live heap 成比例增长;默认 100 可近似理解为允许新分配量接近当前 live heap 后完成下一轮回收。 GOMEMLIMIT 则给 runtime 一个软内存限制,控制器从 runtime 管理的总映射内存估计 memory-limit heap goal,并预留 headroom。 实际目标取 GOGC-derived goal 与 memory-limit-derived goal 的更紧者。

旋钮主要交换错误理解
更低 GOGC更小 heap、更多 GC CPU自动降低业务分配量
更高 GOGC更大 heap、通常更少 GC消除内存上限风险
GOMEMLIMIT用更多 GC 压力守住软目标heap 的硬上限或容器 limit 同义词
两者一起比例目标与环境约束取更紧者设置 limit 后 GOGC 完全失效

soft limit 不是 OOM 保险:non-Go memory、mmap、cgo、kernel page cache 与容器统计口径可能不同;极端 limit 下 runtime 还必须避免 GC thrashing 把应用完全饿死。 生产值应基于容器余量、live set、峰值流量与 CPU budget 联合压测,不要照抄单一百分比。

pacer 目标、25% background utilization 与 memory-limit headroom:mgcpacer.go;配置契约:SetGCPercentSetMemoryLimit

五、把 allocation rate 与 live heap 分开观测

实验通过 runtime/metrics 读取 /gc/heap/allocs:bytes/gc/heap/allocs:objects/gc/heap/live:bytes/gc/cycles/total:gc-cycles。 前两项是累计分配,live bytes 是上一轮 GC 确认仍存活的集合,cycles 是回收节奏;它们的定义和采样时间不同,不能拿 allocs 减 live 直接当精确 reclaimed bytes。

samples := []metrics.Sample{
    {Name: "/gc/heap/allocs:bytes"},
    {Name: "/gc/heap/allocs:objects"},
    {Name: "/gc/heap/live:bytes"},
    {Name: "/gc/cycles/total:gc-cycles"},
}
metrics.Read(samples)
工具适合回答使用边界
go test -benchmem受控路径的 B/op、allocs/op不是生产流量分布
heap profile采样时谁仍占用内存默认看 in-use,不是全部历史分配
allocs profile累计由谁制造 allocation traffic高累计不等于当前泄漏
runtime/metrics服务级趋势与告警先读 metric description 与 kind
GODEBUG=gctrace=1单进程 GC cycle 摘要更适合临时诊断,不替代 tracing
execution traceassist、STW 与 goroutine 时间关系有采集开销,需限定窗口
cd go-runtime/examples/fetchd
go test -run 'Test(AllocationShape|RuntimeMetrics)' -count=20
go test -bench BenchmarkAllocationShapes -benchmem -count=3
go test ./...
go test -race ./...
go vet ./...

六、回到 fetchd 的内存清单

  1. 先定 owner 与寿命。请求内临时值尽量不离开 handler;必须异步持有的对象明确交给 process/session owner。
  2. 用报告解释流向,用 benchmark 判断价值。-m=2 不是性能排行榜。
  3. 已知上界就合理预分配。避免 slice backing array 反复迁移,也避免按不可信输入无限预留。
  4. 减少 bytes 往往比只减少 object count 更重要。两者都测,连同 CPU 和延迟一起看。
  5. 区分 allocation rate 与 live heap。前者驱动 GC 工作,后者决定基础扫描与目标规模。
  6. 把 mark assist 放进请求延迟模型。GC CPU 不只在后台 worker。
  7. 调 GOGC/GOMEMLIMIT 前先压测余量。同时记录容器 RSS、runtime memory、GC CPU 和 tail latency。
  8. 不要依赖 runtime 内部布局。size class、tiny packing 与 specialized malloc 都可能随版本和 experiment 改变。

可复用结论是:逃逸分析决定对象能否安全留在 frame;allocation rate 决定应用制造多少回收工作;live heap 决定 collector 必须持续照看的对象图。 下一篇会把这条请求送回网络:连接能否复用、HTTP/2 怎样在一条连接上并发,以及如何把 httptrace、pprof、trace、metrics 与 race 组合成一次完整诊断。

参考源码与文档