← [ BACK_TO_NOTES ]

Go 中的栈与堆

Go 中栈帧与堆分配的分工:goroutine 栈可增长、堆由分配器与 GC 管理,以及正确性不依赖具体分配位置。

#concept #growing #dev #go

[!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 与栈缓存影响。

结合场景再看关注点

  1. 栈服务调用帧,堆服务更长寿命/无法证明限于栈的对象。
  2. 位置由实现选择;语义由语言定义——先正确,再谈逃逸优化。
  3. 每 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 的上下文,包括栈边界信息。

这与 调度器 紧密相关。

堆

  • 逃逸对象或受大小等限制不能放栈的对象常在此;make 并不指定堆,slice/map 底层数据也可能栈分配或被优化掉。
  • 分配路径见 分配器。
  • 回收见 GC。

逃逸分析如何连接二者

编译器问:这个值能否只活在栈上?

  • 能 → 栈/寄存器
  • 不能 → 堆

见 逃逸分析 与 FAQ。

指针与“悬挂”

在 C 中,自动存储对象生命周期结束后继续通过该地址访问是未定义行为。Go 允许返回局部变量地址,并保证正常引用所需的生命周期;独立函数通常需堆分配,内联后也可能在调用者栈上或被优化掉。unsafe/外部内存另有规则。

值传递与拷贝

大结构体在栈上传递可能发生拷贝;传指针减少拷贝却可能引起逃逸。取舍用 benchmark,不靠口号。见 值与引用语义。

规范 vs 实现分界

规范 / FAQ 级实现细节
有自动内存管理(GC)初始栈大小、增长算法
程序员不手动 free 堆对象连续栈 vs 分段栈等历史方案
栈或堆由实现选择具体栈拷贝时机
语义正确性不依赖位置;MemStats 字段按对应版本 API 文档解读运行中的具体计数、栈缓存与分配策略
数据竞争由内存模型定义栈扫描实现

历史:Go 曾用分段栈,后改为连续栈增长等——说明栈机制可替换,应用代码不应依赖。

边界

  1. 大局部数组
    可能直接在栈上占用巨大空间或迫使堆分配(视逃逸与大小策略)。热路径慎放巨大 [N]T。

  2. 递归深度
    栈可增长,但不是无限;极端递归仍可能耗尽内存。

  3. cgo
    C 栈与 Go 栈切换有额外规则与成本。

  4. 最终器与 resurrection 堆对象生命周期可被 finalizer 复杂化;不要当主资源管理。

  5. 调试器里“在栈上”
    优化与寄存器分配会让“变量位置”更难直观对应源码行。

常见误区

[!warning] 认为 Go 没有栈只有堆 每个 G 都有栈;大量短命局部变量根本不进堆。

[!warning] 认为取地址一定堆分配 逃逸分析可能仍放栈。

[!warning] 用“在栈上”证明无竞态 并发安全取决于实际共享位置与同步协议;指针、slice、map 指向的数据不会因局部变量位于栈上而自动隔离。

[!warning] 把栈增长成本当成永远为零 当前运行时的栈增长涉及分配更大栈、复制活动帧及调整相关指针等工作;具体策略依版本而定,不能把深调用成本视为零。

[!warning] 手动“预留堆”来替代良好设计 先减少泄漏与不必要存活,再谈调 GC。

工程实践

  1. 正确性优先:按值/指针语义与生命周期写代码,不臆测位置。
  2. 性能:用 -benchmem、heap profile、必要时 -m。
  3. API:按身份、拷贝成本和可变性选择值或指针;文档说明所有权,不把指针等同于可变共享。
  4. 避免:无界 goroutine + 大闭包捕获导致的堆驻留。
  5. 观测:StackInuse vs HeapAlloc 帮助区分“太多 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 明确位置不由程序员声明。
  • 优化看数据;正确性看语义与同步,不看“猜在栈上”。

自测题

  1. 为什么 Go 返回局部变量地址通常是安全的?
  2. 栈与堆的释放机制有何本质不同?
  3. GOMAXPROCS 是否等于栈的个数?
  4. 为何正确性不能依赖“变量在栈上”?
  5. 观察栈/堆压力时,应用层首选哪些工具?
参考答案
  1. Go 保证正常引用所需的对象生命周期;实现可选择堆、内联后的调用者栈或消除分配,返回局部地址本身合法。
  2. 栈随函数返回自动回收;堆对象在不可达后由 GC 回收。
  3. 否。GOMAXPROCS 与 P/并行度相关;栈个数与 goroutine 相关。
  4. 位置是实现决策,可随版本与优化变化;语言语义不绑定位置。
  5. benchmark/-benchmem、pprof heap、runtime.MemStats、必要时 -gcflags=-m。

延伸阅读

创建于 2026/7/14 更新于 2026/10/6
Digital Biome
Access

键盘导航

j / k / ↑ / ↓
选择对象
Enter / o
打开选择
/ 或 Ctrl+K
全站搜索
i / f / 1–4
筛选当前列表
g → h / l / p / t / a
首页 / 笔记 / 项目 / 工具 / About
r → 1–9
打开所选对象的关联
u
返回上一页
Esc
关闭浮层 / 取消命令;输入框内清空文本