最近做的优化比较多,整理下和 Go 内存相关的一些东西。

一. 不要过早优化

虽是老生常谈,但确实需要在做性能优化的时候铭记在心,说说我的理解:

  1. first make it work, then measure, then optimize
  2. 二八原则
  3. 需求变更快
  4. 对性能的主观直觉不靠谱

二. 内存优化

Golang 运行时的内存分配算法主要源自 Google 为 C 语言开发的 TCMalloc 算法,全称 Thread-Caching Malloc。核心思路是层级管理,以降低锁竞争开销。Golang 内存管理大概分为三层,每个线程 (GPM 中的 P) 都会自行维护一个独立的内存池 (mcache),进行内存分配时优先从 mcache 中分配(无锁),当 mcache 内存不足时才会向全局内存池(mcentral) 申请 (有锁),当 mcentral 内存不足时再向全局堆空间管理(mheap) 中申请(有锁 + 按照固定大小切割),最后 mheap 如果不足,则向 OS 申请(SysCall)。mcache -> mcentral -> mheap -> OS 代价逐层递增,Golang 运行时的很多地方都有这种层级管理的思路,比如 GPM 调度模型中对 G 的分配,这种层级在并发运行时下,通常有比较好的性能表现。

以下讨论下内存优化 (主要是优化分配和 GC 开销,而非内存占用大小) 常用的手段。

1. 内存复用

关于内存复用最常见的手段就是内存池了,它缓存已分配但不再使用的对象,在下次分配时进行复用,以避免频繁的对象分配。

1.1 sync.Pool

Go 的sync.Pool包是 Go 官方提供的内存池,在使用 sync.Pool 时,需要注意:

  1. sync.Pool 是 goroutine safe 的,也就是会用 mutex
  2. sync.Pool 无法设置大小,所以理论上只受限于系统内存大小
  3. sync.Pool 中的对象不支持自定义过期时间及策略
  4. sync.Pool 中的对象会在 GC 开始前全部清除,这样可以让 Pool 大小随应用峰值而自动收缩扩张,更有效地利用内存。在 go1.13 中有优化,会留一部分 Object,相当于延缓了收缩扩张的速度

sync.Pool 适用于跨 goroutine 且需要动态伸缩的场景,典型的如网络层或日志层,每个连接 (goroutine) 都需要 Pool,并且连接数是不稳定的。

1.2 leakybuf

有时为了达成更轻量,更可控的复用,我们可能会根据应用场景自己造轮子,比如实现一个固定大小,不会被 GC 的内存池。比如 shadowsocks-go 的LeakyBuf就用 channel 巧妙实现了个[]byte Pool。

leakybuf 这类 Pool 相较于 sync.Pool 的主要优势是不会被 GC,但缺点是少了收缩性,设置大了浪费内存,设置小了复用作用不明显。因此它适用于能够提前预估池子大小的场景,在实践中,我们将其用在 DB 序列化层,其 Worker 数量固定,单个[]byte 较大,也相对稳定。

1.3 逻辑对象复用

复用的粒度不仅限于简单 struct 或 slice,也可以是逻辑实体。比如我们游戏中每次生成地图 NPC 时,会根据配置初始化大量的属性和 BUFF,涉及到很多小对象的分配,这里我们选择将整个 NPC 作为复用粒度,在 NPC 倒计时结束或被击败消失时,将 NPC 整理缓存在池子中,并重置其战斗状态和刷新时间。这种逻辑实体的复用不通用但往往有用,必要的时候可以派上用场。

1.4. 原地复用

除了内存池这种” 有借有还” 的复用外,另一种常用的内存复用思路是就地复用,它主要用于切片这类容易重置的数据结构,比如下面是一个过滤切片中所有奇数的操作:

  1. s := []int{1,2,3,4,5}
  2. ret := s[:0]
  3. for i:=0; i<len(s); i++ {
  4. if s[i] & 1 == 1{
  5. ret = append(ret, s[i])
  6. }
  7. }

另一个” 黑科技” 操作是[]byte to string:

  1. bs := make([]byte, 1000)
  2. ss2 = string(bs)
  3. ss1 = *(*string)(unsafe.Pointer(&bs))

切片的这类技巧经常用在网络层和算法层,有时候也能起到不错的效果。

2. 预分配

