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

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

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

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

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

使用工具

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

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

逃逸分析

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

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

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

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

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

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的代码中。

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

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

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

实际上,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

首先注意到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. 在热代码路径里谨慎地使用接口类型
原文连接:segment.com/blog/alloca…
相关文章
相关标签/搜索