惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

D
Docker
Simon Willison's Weblog
Simon Willison's Weblog
H
Help Net Security
F
Fortinet All Blogs
H
Heimdal Security Blog
S
Schneier on Security
L
LangChain Blog
博客园 - Franky
酷 壳 – CoolShell
酷 壳 – CoolShell
NISL@THU
NISL@THU
P
Palo Alto Networks Blog
J
Java Code Geeks
博客园 - 【当耐特】
The Last Watchdog
The Last Watchdog
W
WeLiveSecurity
www.infosecurity-magazine.com
www.infosecurity-magazine.com
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
V
Vulnerabilities – Threatpost
I
InfoQ
Recorded Future
Recorded Future
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
C
CERT Recently Published Vulnerability Notes
T
Tenable Blog
腾讯CDC
C
Check Point Blog
量子位
M
MIT News - Artificial intelligence
GbyAI
GbyAI
罗磊的独立博客
Threat Intelligence Blog | Flashpoint
Threat Intelligence Blog | Flashpoint
B
Blog
小众软件
小众软件
cs.AI updates on arXiv.org
cs.AI updates on arXiv.org
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
C
CXSECURITY Database RSS Feed - CXSecurity.com
Stack Overflow Blog
Stack Overflow Blog
P
Proofpoint News Feed
P
Privacy & Cybersecurity Law Blog
V2EX - 技术
V2EX - 技术
T
Threatpost
Engineering at Meta
Engineering at Meta
Attack and Defense Labs
Attack and Defense Labs
T
Tailwind CSS Blog
S
Securelist
The Cloudflare Blog
博客园 - 叶小钗
L
LINUX DO - 最新话题
T
Troy Hunt's Blog
C
Cyber Attacks, Cyber Crime and Cyber Security
爱范儿
爱范儿

重归混沌的BLOG

给silly实现了一个ernro模块 | 重归混沌的BLOG 给silly实现了一个ernro模块 | 重归混沌的BLOG 第一次在生产环境使用 Vibe Coding | 重归混沌的BLOG 第一次在生产环境使用 Vibe Coding | 重归混沌的BLOG API 设计的艰难抉择 | 重归混沌的BLOG API 设计的艰难抉择 | 重归混沌的BLOG 十年 | 重归混沌的BLOG 十年 | 重归混沌的BLOG 在Go语言中如何使XML加载内存无限趋近于0 | 重归混沌的BLOG 在Go语言中如何使XML加载内存无限趋近于0 | 重归混沌的BLOG 对跨服玩法中的分布式一致性问题进行简单抽象 | 重归混沌的BLOG 对跨服玩法中的分布式一致性问题进行简单抽象 | 重归混沌的BLOG Go语言逃逸分析之slice和map | 重归混沌的BLOG 谈谈观测 | 重归混沌的BLOG 谈谈观测 | 重归混沌的BLOG 写了个AI Agent服务端 | 重归混沌的BLOG 写了个AI Agent服务端 | 重归混沌的BLOG 谈谈代码设计中“严丝合缝” | 重归混沌的BLOG 谈谈代码设计中“严丝合缝” | 重归混沌的BLOG 一次艰难的线上游戏服务器内存排查经历 | 重归混沌的BLOG 一次艰难的线上游戏服务器内存排查经历 如何基于LanguageServerProtocol来编写lint工具 谈谈游戏服务器中RPC模块的设计 谈谈游戏服务器代码抽象 最近碰到的一个分布式一致性问题 谈谈游戏服务器的自动化测试 对Raft协议的一点理解 使用mmap来学习/proc/pid/smaps 2023(完) 再次实现了一个Lua性能分析器 终于给Silly的定时器增加了取消功能 一次虚拟内存排查经历 游戏服务器分布式数据的一种同步的思路 为silly增加了互斥锁 2022(完) Go语言之闭包篇 一例误用unsafe包引起的内存问题 Go语言之内存篇 初识Go语言 重新抽象图形API 给Lua实现了一个数学库 谈谈跨平台图形API的抽象 寻路和Flocking算法的结合 行为树的一种高效实现 内测过程中Shader出现的问题 彻底解决多国语言 谈谈数据库的选型 再谈Lua热更新(终) 初窥Rust 关于游戏服务器的服务拆分 ECS的初步实现 ECS初探 屏幕空间(SreenSpace)的想象力 一些对辐射度量学的理解 深度缓冲和半透明渲染 Mysql的间隙锁 更新一些GPU相关知识 2020 地形渲染之爬过的坑 Lua5.3 GC源码阅读(5) 实现一个数据库存储队列 再学计算机图形学入门 再谈分布式服务架构 游戏上线一个月后的反思 一次并发Bug 双向链表的三种实现 谈谈随机数的使用 再谈性能优化 2019 Lua中的函数式编程 重构登录逻辑 Unity资源管理(续) 谈谈Unity的资源管理 一次关于Cache的性能分析 历史之2018 DC3算法 移动平台native代码遭遇的坑 从CPU层面谈谈优化 开卷有益(UNIX编程艺术篇) GC竞争问题 通过Mesh投影来实现贴花系统 谈谈我对数据同步的理解 又一个类型提升引起的Bug Lua5.3 GC源码阅读(4) Lua5.3 GC源码阅读(3) Lua5.3 GC源码阅读(2) Lua5.3 GC源码阅读(1) 三角形光栅化时遇到的坑 一次git事故 再见2017 又一个lua调试器 客户端缓存落地方案 Paxos算法 HTTP服务器的特点 一次性能优化经历 关于CPU分支预测 C程序中让两个不同版本的库共存 实现了一个AOI模块 一个高可伸缩的游戏服务器架构 关于网络协议封装的一些新想法
Go语言逃逸分析之slice和map | 重归混沌的BLOG
重归混沌 · 2025-06-18 · via 重归混沌的BLOG

本来我以为,凭着之前写的 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 的不变式:

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