Go 中的栈与堆

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

#type / concept #status / growing #tech / dev #resource / go

[!info] 关联笔记

Go 中的栈与堆

这个概念为什么出现

讨论指针、性能与“变量在哪”时,经典二分是:

  • 栈(stack):函数调用自动分配/回收
  • 堆(heap):动态生命周期,需显式释放——在 Go 里由 GC 回收

在 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
}
go build -gcflags="-m" .
# 期望:heapish 中 x 常出现 moved to heap 类备注(文案随版本变化)

再观察 goroutine 栈与堆统计(粗粒度过程量):

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() {
		// 每个 G 有自己的栈;大局部/深调用可能促使栈增长(实现细节)
		var big [256]byte
		_ = big
		close(done)
	}()
	<-done

	runtime.ReadMemStats(&st)
	fmt.Printf("HeapAlloc=%d StackInuse=%d\n", st.HeapAlloc, st.StackInuse)
}

MemStats 适合建立直觉,不适合当严格会计

结合场景再看关注点

  1. 栈服务调用帧,堆服务更长寿命/无法证明限于栈的对象
  2. 位置由实现选择;语义由语言定义——先正确,再谈逃逸优化。
  3. 每 G 一栈解释了为何能开很多 goroutine,以及深调用与栈增长相关。

核心模型

两种存储的职责

flowchart TB
    subgraph G["单个 goroutine"]
      S["栈 frames<br/>参数·局部·返回地址"]
    end
    subgraph H["进程堆"]
      O["对象<br/>slice 数组·map 桶·逃逸变量..."]
    end
    S -->|"指针/描述符"| O
    O --> GC["GC 回收不可达对象"]
维度
谁分配函数入口调栈(实现)runtime 堆分配器
谁释放函数返回GC
可见范围当前 G 的调用链任何持有引用者
失败模式栈溢出(少见,可增长)OOM / 高 GC 压力
性能极便宜分配 + 后续 GC

Goroutine 栈(实现直觉)

  • 每个 G 自带栈,不是所有 G 共享一个 C 式进程栈模型。
  • 初始栈较小,深度增加时可栈增长(复制到更大栈等策略,版本相关)。
  • 栈上指针被运行时精确管理,GC 需要知道栈根。
  • 调度切换保存的是 G 的上下文,包括栈边界信息。

这与 调度器 紧密相关。

  • 逃逸对象、make 的底层数据、大多数跨生命周期结构在此。
  • 分配路径见 分配器
  • 回收见 GC

逃逸分析如何连接二者

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

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

逃逸分析FAQ

指针与“悬挂”

在 C 里返回局部变量地址是未定义行为。在 Go 里,若引用在返回后仍需要,实现把对象放堆,避免悬空。你仍可能通过 unsafe 制造悬空,那是另一回事。

值传递与拷贝

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

规范 vs 实现分界

规范 / FAQ 级实现细节
有自动内存管理(GC)初始栈大小、增长算法
程序员不手动 free 堆对象连续栈 vs 分段栈等历史方案
栈或堆由实现选择具体栈拷贝时机
语义正确性不依赖位置MemStats 字段精确含义随版本
数据竞争由内存模型定义栈扫描实现

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

边界

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

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

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

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

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

常见误区

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

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

[!warning] 用“在栈上”证明无竞态 多 G 若共享堆上数据,仍要同步;栈私有也不等于你把指针发给了别人。

[!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

创建 1e5 个阻塞在 channel 上的 G,读 MemStats.StackInuseNumGoroutine(注意机器内存,量力而行)。

实验 C:大数组局部变量

func f() {
	var a [1 << 20]byte // 1<<20 字节级
	_ = a
}

对比改成 make([]byte, 1<<20) 的 heap 指标。

实验 D:指针传出

把局部 struct 地址存入包级变量,确认逃逸与生命周期延长。

本节总结

  • 栈:每 G 的调用帧存储,随返回回收,实现可增长。
  • 堆:跨生命周期对象,分配器 + GC。
  • 逃逸分析连接二者;FAQ 明确位置不由程序员声明
  • 优化看数据;正确性看语义与同步,不看“猜在栈上”。

自测题

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

延伸阅读

创建于 2026/7/14 更新于 2026/7/15