预分配主要针对 map, slice 这类数据结构,当你知道它要分配多大内存时,就提前分配,一是为了避免多次内存分配,二是为了减少 space grow 带来的数据迁移 (map evacuate or slice copy) 开销。

  1. n := 10
  2. src := make([]int, n)
  3. var dst []int
  4. for i:=0; i<n; i++ {
  5. dst = append(dst, src[i])
  6. }
  7. dst2 := make([]int, 0, n)
  8. for i:=0; i<n; i++ {
  9. dst2 = append(dst2, src[i])
  10. }
  11. dst2 = append(dst2, src...)
  12. copy(dst2, src)

可以看到,slice 预分配对性能的提升是非常大的,这里为了简单起见,以纯粹的拷贝切片为例,而对于切片拷贝,应该使用 go 专门为优化 slice 拷贝提供的内置函数 copy,如果抛开分配 dst2 的开销,copy(dst2, src)的运算速度达到 0.311ns,是append(dst, src...)的 20 倍左右!

平时编码中养成预分配的习惯是有利而无害的,Go 的一些代码分析工具如prealloc可以帮助你检查可做的预分配优化。

有时候预分配的大小不一定是精确的,也可能模糊的,比如要将一个数组中所有的偶数选出来,那么可以预分配 1/2 的容量,在不是特别好估算大小的情况下,尽可能保守分配。

预分配的另一个思路就是对于一些频繁生成的大对象,比如我们逻辑中打包地图实体,这是个很大的 pb 协议,pb 默认生成的内嵌消息字段全是指针,给指针赋值的过程中为了保证深拷贝语义需要频繁地分配这些各级字段的内存,为了优化分配内存次数,我们使用gogoproto的 nullable 生成器选项来对这类消息生成嵌套的值字段而非指针字段,这样可以减少内存分配次数 (但分配的数量是一样的)。

3. 避免不必要的分配

Go 的逃逸分析 + GC 会让 Gopher 对指针很青睐,receiver,struct field, arguments, return value 等,却容易忽略背后的开销 (当然,大部分时候开发者确实不需要操心)。

3.1 减少返回值逃逸

为了避免引用必要的时候也可以化切片为数组:

  1. func GetNineGrid1() []int {
  2. ret := make([]int, 9)
  3. return ret
  4. }
  5. func GetNineGrid2() []int {
  6. var ret [9]int
  7. return ret[:]
  8. }
  9. func GetNineGrid3() [9]int {
  10. return [9]int{}
  11. }

这还不够,有时候还需要对计算流程进行优化:

  1. type Coord struct{
  2. X int32
  3. Z int32
  4. }
  5. func f1(c *Coord) *Coord {
  6. ret := Coord{
  7. X: c.X/2,
  8. Z: c.Z/2,
  9. }
  10. return &ret
  11. }
  12. func f2() int32 {
  13. c := &Coord{
  14. X: 2,
  15. Z: 4,
  16. }
  17. c2 := f1(c)
  18. return c2.X
  19. }
  20. func new_f1(c *Coord, ret *Coord) {
  21. ret.X = c.X/2,
  22. ret.Z = c.Z/2,
  23. }
  24. func new_f2() int32 {
  25. c := &Coord{
  26. X: 2,
  27. Z: 4,
  28. }
  29. ret := &Coord{}
  30. new_f1(c, ret)
  31. return ret.X
  32. }

在上面的代码中,Go 编译器会分析到 f1 的变量 ret 地址会返回,因此它不能分配在栈在 (调用完成后,栈就回收了),必须分配在堆上,这就是逃逸分析 (escape analyze)。而对于 f2 中的变量 c 来说,虽然函数中用了 c 的地址,但都是用于其调用的子函数 (此时 f1 的栈还有效),并未返回或传到函数栈有效域外,因此 f2 中的 c 会分配到栈上。

在默认编译参数下,f1 的 ret 并不会逃逸,这是因为 f1 会被内联的,f1 的调用并不会有新的函数栈的扩展和收缩,都会在 f2 的函数栈上进行,由于 f2 中的变量都没有逃逸到 f2 之外,因此对 f2 的调用也不会有任何内存分配,可以通过-gcflags -N -l编译选项来禁用内联,并通过-gcflags -m打印逃逸分析信息。但内联也是有条件的 (函数足够简单,不涉及 Interface),我们将在后面再聊到内联。

3.2 化指针为值

可以将使用频繁并且简单的结构体比如前面的地图坐标 Coord,使用值而不是指针,这样可以减少不必要的变量逃逸带来的 GC 开销。

3.3 字符串操作优化

