手动内存管理真的很坑爹(如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
逃逸是由于它被传入一个方法参数里,这个方法参数本身逃逸了。后面能够看到更多这种状况。
规则可能看上去是随意的,通过工具的尝试,一些规律显现出来。这里列出了一些典型的致使逃逸的状况:
[]*string
。它总会致使slice中的内容逃逸。尽管切片底层的数组仍是在堆上,可是引用的数据逃逸到堆上了。io.Reader
的变量r
。对r.Read(b)
的调用将致使r
的值和byte slice b
的底层数组都逃逸,所以在堆上进行分配。以咱们的经验来说,这四种状况是Go程序中最多见的动态分配状况。对于这些状况仍是有一些解决方案的。接下来,咱们将深刻探讨如何解决生产软件中内存低效问题的一些具体示例。
经验法则是:指针指向堆上分配的数据。 所以,减小程序中指针的数量会减小堆分配的数量。 这不是公理,但咱们发现它是现实世界Go程序中的常见状况。
咱们直觉上得出的一个常见的假设是这样的:“复制值代价是昂贵的,因此我会使用指针。”然而在许多状况下,复制值比使用指针的开销要便宜的多。你可能会问这是为何。
panic()
来避免内存损坏。这部分额外代码必须在运行时去运行。若是数据按值传递,它不会是nil。指针应主要用于反映成员全部关系以及可变性。实际中,使用指针避免复制应该是不常见的。不要陷入过早优化陷阱。按值传递数据习惯是好的,只有在必要的时候才去使用指针传递数据。另外,值传递消除了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(和maps)的底层数组会分配到堆上。咱们来看一下一些方法,让slice在栈上分配而不是在堆上。
Centrifuge集中使用了Mysql。整个程序的效率严重依赖了Mysql driver的效率。在使用pprof
去分析了分配行为以后,咱们发现Go MySQL driver代码序列化time.Time
值的代价十分昂贵。
分析器显示大部分堆分配都在序列化time.Time
的代码中。
相关代码在调用time.Time
的Format
这里,它返回了一个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!
咱们来总结一下关键点: