高性能go服务之高效内存分配

手动内存管理真的很坑爹(如C C++),好在我们有强大的自动化系统能够管理内存分配和生命周期,从而解放我们的双手。

但是呢,如果你想通过调整JVM垃圾回收器参数或者是优化go代码的内存分配模式话来解决问题的话,这是远远不够的。自动化的内存管理帮我们规避了大部分的错误,但这只是故事的一半。我们必须要合理有效构建我们的软件,这样垃圾回收系统可以有效工作。

SRE实战 互联网时代守护先锋,助力企业售后服务体系运筹帷幄!一键直达领取阿里云限量特价优惠。

在构建高性能go服务Centrifuge时我们学习到的内存相关的东西,在这里进行分享。Centrifuge每秒钟可以处理成百上千的事件。Centrifuge是Segment公司基础设施的关键部分。一致性、行为可预测是必须的。整洁、高效和精确的使用内存是实现一致性的重要部分。

这篇文章,我们将介绍导致低效率和与内存分配相关的生产意外的常见模式,以及消除这些问题的实用方法。我们会专注于分配器的核心机制,为广大开发人员提供一种处理内存使用的方法。

使用工具

首先我们建议的是避免过早进行优化。Go提供了出色的分析工具,能够直接指向内存分配密集的代码部分。没有必要重新造轮子,我们直接参考Go官方这篇文章即可。它为使用pprof进行CPU和分配分析提供了可靠的demo。我们在Segment中用于查找生产Go代码中的瓶颈的工具就是它,学会使用pprof是基本要求。

另外,使用数据去推动你的优化。

逃逸分析

Go能够自动管理内存分配。这可以防止一大类潜在错误,但是不能说完全不去了解分配的机制。

首先要记住一点:栈分配是很廉价的而堆分配代价是昂贵的。我们来看一下具体含义。

Go在两个地方分配内存:用于动态分配的全局堆,以及用于每个goroutine的局部栈。Go偏向于在栈中分配----大多数go程序的分配都是在栈上面的。栈分配很廉价,因为它只需要两个CPU指令:一个是分配入栈,另一个是栈内释放。

但是不幸的是,不是所有数据都能使用栈上分配的内存。栈分配要求可以在编译时确定变量的生存期和内存占用量。然而堆上的动态分配发生在运行时。malloc必须去找一块儿足够大的空闲内存来保存新值。然后垃圾收集器扫描堆以查找不再引用的对象。毫无疑问,它比堆栈分配使用的两条指令要贵得多。

编译器使用逃逸分析技术去选择堆或者栈。基本思想是在编译时期进行垃圾收集工作。编译器追踪代码域变量的作用范围。它使用追踪数据来检查哪些变量的生命周期是完全可知的。如果变量通过这些检查,则可以在栈上进行分配。如果没通过,也就是所说的逃逸,则必须在堆上分配。

go语言里没有明确说明逃逸分析规则。对于Go程序员来说,最直接去了解规则的方式就是去实验。通过构建时候加上go build -gcflags '-m',可以看到逃逸分析结果。我们看一个例子。

package main

import "fmt" func main() { x := 42 fmt.Println(x) } 
$ go build -gcflags '-m' ./main.go # command-line-arguments ./main.go:7: x escapes to heap ./main.go:7: main ... argument does not escape 

我们这里看到变量x“逃逸到堆上”,因为它是在运行时期动态在堆上分配的。这个例子可能有点困惑。我们肉眼看上去,显然x变量在main()方法上不会逃逸。编译器输出并没有解释为什么它会认为变量逃逸了。为了看到更多细节,再加上一个-m参数,可以看到更多输出

$ go build -gcflags '-m -m' ./main.go # command-line-arguments ./main.go:5: cannot inline main: non-leaf function ./main.go:7: x escapes to heap ./main.go:7: from ... argument (arg to ...) at ./main.go:7 ./main.go:7: from *(... argument) (indirection) at ./main.go:7 ./main.go:7: from ... argument (passed to call[argument content escapes]) at ./main.go:7 ./main.go:7: main ... argument does not escape 

这说明,x逃逸是因为它被传入一个方法参数里,这个方法参数自己逃逸了。后面可以看到更多这种情况。

规则可能看上去是随意的,经过工具的尝试,一些规律显现出来。这里列出了一些典型的导致逃逸的情况:

  • 发送指针或者是带有指针的值到channel里。编译时期没有办法知道哪个goroutine会受到channel中的数据。因此编译器无法确定这个数据什么时候不再被引用到。
  • 在slice中存储指针或者是带有指针的值。这种情况的一个例子是[]*string。它总会导致slice中的内容逃逸。尽管切片底层的数组还是在堆上,但是引用的数据逃逸到堆上了。
  • slice底层数组由于append操作超过了它的容量,它会重新分片内存。如果在编译时期知道切片的初始大小,则它会在栈上分配。如果切片的底层存储必须被扩展,数据在运行时才获取到。则它将在堆上分配。
  • 在接口类型上调用方法。对接口类型的方法调用是动态调用--接口的具体实现只有在运行时期才能确定。考虑一个接口类型为io.Reader的变量r。对r.Read(b)的调用将导致r的值和byte slice b的底层数组都逃逸,因此在堆上进行分配。

以我们的经验来讲,这四种情况是Go程序中最常见的动态分配情况。对于这些情况还是有一些解决方案的。接下来,我们将深入探讨如何解决生产软件中内存低效问题的一些具体示例。

指针相关

经验法则是:指针指向堆上分配的数据。 因此,减少程序中指针的数量会减少堆分配的数量。 这不是公理,但我们发现它是现实世界Go程序中的常见情况。

我们直觉上得出的一个常见的假设是这样的:“复制值代价是昂贵的,所以我会使用指针。”然而在许多情况下,复制值比使用指针的开销要便宜的多。你可能会问这是为什么。

  • 在解引用一个指针的时候,编译器会生成检查。它的目的是,如果指针是nil的话,通过运行panic()来避免内存损坏。这部分额外代码必须在运行时去运行。如果数据按值传递,它不会是nil。
  • 指针通常具有较差的引用局部性。函数中使用的所有值都在并置在堆栈内存中。引用局部性是代码高效的一个重要方面。它极大增加了变量在CPU caches中变热的可能性,并降低了预取时候未命中风险。
  • 复制缓存行中的对象大致相当于复制单个指针。 CPU在缓存层和主存在常量大小的缓存行上之间移动内存。 在x86上,cache行是64个字节。 此外,Go使用一种名为Duff`s devices的技术,使拷贝等常见内存操作非常高效。

指针应主要用于反映成员所有关系以及可变性。实际中,使用指针避免复制应该是不常见的。不要陷入过早优化陷阱。按值传递数据习惯是好的,只有在必要的时候才去使用指针传递数据。另外,值传递消除了nil从而增加了安全性。

减少程序中指针的数量可以产生另一个有用的结果,因为垃圾收集器将跳过不包含指针的内存区域。例如,根本不扫描返回类型为[]byte 的切片的堆区域。对于不包含任何具有指针类型字段的结构类型数组,也同样适用。

减少指针不仅减少垃圾回收的工作量,还会生存出”cache友好“的代码。读取内存会将数据从主存移到CPU cache中。Caches是优先的,因此必须清掉一些数据来腾出空间。cache清掉的数据可能会和程序的其它部分相关。由此产生的cache抖动可能会导致不可预期行为和突然改变生产服务的行为。

指针深入

减少指针使用通常意需要味着深入研究用于构建程序的类型的源代码。我们的服务Centrifuge保留了一个失败操作队列,来作为循环缓冲区重试去进行重试,其中包含一组如下所示的数据结构:

type retryQueue struct { buckets [][]retryItem // each bucket represents a 1 second interval currentTime time.Time currentOffset int } type retryItem struct { id ksuid.KSUID // ID of the item to retry time time.Time // exact time at which the item has to be retried } 

数组buckets的外部大小是一个常量值,但是[]retryItem所包含的items会在运行时期改变。重试次数越多,这些slices就变越大。

深入来看一下retryItem细节,我们了解到KSUID是一个[20]byte的同名类型,不包含指针,因此被逃逸规则排除在外。currentOffset是一个int值,是一个固定大小的原始值,也可以排除。下面看一下,time.Time的实现:

type Time struct { sec int64 nsec int32 loc *Location // pointer to the time zone structure } 

time.Time结构内部包含一个loc的指针。在retryItem内部使用它导致了在每次变量通过堆区域时候,GC都会去标记struct上的指针。

我们发现这是在不可预期情况下级联效应的典型情况。通常情况下操作失败是很少见的。只有小量的内存去存这个retries的变量。当失败操作激增,retry队列会每秒增加到上千个,这会大大增加垃圾回收器的工作量。

对于这种特殊使用场景,time.Time的time信息其实是不必要的。这些时间戳存在内存中,永远不会被序列化。可以重构这些数据结构以完全避免time类型出现。

type retryItem struct { id ksuid.KSUID nsec uint32 sec int64 } func (item *retryItem) time() time.Time { return time.Unix(item.sec, int64(item.nsec)) } func makeRetryItem(id ksuid.KSUID, time time.Time) retryItem { return retryItem{ id: id, nsec: uint32(time.Nanosecond()), sec: time.Unix(), } 

现在retryItem不包含任何指针。这样极大的减少了垃圾回收器的工作负载,编译器知道retryItem的整个足迹。

请给我传切片(Slice)

slice使用很容易会产生低效分配代码。除非编译器知道slice的大小,否则slice(和maps)的底层数组会分配到堆上。我们来看一下一些方法,让slice在栈上分配而不是在堆上。

Centrifuge集中使用了Mysql。整个程序的效率严重依赖了Mysql driver的效率。在使用pprof去分析了分配行为之后,我们发现Go MySQL driver代码序列化time.Time值的代价十分昂贵。

分析器显示大部分堆分配都在序列化time.Time的代码中。

 

高性能go服务之高效内存分配 go 第1张

 

相关代码在调用time.TimeFormat这里,它返回了一个string。等会儿,我们不是在说slices么?好吧,根据Go官方文档,一个string其实就是个只读的bytes类型slices,加上一点额外的语言层面的支持。大多数分配规则都适用!

分析数据告诉我们大量分配,即12.38%都产生在运行的这个Format方法里。这个Format做了些什么?

 

高性能go服务之高效内存分配 go 第2张

 

事实证明,有一种更加有效的方式来做同样的事情。虽然Format()方法方便容易,但是我们使用AppendFormat()在分配器上会更轻松。观察源码库,我们注意到所有内部的使用都是AppendFormat()而非Format(),这是一个重要提示,AppendFormat()的性能更高。

 

高性能go服务之高效内存分配 go 第3张

 

实际上,Format方法仅仅是包装了一下AppendFormat方法:

func (t Time) Format(layout string) string {
          const bufSize = 64
          var b []byte
          max := len(layout) + 10
          if max < bufSize { var buf [bufSize]byte b = buf[:0] } else { b = make([]byte, 0, max) } b = t.AppendFormat(b, layout) return string(b) } 

更重要的是,AppendFormat()给程序员提供更多分配控制。传递slice而不是像Format()自己在内部分配。相比Format,直接使用AppendFormat()可以使用固定大小的slice分配,因此内存分配会在栈空间上面。

可以看一下我们给Go MySQL driver提的这个PR

 

高性能go服务之高效内存分配 go 第4张

 

首先注意到var a [64]byte是一个大小固定的数组。编译期间我们知道它的大小,以及它的作用域仅在这个方法里,所以我们知道它会被分配在栈空间里。

但是这个类型不能传给AppendFormat(),该方法只接受[]byte类型。使用a[:0]的表示法将固定大小的数组转换为由此数组所支持的b表示的切片类型。这样可以通过编译器检查,并且会在栈上面分配内存。

更关键的是,AppendFormat(),这个方法本身通过编译器栈分配检查。而之前版本Format(),编译器不能确定需要分配的内存大小,所以不满足栈上分配规则。

这个小的改动大大减少了这部分代码的堆上分配!类似于我们在MySQL驱动里使用的“附加模式”。在这个PR里,KSUID类型使用了Append()方法。在热路径代码中,KSUID使用Append()模式处理大小固定的buffer而不是String()方法,节省了类似的大量动态堆分配。 另外值得注意的是,strconv包使用了相同的append模式,用于将包含数字的字符串转换为数字类型。

接口类型

众所周知,接口类型上进行方法调用比struct类型上进行方法调用要昂贵的多。接口类型的方法调用通过动态调度执行。这严重限制了编译器确定代码在运行时执行方式的能力。到目前为止,我们已经在很大程度上讨论了类型固定的代码,以便编译器能够在编译时最好地理解它的行为。 接口类型抛弃了所有这些规则!

不幸的是接口类型在抽象层面非常有用 --- 它可以让我们写出更加灵活的代码。程序里常用的热路径代码的相关实例就是标准库提供的hash包。hash包定义了一系列常规接口并提供了几个具体实现。我们看一个例子。

package main

import (
        "fmt" "hash/fnv" ) func hashIt(in string) uint64 { h := fnv.New64a() h.Write([]byte(in)) out := h.Sum64() return out } func main() { s := "hello" fmt.Printf("The FNV64a hash of '%v' is '%v'\n", s, hashIt(s)) } 

构建检查逃逸分析结果:

./foo1.go:9:17: inlining call to fnv.New64a
./foo1.go:10:16: ([]byte)(in) escapes to heap ./foo1.go:9:17: hash.Hash64(&fnv.s·2) escapes to heap ./foo1.go:9:17: &fnv.s·2 escapes to heap ./foo1.go:9:17: moved to heap: fnv.s·2 ./foo1.go:8:24: hashIt in does not escape ./foo1.go:17:13: s escapes to heap ./foo1.go:17:59: hashIt(s) escapes to heap ./foo1.go:17:12: main ... argument does not escape 

也就是说,hash对象,输入字符串,以及代表输入的[]byte全都会逃逸到堆上。我们肉眼看上去显然不会逃逸,但是接口类型限制了编译器。不通过hash包的接口就没有办法安全地使用具体的实现。 那么效率相关的开发人员应该做些什么呢?

我们在构建Centrifuge的时候遇到了这个问题,Centrifuge在热代码路径对小字符串进行非加密hash。因此我们建立了fasthash库。构建它很直接,困难工作依旧在标准库里做。fasthash只是在没有使用堆分配的情况下重新打包了标准库。

直接来看一下fasthash版本的代码

package main

import (
        "fmt" "github.com/segmentio/fasthash/fnv1a" ) func hashIt(in string) uint64 { out := fnv1a.HashString64(in) return out } func main() { s := "hello" fmt.Printf("The FNV64a hash of '%v' is '%v'\n", s, hashIt(s)) } 

看一下逃逸分析输出

./foo2.go:9:24: hashIt in does not escape ./foo2.go:16:13: s escapes to heap ./foo2.go:16:59: hashIt(s) escapes to heap ./foo2.go:16:12: main ... argument does not escape 

唯一产生的逃逸就是因为fmt.Printf()方法的动态特性。尽管通常我们更喜欢是用标准库,但是在一些情况下需要进行权衡是否要提高分配效率。

一个小窍门

我们最后这个事情,不够实际但是很有趣。它有助我们理解编译器的逃逸分析机制。 在查看所涵盖优化的标准库时,我们遇到了一段相当奇怪的代码。

// noescape hides a pointer from escape analysis.  noescape is
// the identity function but escape analysis doesn't think the // output depends on the input. noescape is inlined and currently // compiles down to zero instructions. // USE CAREFULLY! //go:nosplit func noescape(p unsafe.Pointer) unsafe.Pointer { x := uintptr(p) return unsafe.Pointer(x ^ 0) } 

这个方法会让传递的指针逃过编译器的逃逸分析检查。那么这意味着什么呢?我们来设置个实验看一下。

package main

import (
        "unsafe" ) type Foo struct { S *string } func (f *Foo) String() string { return *f.S } type FooTrick struct { S unsafe.Pointer } func (f *FooTrick) String() string { return *(*string)(f.S) } func NewFoo(s string) Foo { return Foo{S: &s} } func NewFooTrick(s string) FooTrick { return FooTrick{S: noescape(unsafe.Pointer(&s))} } func noescape(p unsafe.Pointer) unsafe.Pointer { x := uintptr(p) return unsafe.Pointer(x ^ 0) } func main() { s := "hello" f1 := NewFoo(s) f2 := NewFooTrick(s) s1 := f1.String() s2 := f2.String() } 

这个代码包含两个相同任务的实现:它们包含一个字符串,并使用String()方法返回所持有的字符串。但是,编译器的逃逸分析说明FooTrick版本根本没有逃逸。

./foo3.go:24:16: &s escapes to heap
./foo3.go:23:23: moved to heap: s
./foo3.go:27:28: NewFooTrick s does not escape
./foo3.go:28:45: NewFooTrick &s does not escape
./foo3.go:31:33: noescape p does not escape
./foo3.go:38:14: main &s does not escape
./foo3.go:39:19: main &s does not escape
./foo3.go:40:17: main f1 does not escape
./foo3.go:41:17: main f2 does not escape

这两行是最相关的

./foo3.go:24:16: &s escapes to heap
./foo3.go:23:23: moved to heap: s

这是编译器认为NewFoo()``方法把拿了一个string类型的引用并把它存到了结构体里,导致了逃逸。但是NewFooTrick()方法并没有这样的输出。如果去掉noescape(),逃逸分析会把FooTrick结构体引用的数据移动到堆上。这里发生了什么?

func noescape(p unsafe.Pointer) unsafe.Pointer {
    x := uintptr(p)
    return unsafe.Pointer(x ^ 0) } 

noescape()方法掩盖了输入参数和返回值直接的依赖关系。编译器不认为p会通过x逃逸,因为uintptr()会产生一个对编译器不透明的引用。内置的uintptr类型的名称会让人相信它是一个真正的指针类型,但是从编译器的视角来看,它只是一个恰好大到足以存储指针的整数。最后一行代码构造并返回了一个看似任意整数的unsafe.Pointer值。

一定要清楚,我们并不推荐使用这种技术。这也是为什么它引用的包叫做unsafe,并且注释里写着USE CAREFULLY!

总结

我们来总结一下关键点:

  1. 不要过早优化!使用数据来驱动优化工作
  2. 栈分配廉价,堆分配昂贵
  3. 了解逃逸分析的规则能够让我们写出更高效的代码
  4. 使用指针几乎不会在栈上分配
  5. 性能关键的代码段中寻找提供分配控制的API
  6. 在热代码路径里谨慎地使用接口类型
扫码关注我们
微信号:SRE实战
拒绝背锅 运筹帷幄