GO性能优化指北-高效内存分配
如何实现高效内存分配?
绝大多数时候我们不需要关注内存管理, go运行时会自行处理, 但是对于热点路径, 我们必须确保高效地内存分配来榨取每一点性能
需要提醒的是, 一切的性能优化都必须以性能分析为起点, 绝对不能进行未测量的优化.
堆分配与栈分配花销对比
通常而言, 堆分配的花销是远比栈分配更昂贵的. 我们可以具体描述如下:
栈分配的花销比较简单, 仅仅需要两条CPU指令: 一个是推入栈中(来分配), 一个是从栈中释放.
堆分配的花销则主要在于分配和GC: malloc需要寻找一个足够大的闲置空间来存储, 而GC也需要扫描堆来发现可回收的对象. 这两种操作的时间成本显然高于栈分配的花销
什么情况下会触发栈/堆分配?
一个简单的答案就是: 如果一个变量的生命周期和内存印迹(memory footprint )可以在编译时确定, 那么该变量就会栈分配, 否则就会触发堆分配
编译器会利用逃逸分析来决定使用栈还是堆. 简单来说, 编译器会跟踪变量的作用域(scope)来确定是否可以在编译阶段确定该变量的生命周期, 如果是, 那么就会分配到栈中.
常见的导致堆分配的例子
go并没有明确的规定逃逸分析的规则, 但是通过一些实验1我们可以总结出一些常见的造成堆分配的例子. 需要提醒的是, 当我们说"导致堆分配"的时候, 指的是指针所指的对象分配到堆而不是指针本身
-
向channel发送一个指针或者包含指针的对象
编译时无法获知哪个goroutine会获取数据, 因此编译器无法确定该数据什么时候不再被引用
-
在切片中存储指针或者包含指针的对象
一个常见的例子是
[]*string
, 在这种情况下, 虽然切片后的数组分配在栈, 但是数组中的元素(*string)都分配在堆 -
如果append操作会让切片容量需要扩展
如果编译时切片起始大小已知( 比方说来自于一个固定大小数组 ), 那么切片会分配至栈. 但是如果append时切片内部容量不足, 它会分配至堆中
-
调用接口方法
接口方法是称之为动态分发(dynamic dispatch), 在这种情况下, 其实际类型会在runtime确定, 而不是编译时, 因此无法分配至栈
比方说有类型为接口
io.Reader
的变量r
, 当调用r.Read(p)
时候,r
背后的实际值和p
背后的数组都会被分配至堆中.
指针迷思
很多时候我们都会下意识的认为 “复制是昂贵的,让我们使用指针吧”.
然而很多情况下, 使用指针会导致分配至堆, 而且即便是不考虑分配至堆的花销, 复制也有可能比指针更廉价, 以下是几种原因:
-
当使用指针方法的时候, 编译器会自行插入非空检查.
其目的在于如果指针为空, 那么就panic(而不是内存污染memory corruption). 而当传入的是值的时候, 不可能为空
-
指针无法很好地利用局部性原理
现代计算机往往会基于局部性原理做优化, 如果使用复制, 那么函数用到的所有的值都在栈中, 从而大大提高了所需值在CPU cache中的机会以及减少在prefecting中未命中风险
-
复制一个足够小(在一个缓存行)的对象成本等同于复制一个指针
CPU是基于固定大小的缓存行在缓存层和主存之间移动数据. 在x86下缓存行大小为64字节, 这意味着如果对象足够小, 那么复制对象的成本不会高于指针
使用指针的最主要原因应该是表达属主语义(ownership semantics)以及可变性. 实践上, 为了避免复制而使用指针应该慎重, 过早的优化是万恶之源. 一个好的习惯是优先使用复制, 当有必要的时候才考虑使用指针.
除此之外, 使用值而不是指针的好处还包括
-
减轻GC负担
GC的时候会自动跳过确定不包含指针的领域. 比方说
[]byte
以及确定不包含指针的结构体切片 -
减少缓存颠簸(cache thrashing)
一些技巧
值得注意的是, 下面的技巧除了重用buffer比较通用, 其他的都需要较高的代价. 这意味着类似的优化一定是需要严格的性能剖析, 找到热点路径然后再进行这种级别的优化
-
当发现GC缓慢的时候, 可以先分析判断, 然后通过谨慎的替换指针或者包含指针结构体的字段来提高性能(因为GC可以跳过)
-
避免返回string等函数, 永远优先考虑使用可以自行提供内存的函数(如
AppendFormat
而不是Format
) -
接口虽然提供了抽象, 但是某种程度上也牺牲了性能. 对于热点代码, 可以恰当的牺牲通用性, 比方说针对字符串等常用类型做优化.
例如当我们试图使用hash标准库的时候, 传入字符串会带来两次内存分配: 一次将字符串转换成字节切片
[]byte
, 一次复制到接口. 而且这两次分配都会在堆上分配, 从而大大降低性能.func main(){ const ( input1 = "The tunneling gopher digs downwards, " ) first := sha256.New() first.Write([]byte(input1)) fmt.Printf("%x\n", first.Sum(nil)) } //分析结果 ./hash.go:13:21: new(sha256.digest) escapes to heap ./hash.go:13:21: hash.Hash(sha256.d·2) escapes to heap ./hash.go:14:20: ([]byte)(input1) escapes to heap ./hash.go:17:30: first.Sum(nil) escapes to heap
对此, 如果我们需要极致性能, 那么我们可以自行借用hash的逻辑, 封装针对string的函数来提高性能
参考
Allocation efficiency in high-performance Go services
脚注
-
go build -gcflags '-m'
可以返回编译优化信息, 可以通过更多的-m(如-gcflags '-m -m'
)来提供更细致的解释 ↩︎
上一篇: 有个大胸媳妇就是麻烦