原因

目前 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 (因为 unsafesync.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。


秦川
6 声望3 粉丝

September 3rd 2019.