原因
目前 Go 的 GC 虽然高效,但是也是有代价的。
对于一些会有大量堆对象生成的场景,GC 相关的内存和CPU资源占用,会导致服务吞吐量和相应速度受到影响。
因此需要一个效率更高且安全的内存管理机制,应对内存(GC)密集型的需求场景。
这也是个人长期以来对于 Go 的一个特别关注点。之前见过一些基于 mmap 系统内存自己管理的方案,但是很遗憾,这些方案看起来都很难真正的在项目中使用(接口复杂,抛弃了 Go 原生数据结构,有并发问题,容易导致panic等)。
22年初开始,一个叫 Go arena 的提议 引起了社区里的关注。
此提议下面的讨论丰富且精彩,写一篇博文来记录一下从中学习到的内容。
本文编写时间
2023年1月末,目前状态:
- Go 1.20(尚未发布) 里面已经有一个比较完善的 arena 实现。
- 可以通过
GOEXPERIMENT="arenas"
环境变量开启此项功能。 - (不知道是否和激烈的讨论有关)在 Go 1.20 的 release notes 里面不会提及此项 EXPERIMENT。
什么是 arena
Go values can be manually allocated and freed in bulk.
Go arena
: Go 1.20 新增的试验型的特性,用于手工批量申请和释放值(values)的内置库。
它有几个基本特点:
- 使用 Go 1.18 增加的泛型接口,分配基础原生类型和 slice。
- 不支持并发安全。
- 分配的值参加 GC。
为何 arena 会高效
- 一次申请,连续分配,一次释放。
- 更快的回收(重用)内存。
可能降低 GC 频率。
- 如果在 GC 周期之前 free,可能会推迟 GC 周期的执行
- 可以通过 GOGC 参数的原理了解。
减少 mark-sweep 开销 (错误:实际上也参与 GC)。
堆内存管理的本质
(栈上的值由编译器管理,略)
我个人的理解:
程序从堆上获取了内存,确没有在使用完毕,或者说生命周期结束的时候归还给系统。
和别家 Go 分配器相比的优势
- 原生类别支持更好
完整的GC支持:
- 一个堆上的对象,如果唯一指向它的指针是在 arena 中的话,它也==不会==被 GC 掉。
- 外部指向 arena 分配的对象不会变为错误数据。(但是会 panic,且并不保证)
什么是内存安全(memory safe)
比较好解释的是,什么是内存不安全:
- memory corruption
个人理解:一个对象指向或者引用的数据,被另一个处程序篡改写入(并非故意)。 - fault address crash
个人理解:某一个指针指向的值的内存地址实际上已经无效。指针地址运算和缺乏越界检查的语言会比较容易出现此问题。
关于内存泄露:我的理解是,程序失去或者遗忘了系统申请的内存的控制权,导致这块无法被控制的内存得不到应有的释放。
在 Go 程序里,不太会出现因为作用域而导致失去控制权的情况(因为会被GC处理)。
如果不出动 unsafe
包里的代码,Go 基本上来说是内存安全的。
Go 的内存安全
根据arena讨论,我学到一种新颖的说法:
Go 的指针,几乎只有两种状态:
- 指向一块合法的内存
nil
因此我们判断指针的时候,只要不是 nil 就能够很有信心的使用它!
if ptr != nil { v := *ptr }
一定不会 panic (但是data-race会导致错误的数据)。
但是,sync.Pool
会带来 memory corruption。
Arena 会带来 panic。
GC 能够带来什么
- 从繁琐的对象生命周期管理中解放出来
- 减少一些人工写的生命周期管理代码的错误
不能带来什么:
- 并发导致的 data race
- memory leak
- memory corruption (因为
unsafe
和sync.Pool
)
GC 是无可替代的么?
内存管理技术至少有这些:
- 手工管理
- GC
- 类似 borrow checker
(我的理解:通过借用所有权,让动态分配对象和生命周期和代码中的一处对象绑定,通过对这个对象的自动的来回收内存?)
实际使用起来的争论
在我看来,下面的几项可能是 arena 面临的最大问题,而且大部分问题目前看起来没有太好的方案。
##### 1. 是否是侵入性的接口,为何不能用库实现
首先确认无法用第三方库实现(因为和 GC 相关)。
其次,基于目前的实现,接口一定是侵入性且具有传染性的。
比如,我需要构建一个较为复杂的结构体,在构建的过程中会调用其他函数(甚至是第三方库的接口)来构建某些成员变量。那么相关的接口会被 arena 污染。
func newTObj(params...) *T {
t := T{a:1}
t.b = newObjB(params[2])
xx.UpdateC(t, params[3])
return &t
}
func newObjB(int) int
// package xx
func NewC(*T, int) int
如果在 newTObj
中使用了 arena,那么它调用的接口可能就变成了:
func newObjB(*arena, int) int
// package xx
func NewC(*arena, *T, int) int
即便引入 option 可选参数,带来的痛苦也会是巨大的。
##### 2. 是否会带来使用者的心智负担:arena 分配出的对象被其他地方保存(persist)。
我个人认为会有很大的负担。但是,这里有一个争论点:即在 Go 里面保留其他函数分配出的对象是否是正确的。这个说法估计可以吵几天(特别是引入 sync.Pool 之后),这里我保留观点。
##### 3. 是否会导致大量panic,如何防止上下游的老代码不会panic。
我认为会带来较多panic,因为没有人会在意一个对象是否被保存(或者逃逸)到其他地方去。
##### 4. 使用的第三发库怎样利用这个分配器(考虑到大量的内存实际上是类似 protobuf/json 库分配出的)
没有看到较好的解决方案
##### 5. panic的问题(见下文)
一个大问题: use-after-free
的行为一致性
func use_after_free() []byte {
a := arena.NewArena()
v := arena.MakeSlice[byte](a, 8, 8)
a.Free()
return v
}
请问,使用 use_after_free
返回的 slice 时:
- 究竟会不会 panic?
- 什么时候 panic?
- 是否让确保立刻 panic 会更好?
- (妥协方案)是否应当增加一种类似
-race
的模式,在此模式下所有的「不当的使用」能够被更加快速和直观的侦查到?
其他 arena 参考信息
- C++ 里面类似的技术常被使用,Java 20 也加了 arenas。
- 相比于
sync.Pool
,arena 能够:(1). 分配不同类型 (2). 无GC (3). 快速分配和回收 从系统获取一大块内存自己管理处理。
- 问题:原生数据兼容性
- 作用域问题
- 和GC的冲突
- 并发问题
实现相关的一些细节
- arena 地址空间和堆内存地址空间是完全分开的。意味着在做GC扫描的时候能够很轻易的区分堆上的对象和 arena 对象。
- Free 之后的 chunk 会立刻回收其物理内存,但是不会立刻被重用,当 GC 扫描到所有其分配对象都不被引用的时候,才会被重用。
- 因为有 GC 的存在,所以 arena 可能在用户显式调用 Free 之前就已经被 runtime Free 了。
其他
此 proposal 是 Google 内部人员发起的。意味着他们会遇到 GC 的问题么,让我们来无责任猜想一下它的前因后果:
- 他们内部有大量的提供服务的程序,主要使用是 gRPC。
- 对于每一个独立的请求处理,gRPC 框架的对于客户端发来的负责protobuf struct 的解包操作产生了大量的堆内存分配。
- 繁忙服务里的堆内存导致较高的 GC 开销,影响了业务吞吐量。
很自然的想法:既然每个 gRPC Call 都是独立的,从最外层的请求开始,整个处理链路使用到的堆上的值,会在请求结束时候统一的结束生命周期。那么,如果我们让这个处理链路所有的值都从自己的一个 slab 分配器上产生,并且在请求结束的时候一次性回收,岂不是在分配效率,回收效率和存活堆内存三个方面都有极大的改善?
于是有了 Go arena。