以下是一个简单的测试,并注有其 benchmark 性能数据:

  1. func stringSprintf() string {
  2. var s string
  3. v := "benmark"
  4. for i := 0; i < 10; i++ {
  5. s = fmt.Sprintf("%s[%s]", s, v)
  6. }
  7. return s
  8. }
  9. func stringPlus() string {
  10. var s string
  11. v := "benmark"
  12. for i := 0; i < 10; i++ {
  13. s = s + "[" + v + "]"
  14. }
  15. return s
  16. }
  17. func stringBuffer() string {
  18. var buffer bytes.Buffer
  19. v := "benmark"
  20. for i := 0; i < 10; i++ {
  21. buffer.WriteString("[")
  22. buffer.WriteString(v)
  23. buffer.WriteString("]")
  24. }
  25. return buffer.String()
  26. }
  27. func stringBuilder() string {
  28. var builder strings.Builder
  29. v := "benmark"
  30. for i := 0; i < 10; i++ {
  31. builder.WriteString("[")
  32. builder.WriteString(v)
  33. builder.WriteString("]")
  34. }
  35. return builder.String()
  36. }

导致内存分配差异如此大的主要原因是 string 是常量语义,每次在构造新的 string 时,都会将之前 string 的底层[]byte 拷贝一次,在执行多次 string 拼接时,strings.Builderbytes.Buffer会缓存中间生成的[]byte,直到真正需要 string 结果时再调用它们的String()返回,避免了生成不必要的 string 中间结果,因此在多次拼接字符串的场景,strings.Builderbytes.Buffer是更好的选择。

简单总结下:

  • fmt.Sprintf: 适用于将其它类型格式化为字符串,灵活性高
  • +: 适用于少量的的常量字符串拼接,易读性高
  • bytes.Buffer: 有比 slice 默认更激进的 cap 分配,它的String()方法需要拷贝。适用于大量的字符串的二进制拼接,如网络层。
  • strings.Builder: 底层使用 slice 默认 cap 分配,主要的优点是调用String()不需要拷贝 (直接地址转换),适用于要求输出结果是 string 的地方

三 逃逸和内联

1. 逃逸分析

前面简单提了下逃逸分析,这里我们再深入讨论下,逃逸分析虽然好用,却并不免费,只有理解其内部机制,才能将收益最大化 (开发效率 vs 运行效率)。逃逸分析的本质是当编译器发现函数变量将脱离函数栈有效域或被函数栈有效域外的变量所引用时时,将变量分配在堆上而不是栈在,典型的情况有:

  1. 函数返回变量地址,或返回包含变量地址的 struct,刚才已经讨论过
  2. 将变量地址写入 channel 或 sync.Pool,编译器无法获悉其它 goroutine 如何使用这个变量,也就无法在编译时决议变量的生命周期
  3. 闭包也可能导致闭包上下文逃逸
  4. slice 变量超过 cap 重新分配时,将在堆上进行,栈的大小毕竟是固定和有限的
  5. 将变量地址赋给可扩容容器 (如 map,slice) 时
  6. 将变量赋给可扩容 Interface 容器 (k 或 v 为 Interface 的 map,或[]Interface) 时
  7. 涉及到 Interface 的地方都有可能导致对象逃逸,MyInterface(x).Foo(a)将会导致 x 逃逸,如果 a 是引用语义 (指针, slice,map 等),a 也会分配到堆上,涉及到 Interface 的很多逃逸优化都很保守,比如reflect.ValueOf(x)会显式调用escapes(x)导致 x 逃逸。
    第 4 点和第 5 点单独说下,以 slice 和空接口为例:
  1. func example() {
  2. s1 := make([]int, 10)
  3. s2 := make([]*int, 10)
  4. s3 := make([]interface{}, 10)
  5. a := 123
  6. s1[1] = a
  7. s2[1] = &a
  8. s3[1] = a
  9. s3[1] = &a
  10. }

首先我们知道 slice 重分配是在堆上,slice 重分配时,会发生数据迁移,此时要将原本 slice len 内的元素浅拷贝到新的空间,而这个浅拷贝会导致新的 slice(堆内存) 引用了 p(栈内存) 的内容,而栈内存和堆内存的生命周期是不一样的,可能出现函数返回后,堆内存引用无效的栈内存的情况,这会影响到运行时的稳定性。因此即使 slice 变量本身没有显式逃逸,但由于隐式的数据迁移,编译器会保守地将 slice 或 map 的指针 elem 逃逸到堆上。这就是第 4 点的原因,也解释了上面代码中的 case1 case2 case4,现在来看看 case3。

