本来我以为,凭着之前写的 Go 语言三部曲(《初识 Go 语言》《Go 语言之内存篇》《Go 语言之闭包篇》),已经足够扫平工作中遇到的大部分障碍了。
虽然 Go 语言的逃逸分析有一些特殊的规则,但是他本身的实现却很朴素, 一直以来也没太重视 Go 语言的逃逸分析。
正所谓太阳底下没有新鲜事,Go 语言的逃逸分析也好,Rust 语言的无 GC 设计也罢,本质上和我们 C 语言时代“尽量分配在栈上”的思路是一致的。
换句话说,只要延续当年写 C 语言时那套“避免 malloc”的技巧,大概率变量就不会逃逸,同时也能顺利通过 Rust 编译器的生命周期检查。
这里用了“大概率”这个词,是因为无论是 Go 的逃逸分析,还是 Rust 的生命周期系统,说到底都只是对那套 C 语言技巧的一个子集。很多复杂的优化手段,编译器是分析不出来的。
最近在和同学讨论map的逃逸分析时发现一些有趣的现象,我觉得是时候总结一下了。
先来看一段代码,测试环境为Go 1.23.8
。
package main
func SliceUnescape(n int) int {
s := make([]int, 0, 128)
for i := 0; i < n; i++ {
s = append(s, i*2)
}
sum := 0
for _, v := range s {
sum += v
}
return sum / len(s)
}
func SliceEscape(n int) int {
s := make([]int, 0, n)
for i := 0; i < n; i++ {
s = append(s, i*2)
}
sum := 0
for _, v := range s {
sum += v
}
return sum
}
func MapUnescape(n int) int {
m := make(map[int]int, n) // 这里会不会逃逸取决于n的大小,具体可以对比C代码
for i := 0; i < n; i++ {
m[i] = i*2
}
sum := 0
for _, v := range m {
sum += v
}
return sum
}
func MapEscape(n int) any {
m := make(map[int]int, n)
for i := 0; i < 8; i++ {
m[i] = i*2
}
return m
}
func main() {}
$ go build -gcflags="-m -l" a.go
./a.go:4:11: make([]int, 0, 128) does not escape
./a.go:16:11: make([]int, 0, n) escapes to heap
./a.go:28:11: make(map[int]int, n) does not escape
./a.go:40:11: make(map[int]int, n) escapes to heap
让我们来看段C代码,他是由Go代码编译成汇编语言,再人工反编译成的等效代码。
typedef struct {
int *data;
int len;
int cap;
} slice;
typdef struct {
tophash uint8_t[8]
int key[8];
int values[8];
} bmap;
typedef struct {
count int
flags uint8
B uint8
noverflow uint16
hash0 uint32
buckets *bmap
oldbuckets unsafe.Pointer
nevacuate uintptr
extra *mapextra // optional fields
} hmap;
int SliceUnescape(int n) {
int buf[128];
slice *s = alloca(sizeof(slice)); // 分配在栈上
s->data = buf;
s->len = 0;
s->cap = 128;
for (int i = 0; i < n; i++) {
s->data[s->len++] = i * 2;
if (s->len == s->cap) {
int *ptr;
s->cap *= 2;
ptr = malloc(s->cap * sizeof(int));
memcpy(ptr, s->data, s->len * sizeof(int));
s->data = ptr;
// 旧内存靠GC系统释放
}
}
int sum = 0;
for (int i = 0; i < s->len; i++) {
sum += s->data[i];
}
return sum / s->len;
}
int SliceEscape(int n) {
slice *s = malloc(sizeof(slice));
s->data = malloc(n * sizeof(int));
s->len = 0;
s->cap = n;
for (int i = 0; i < n; i++) {
s->data[s->len++] = i * 2;
}
return s;
}
int MapUnescape(int n) {
hmap *m;
maptype *t = typeof(map[int]int); // 伪码,因为C语言没有反射类型系统
if n <= 8 { // 8 这个值是目前Go编译器写死的,未来不保证不会改变
m = alloca(sizeof(hmap));
m->count = 0;
m->flags = 0;
m->B = 0;
m->noverflow = 0;
m->hash0 = 0;
m->buckets = alloc(sizeof(bmap));
m->oldbuckets = NULL;
runtime.makemap(t, n, m);
} else {
// 当makemap第三个参数为NULL时,会强制将hmap分配在堆上
m = runtime.makemap(t, n, NULL);
}
for i := 0; i < n; i++ {
runtime.mapassign_fast64(t, m, i) = i*2;
}
sum := 0
for _, v := range m {
sum += v
}
return sum
}
void *MapEscape(int n) {
hmap *m;
maptype *t = typeof(map[int]int); // 伪码,因为C语言没有反射类型系统
// 当makemap第三个参数为NULL时,会强制将hmap分配在堆上
m = runtime.makemap(t, n, NULL);
for i := 0; i < 8; i++ {
runtime.mapassign_fast64(t, m, i) = i*2;
}
return m
}
使用 C 代码重写后,结论已经非常清晰了。
先来看 slice
。
当 slice
被识别为 不逃逸 时,struct slice
结构体本身和 slice.data
指向的内存会一起分配在栈上。
如果后续发生扩容,slice.data
才会从栈迁移到堆。
当 slice
被识别为 逃逸 时,struct slice
和 slice.data
指向的内存则会一起分配到堆上。
slice
的逃逸规则非常复杂,但有两点是确定的:
- 使用 可变大小
make
创建的slice
一定会逃逸, 即便初始容量非常小。从函数SliceEscape
中可以很直观地看出这一点。 - 使用 常量大小
make
创建的slice
在某些情况下不会逃逸(复杂规则的来源就在于此,例如取地址传参等操作,都有可能会导致逃逸)。
再来看 map
。
当 map
被判定为 不逃逸 时,struct hmap
和其对应的 bucket
数组会一起分配在栈上。
当 map
被识别为 逃逸 时,struct hmap
结构和 bucket
数组也会一起分配到堆上。
相比于 slice
的逃逸规则,map
的逃逸判断要简单很多:
当我们使用 make
创建 map
时,如果指定的容量(即使是变量,在运行时也会被判定)小于等于 8,那么 hmap
和 bucket
都会被分配在栈上。
否则,map
就会逃逸,hmap
和 bucket
都会被分配到堆上。
总结一下。
像 slice
和 map
这类容器型数据结构,通常由一个 header
加上一个 buffer
组成。
当逃逸分析判断 slice
或 map
不逃逸时,header
和 buffer
会一起分配在栈上。
当判断为逃逸时,header
和 buffer
则会一起分配在堆上。
从上面简单的测试, 我们就可以得出一个结论: 由于某种不为人知的原因,Go 编译器总是倾向于让header
和buffer
拥有相同的生命周期。
以 SliceEscape
为例,struct slice
明明可以保留在栈上,仅让其指向堆上的数据,这样的行为就相当于“未逃逸但触发过扩容”的 slice
,可以节省一次内存分配与释放。
但 Go 编译器依然选择将 struct slice
一并分配到堆上。
从这个行为中,还可以顺带得出一条关于 GC 的不变式:
栈内存
只能被栈内存
引用堆内存
可以被栈内存
和堆内存
引用