Go 逃逸分析

这段话的核心是 Go 语言中“传值 vs 传指针”的性能权衡,以及 Go 编译器的逃逸分析(escape analysis)机制。不要盲目用指针“优化”拷贝,因为它经常会导致变量从栈(stack)逃逸到堆(heap),而堆分配 + GC 的开销远大于栈上的简单拷贝。

1. 栈 vs 堆的基本区别(为什么重要)

  • 栈(Stack)

    • 分配极快(只是移动栈指针)。
    • 函数返回时自动回收(LIFO)。
    • 访问速度快,CPU 缓存友好。
    • 几乎无额外开销,不需要 GC。
  • 堆(Heap)

    • 分配较慢(runtime 管理)。
    • 需要垃圾回收(GC)跟踪和清理。
    • 指针间接访问可能导致缓存未命中。
    • 长期存活的对象会增加 GC 压力,尤其在高并发/hot path 中。

Go 编译器会尽量把变量放在上,只有当它“可能”在函数返回后还被使用时,才会逃逸到堆

2. 传值(by value)时发生了什么?

type User struct {
    ID   int
    Name string
    // ... 其他字段
}

func process(u User) { // 传值
    // ...
}

func main() {
    u := User{...}
    process(u) // 这里复制一份 u
}
  • 复制发生在调用方的栈帧上(或寄存器)。
  • 对于中小型结构体(通常 < 64~128 字节,取决于编译器),复制开销极小(CPU 几纳秒就能搞定)。
  • 局部变量 u 通常不逃逸,整个过程都在栈上,零堆分配。

基准测试常见结果:传值可能只有 0.3 ns/op,0 allocs/op。

3. 传指针(by pointer)时发生了什么?

func processPtr(u *User) { // 传指针
    // ...
}

func main() {
    u := User{...}
    processPtr(&u) // 传递地址
}
  • 表面上看:只复制 8 字节指针(64位系统),避免了结构体拷贝。
  • 实际可能:编译器进行逃逸分析
    • 如果函数只是读取/临时使用,且编译器能证明指针不会“泄露”(不存到全局、返回、不存到 interface 等),可能还留在栈。
    • 很多情况下(尤其是指针被传递给其他函数、存字段、返回等),编译器保守地认为变量可能在函数返回后还被使用 → 变量逃逸到堆

结果

  • 堆分配(~25-50 ns + GC 开销)。
  • 基准测试中可能慢 40 倍,并产生 allocs。

查看逃逸分析的方法:

go build -gcflags="-m" yourfile.go
# 或
go test -gcflags="-m" ./...

你会看到类似 "moved to heap: u""u escapes to heap" 的提示。

4. 为什么“盲目用指针”反而更慢?

  • 栈复制:廉价、确定性、自动清理。
  • 堆分配:昂贵 + GC 压力累积(尤其高 QPS 服务)。
  • 对于小结构体,拷贝几乎免费;对于大结构体(> 几百字节或含大量数据),指针才更有优势——但前提是必须共享/修改状态。
  • 额外副作用:指针接收者方法、interface{}、闭包、返回指针等,都容易触发逃逸。

经验法则(社区共识):

  • 默认传值(尤其是小结构体、不可变数据)。
  • 只在需要修改原对象、共享状态、或结构体非常大时用指针。
  • 返回时:小结构体优先返回 value,大结构体或需要 nil 表示“无”时才返回指针。
  • 始终用 benchmark + pprof + -m 验证,不要凭感觉优化。

总结理解

这段话的本质是:性能优化要尊重 Go 的内存模型。栈拷贝是“快路径”,堆逃逸是“慢路径”。盲目“传指针省拷贝”往往把快路径变成了慢路径 + GC 负担。编译器在栈上做的事远比你想象的聪明和便宜。

想深入可以自己写个小 benchmark,对比不同大小的 struct 传值/传指针,并用 -gcflags="-m" 观察逃逸情况。实践是最好的老师!