简单来说,interface{} 让值语义变为引用语义,interface{} 本质上为 typ + pointer,这个 pointer 指向实际的 data,参考我之前写的go interface 实现s3[i] = a实际上让 s3 slice 持有了 a 的引用,因此 a 会逃逸到堆上分配。

我们逻辑中调用的fmt.Sprintflogrus.Debugf都会导致所有传入参数逃逸,因为不定参数实际上是 slice 的语法糖,编译器无法确定logrus.Debugf不会对参数 slice 进行 append 操作导致重分配,只能保守地将传入的参数分配到堆上以保证浅拷贝是正确的。我认为用保守来形容 Go 的逃逸分析策略是比较合适的,比如前面代码的 s1,s2,s3,既 slice 变量本身没有逃逸,也没有发生扩容,那么让 slice 以及其元素都在栈上应该是安全的,目前不理解 Go 编译器出于何种考虑没有做这种优化。当然,好的逃逸分析需要在编译期更深入地理解程序,这本身就是非常困难的,特别是当涉及到 interface{},指针,可扩展容器的时候。

2. 内联

前面说过,逃逸分析 + GC 好用但不免费,但如果没有内联这个最佳辅助的话,前两者的代价怕是昂贵得用不起,所有函数返回的地方,都会树立起一道” 墙”,任何想要从墙内逃逸到墙外的变量都会被分配在堆上,如:

  1. func NewCoord() *Coord {
  2. return &Coord{
  3. X: 1,
  4. Z: 2,
  5. }
  6. }
  7. func foo() {
  8. c := NewCoord()
  9. return c.x
  10. }

NewCoord这类简单的构造函数都会导致返回值分配在堆上,抽离函数的代价也更大。因此 Go 的内联,逃逸分析,GC 是名副其实的三剑客,它们共同将其它语言避之不及的指针变得” 物美价廉”。

Go1.9 开始对内联做了比较大的运行时优化,开始支持mid-stack inline,talk 链接在这里。并且支持通过-l编译参数指定内联等级 (参数定义参考cmd/compile/internal/gc/inl.go)。并且只在-l=4中提供了 mid-stack inline,据 Go 官方统计,这大概可以提升 9% 的性能,也增加了 11% 左右的二进制大小。

Go1.10 做一些 Interface 相关的优化,如devirtualization,编译器在能够知晓 Interface 具体对象的情况下 (如var i Iface = &myStruct{}),可以直接生成对象相关代码调用 (还不是内联),而无需走 Interface 方法查找。目前 devirtualization 优化还不完善,还不能应用于逃逸分析优化 (这里有讨论)。

Go1.12 开始默认支持了 mid-stack inline。

PS: 关注和更新 Go 最新版本可能是最” 廉价” 的优化手段。如 GC,编译器优化,defer 等特性都还在不断改进,毕竟 Go 还很年轻

我们目前还没有调整过内联参数,因为这是有利有弊的,过于激进的内联会导致生成的二进制文件更大,CPU instruction cache miss 也可能会增加。默认等级的内联大部分时候都工作得很好并且稳定,到目前 (Go1.13) 为止,对 Interface 方法的调用还不能被内联(即使编译器知晓具体类型):

  1. type I interface {
  2. F() int
  3. }
  4. type A struct{
  5. x int
  6. y int
  7. }
  8. func (a *A) F() int {
  9. z := a.x + a.y
  10. return z
  11. }
  12. func BenchmarkX(b *testing.B) {
  13. b.ReportAllocs()
  14. for i:=0; i<b.N; i++ {
  15. var i I = &A{}
  16. i.F()
  17. }
  18. }

对于一些底层基础的结构体,比如我们地图上的实体基础信息 Entity,包含 ID,坐标,碰撞半径等最基础的信息,我们为其抽象了一个接口 IEntity,它只提供简单的对字段的访问和设置,最近再考虑将 IEntity 去掉,直接用 Struct,借助于内联,字段访问会快一个数量级。

另外,针对目前 Go Interface 内联做得不够好的情况,一个比较好的实践是,让你的公用 API 返回具体类型而非 Interface,如etcdclient.Newgrpc.NewServer等都是如此实践的,它们通过私有字段加公开方法让外部用起来像 Interface 一样,但数据逻辑层可能实践起来会有一些难度,因为 Go 的访问控制太弱了…
https://wudaijun.com/2019/09/go-performance-optimization/