Go语言逃逸分析之slice和map

本来我以为,凭着之前写的 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 sliceslice.data指向的内存则会一起分配到上。

slice 的逃逸规则非常复杂,但有两点是确定的:

  • 使用 可变大小 make 创建的 slice 一定会逃逸, 即便初始容量非常小。从函数 SliceEscape 中可以很直观地看出这一点。
  • 使用 常量大小 make 创建的 slice 在某些情况下不会逃逸(复杂规则的来源就在于此,例如取地址传参等操作,都有可能会导致逃逸)。

再来看 map

map 被判定为 不逃逸 时,struct hmap 和其对应的 bucket 数组会一起分配在上。

map 被识别为 逃逸 时,struct hmap 结构和 bucket 数组也会一起分配到上。

相比于 slice 的逃逸规则,map 的逃逸判断要简单很多:

当我们使用 make 创建 map 时,如果指定的容量(即使是变量,在运行时也会被判定)小于等于 8,那么 hmapbucket 都会被分配在上。

否则,map 就会逃逸,hmapbucket 都会被分配到上。


总结一下。

slicemap 这类容器型数据结构,通常由一个 header 加上一个 buffer 组成。

当逃逸分析判断 slicemap 不逃逸时,headerbuffer一起分配在上。

当判断为逃逸时,headerbuffer 则会一起分配在上。

从上面简单的测试, 我们就可以得出一个结论: 由于某种不为人知的原因,Go 编译器总是倾向于让headerbuffer拥有相同的生命周期。

SliceEscape 为例,struct slice 明明可以保留在栈上,仅让其指向堆上的数据,这样的行为就相当于“未逃逸但触发过扩容”的 slice,可以节省一次内存分配与释放。

但 Go 编译器依然选择将 struct slice 一并分配到堆上。

从这个行为中,还可以顺带得出一条关于 GC 的不变式:

  • 栈内存 只能被 栈内存 引用
  • 堆内存 可以被 栈内存堆内存 引用

发表评论

× seven = sixty three