Go 中的栈与堆
Go 中栈帧与堆分配的分工:goroutine 栈可增长、堆由分配器与 GC 管理,以及正确性不依赖具体分配位置。
[!info] related notes
Go 中的栈与堆
这个概念为什么出现
讨论指针、性能与“变量在哪”时,经典二分是:
- 栈(stack):函数调用自动分配/回收
- 堆(heap):对象生命周期不必绑定某个调用帧;Go 的托管堆由 GC 回收,不由应用显式 free
在 Go 中还要加上第三条:
- 每个 goroutine 有自己的栈,且实现上通常可增长/收缩
- 编译器决定值放栈还是堆(逃逸分析)
- 正确性不依赖你猜对位置(FAQ)
不先建立这张图,就无法正确理解逃逸、分配器、GC 与“为何 goroutine 初始栈很小却能递归/深调用”。
[!abstract] 一句话理解 栈服务当前 goroutine 的函数调用帧;堆服务生命周期更长或无法证明限于栈帧的对象。栈分配随返回廉价失效,堆分配经分配器与 GC——位置由实现选择,语义由语言定义。
最小可观察示例
先把示例放进可验证实验场景,再看代码:
场景:弄清「变量在哪」——性能排障的前置地图
讨论 return &x 是否分配、goroutine 初始栈为何很小却能深调用时,需要一张栈帧 vs 堆地图。
工程师关心:栈随返回廉价失效;堆经分配器与 GC;正确性不依赖猜对位置,但读 pprof/-m 必须懂分工。本实验只建立直觉,不做会计级计量。
package main
import "fmt"
// heapish:返回局部地址——引用活过函数,编译器通常把 x 放到堆(实现决策)。
// 业务类比:构造 *Session 返回给调用方长期持有。
func heapish() *int {
x := 7
return &x
}
// stackish:只返回值——调用方不持有 &y,y 通常可在栈/寄存器(实现决策)。
// 业务类比:纯计算返回小整数/小结构体。
func stackish() int {
y := 7
return y
}
func main() {
p := heapish()
fmt.Println(*p, stackish()) // 期望:7 7
}
把第一个示例保存为新模块中的 main.go,先 go mod init example.com/stack-demo,再运行 go run . 与编译观察:
go build -gcflags="-m" .
# 期望:heapish 中 x 常出现 moved to heap 类备注(文案随版本变化)
下面另存 stats.go,用 go run stats.go 运行,避免两个 main 同时参与构建。这只展示进程级统计接口,不证明单个局部变量的位置或一次栈增长:
package main
import (
"fmt"
"runtime"
)
func main() {
var st runtime.MemStats
runtime.ReadMemStats(&st)
fmt.Printf("HeapAlloc=%d StackInuse=%d\n", st.HeapAlloc, st.StackInuse)
done := make(chan struct{})
go func() {
// 仅演示启动与等待;未使用的局部数组可能被优化掉,不能据此测栈增长。
close(done)
}()
<-done
runtime.ReadMemStats(&st)
fmt.Printf("HeapAlloc=%d StackInuse=%d\n", st.HeapAlloc, st.StackInuse)
}
HeapAlloc 包含尚未回收的堆对象(包括等待清理的垃圾),StackInuse 是用于栈的 span 字节数,含其中未用空间;它们不是 RSS,也不能用两次全进程快照把差值归因到单个变量。第二次读数还可能受任务退出、GC 与栈缓存影响。
结合场景再看关注点
- 栈服务调用帧,堆服务更长寿命/无法证明限于栈的对象。
- 位置由实现选择;语义由语言定义——先正确,再谈逃逸优化。
- 每 G 一栈解释了为何能开很多 goroutine,以及深调用与栈增长相关。
核心模型
两种存储的职责
flowchart TB
subgraph G["单个 goroutine"]
S["栈 frames<br/>参数·局部·返回地址"]
end
subgraph H["进程堆"]
O["可能堆分配的对象<br/>数组·map 存储·逃逸变量..."]
end
S -->|"指针/描述符"| O
O --> GC["GC 回收不可达对象"]
| 维度 | 栈 | 堆 |
|---|---|---|
| 谁分配 | 函数入口调栈(实现) | runtime 堆分配器 |
| 谁释放 | 返回后帧空间可复用,栈 backing memory 不一定归还 OS | 不可达对象由 GC 回收,归还 OS 另有过程 |
| 典型用途 | 当前 G 的调用帧;不能据位置推断并发安全 | 生命周期或大小等原因需要堆存储的对象 |
| 失败模式 | 栈溢出(少见,可增长) | OOM / 高 GC 压力 |
| 性能 | 极便宜 | 分配 + 后续 GC |
Goroutine 栈(实现直觉)
- 每个 G 自带栈,不是所有 G 共享一个 C 式进程栈模型。
- 初始栈较小,深度增加时可栈增长(复制到更大栈等策略,版本相关)。
- 栈上指针被运行时精确管理,GC 需要知道栈根。
- 调度切换保存的是 G 的上下文,包括栈边界信息。
这与 调度器 紧密相关。
堆
逃逸分析如何连接二者
编译器问:这个值能否只活在栈上?
- 能 → 栈/寄存器
- 不能 → 堆
指针与“悬挂”
在 C 中,自动存储对象生命周期结束后继续通过该地址访问是未定义行为。Go 允许返回局部变量地址,并保证正常引用所需的生命周期;独立函数通常需堆分配,内联后也可能在调用者栈上或被优化掉。unsafe/外部内存另有规则。
值传递与拷贝
大结构体在栈上传递可能发生拷贝;传指针减少拷贝却可能引起逃逸。取舍用 benchmark,不靠口号。见 值与引用语义。
规范 vs 实现分界
| 规范 / FAQ 级 | 实现细节 |
|---|---|
| 有自动内存管理(GC) | 初始栈大小、增长算法 |
| 程序员不手动 free 堆对象 | 连续栈 vs 分段栈等历史方案 |
| 栈或堆由实现选择 | 具体栈拷贝时机 |
| 语义正确性不依赖位置;MemStats 字段按对应版本 API 文档解读 | 运行中的具体计数、栈缓存与分配策略 |
| 数据竞争由内存模型定义 | 栈扫描实现 |
历史:Go 曾用分段栈,后改为连续栈增长等——说明栈机制可替换,应用代码不应依赖。
边界
-
大局部数组
可能直接在栈上占用巨大空间或迫使堆分配(视逃逸与大小策略)。热路径慎放巨大[N]T。 -
递归深度
栈可增长,但不是无限;极端递归仍可能耗尽内存。 -
cgo
C 栈与 Go 栈切换有额外规则与成本。 -
最终器与 resurrection 堆对象生命周期可被 finalizer 复杂化;不要当主资源管理。
-
调试器里“在栈上”
优化与寄存器分配会让“变量位置”更难直观对应源码行。
常见误区
[!warning] 认为 Go 没有栈只有堆 每个 G 都有栈;大量短命局部变量根本不进堆。
[!warning] 认为取地址一定堆分配 逃逸分析可能仍放栈。
[!warning] 用“在栈上”证明无竞态 并发安全取决于实际共享位置与同步协议;指针、slice、map 指向的数据不会因局部变量位于栈上而自动隔离。
[!warning] 把栈增长成本当成永远为零 当前运行时的栈增长涉及分配更大栈、复制活动帧及调整相关指针等工作;具体策略依版本而定,不能把深调用成本视为零。
[!warning] 手动“预留堆”来替代良好设计 先减少泄漏与不必要存活,再谈调 GC。
工程实践
- 正确性优先:按值/指针语义与生命周期写代码,不臆测位置。
- 性能:用
-benchmem、heap profile、必要时-m。 - API:按身份、拷贝成本和可变性选择值或指针;文档说明所有权,不把指针等同于可变共享。
- 避免:无界 goroutine + 大闭包捕获导致的堆驻留。
- 观测:
StackInusevsHeapAlloc帮助区分“太多 G/深栈”与“堆对象爆炸”。
可验证实验
实验 A:逃逸对比
heapish vs stackish 的 -m 与 benchmark allocs。
实验 B:goroutine 数量与 StackInuse
从有界的小批量开始(如 100、1000),用就绪计数确认 G 已到达等待点后采样,最后关闭释放信号并 Wait;记录工具链与 G 数,不把每次差值当成固定单 G 栈大小。
实验 C:大数组局部变量
func f() {
var a [1 << 20]byte // 1<<20 字节级
_ = a
}
这个片段的 a 没有可观察用途,可能整体消除;不能用它证明 1 MiB 在栈或堆。先让数组参与结果计算并保留结果,再结合 -m 和 benchmark 测量;存到全局本身会改变逃逸条件。
实验 D:指针传出
把局部 struct 地址存入包级变量,确认逃逸与生命周期延长。
本节总结
- 栈:每 G 的调用帧存储,随返回回收,实现可增长。
- 堆:跨生命周期对象,分配器 + GC。
- 逃逸分析连接二者;FAQ 明确位置不由程序员声明。
- 优化看数据;正确性看语义与同步,不看“猜在栈上”。
自测题
- 为什么 Go 返回局部变量地址通常是安全的?
- 栈与堆的释放机制有何本质不同?
GOMAXPROCS是否等于栈的个数?- 为何正确性不能依赖“变量在栈上”?
- 观察栈/堆压力时,应用层首选哪些工具?
参考答案
- Go 保证正常引用所需的对象生命周期;实现可选择堆、内联后的调用者栈或消除分配,返回局部地址本身合法。
- 栈随函数返回自动回收;堆对象在不可达后由 GC 回收。
- 否。
GOMAXPROCS与 P/并行度相关;栈个数与 goroutine 相关。 - 位置是实现决策,可随版本与优化变化;语言语义不绑定位置。
- benchmark/
-benchmem、pprof heap、runtime.MemStats、必要时-gcflags=-m。