Appearance
拼一个 4 KiB 数据帧,为什么累计分配了近 5 KiB?
网关先写一行前缀,再写 4096 字节内容。直觉上每次只需要一块 4 KiB 内存。如果 pprof 报了数 GiB,应该扩容、调高 GOGC,还是改代码?这三个动作针对三个不同问题。先保持输出内容不变,只换缓冲区的取得方式:
完整源码:modeldock/labs/runtime/lab_test.go · 第 13–43 行
go
func BenchmarkFrame(b *testing.B) {
for _, reuse := range []bool{false, true} {
name := "fresh"
if reuse {
name = "pool"
}
b.Run(name, func(b *testing.B) {
payload := bytes.Repeat([]byte("a"), 4096)
b.ReportAllocs()
for b.Loop() {
var buf *bytes.Buffer
if reuse {
buf = pool.Get().(*bytes.Buffer)
buf.Reset()
} else {
buf = new(bytes.Buffer)
}
buf.WriteString("data: ")
buf.Write(payload)
if crc32.ChecksumIEEE(buf.Bytes()) == 0 {
b.Fatal("checksum")
}
if reuse {
pool.Put(buf)
}
}
})
}
}一次分配怎样变成请求的 CPU 开销
局部变量的作用域不决定存放位置。如果对变量的引用可能活得比当前栈帧久,编译器必须提供安全的生命周期;内联后若能证明引用没有逃出调用者,结果又可能不同。缓冲区结构体与它引用的底层数组也可以有不同归宿。在 modeldock 目录运行 go test -gcflags='-m=2' ./labs/runtime 看编译器对具体表达式的判断,不要从出现 new 就推断一次堆分配。
小对象通常按尺寸分级,从当前 P 的 mcache 领取空位;当前 span 用尽才向 mcentral 补充,更下层由 mheap 管理页。大对象不沿同样的小对象快路径。前缀先写入一个小缓冲,追加正文时装不下,Buffer 扩容并复制已有字节。那块旧数组随后可能变成垃圾。调度器给 G 时间执行,也让分配路径能用 P 的本地缓存;GMP 实验改变的是执行资源,不会让这次扩容凭空消失。
flowchart LR; Write-->Grow; Grow-->Allocate; Allocate-->Heap; Heap-->Mark; Mark-->Sweep; Allocate-->Assist; Assist-->Mark
图里最容易忽略的是 Allocate → Assist。标记期间,从栈、全局引用等根出发,灰色对象已经发现但内部指针尚未扫描,黑色对象已经扫描。假设程序把尚未发现的对象移到已扫描对象下面,同时删掉原来可追踪的引用;若回收器只走原来的路,就可能漏掉仍活着的对象。并发写屏障在指针变动时维持标记所需的不变量。三色是正确性模型,不是说每个对象都存着一个三值字段。
标记的大部分工作与业务并发,阶段切换仍有短暂停顿;分配太快的 G 可能被要求协助标记。于是即使 STW 很短,业务也会因 CPU 被标记消耗而变慢。本文实验用 Go 1.26.2;Go 1.26 发布说明中 Green Tea 成为默认 GC。扫描的组织和局部性会随版本变化,不能把旧版扫描器逐对象的执行细节当成语言保证。
先量分配,再解释调参能改变什么
sh
cd modeldock
go test ./labs/runtime -run '^$' -bench BenchmarkFrame -cpu=1,4 -benchmem
go test ./labs/runtime -run '^$' -bench BenchmarkFrame/fresh -memprofile /tmp/cloudlab-frame.mem -o /tmp/cloudlab-frame.test
go tool pprof -top -alloc_space /tmp/cloudlab-frame.test /tmp/cloudlab-frame.mem本轮 Apple M5 Pro、单 P:fresh 为 783.0 ns/op、4976 B/op、3 allocs/op;pool 为 428.5 ns/op、0 B/op、0 allocs/op。两者都构造帧并计算校验和。这个差值只属于拼帧;sync.Pool 可以被 GC 清理,一次稳态测得零分配不承诺每次 Get 都命中。Reset 还会保留容量,偶发超大帧不宜无限制放回池里。
GOGC 影响堆增长目标:提高它通常让分配者有更多空间再开始下轮回收,以内存换取较少 GC CPU。GOMEMLIMIT 约束 runtime 管理内存的软目标,并非 RSS 硬上限。把限额压到接近活跃对象集合时,GC 可能反复扫描同一批活对象却腾不出多少空间。用下面两次独立进程观察 gctrace 和 B/op;先看回收频率,再问每帧分配源有没有改变。
sh
GOGC=50 GODEBUG=gctrace=1 /tmp/cloudlab-frame.test -test.run='^$' -test.bench=BenchmarkFrame/fresh
GOGC=150 GOMEMLIMIT=128MiB GODEBUG=gctrace=1 /tmp/cloudlab-frame.test -test.run='^$' -test.bench=BenchmarkFrame/fresh面试追问:累计分配持续上升就有泄漏吗? 一个持续工作的服务本来就会不断分配。alloc_space 找的是产生分配的调用栈,inuse_space 才描述采样时仍存活的分配。要证明滞留,在可比负载和回收条件下比较多份存活剖面,再查是谁持有对象。下面把这个区别带进真实 HTTP handler。
把同一个问题搬进 HTTP 服务
微基准能隔离代码,服务 profile 则能检查真实 handler 是不是沿同一条路径分配。下面的实验每次调用构造 4096 帧,返回校验和;fresh 与 pool 输出应该相同。服务只绑定回环地址,诊断端点不需要对外暴露。
sh
(cd modeldock && go run ./cmd/frameprof)
# 新终端
curl -fsS http://127.0.0.1:19066/frames/pool
curl -fsS http://127.0.0.1:19066/frames/fresh
curl -fsS 'http://127.0.0.1:19066/debug/pprof/heap?gc=1' -o /tmp/cloudlab-http.mem
go tool pprof -top -alloc_space /tmp/cloudlab-http.mem
go tool pprof -top -inuse_space /tmp/cloudlab-http.mem
rm /tmp/cloudlab-http.mem
# 原终端 Ctrl+C 停服务本轮两条路径校验和一致,累计分配 profile 里 bytes.growSlice 为 19.08 MB(97.02%)。见服务 profile。本例提高了内存采样频率;不应把这个采样开销当成线上默认值。要比较两种模式各自的累计分配,分别重启服务做同样次数的请求,不能先跑 fresh 再把整个进程历史算给 pool。
完整源码:modeldock/cmd/frameprof/main.go · 第 26–43 行
go
mux.HandleFunc("GET /debug/pprof/profile", pprof.Profile)
mux.HandleFunc("GET /frames/{mode}", func(w http.ResponseWriter, r *http.Request) {
mode := r.PathValue("mode")
if mode != "fresh" && mode != "pool" {
http.Error(w, "fresh or pool", 400)
return
}
var sum uint32
for i := 0; i < 4096; i++ {
if r.Context().Err() != nil {
return
}
var b *bytes.Buffer
if mode == "pool" {
b = pool.Get().(*bytes.Buffer)
b.Reset()
} else {
b = new(bytes.Buffer)