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背后的数组都会被分配至堆中.

指针迷思

很多时候我们都会下意识的认为 “复制是昂贵的,让我们使用指针吧”.

然而很多情况下, 使用指针会导致分配至堆, 而且即便是不考虑分配至堆的花销, 复制也有可能比指针更廉价, 以下是几种原因:

  1. 当使用指针方法的时候, 编译器会自行插入非空检查.

    其目的在于如果指针为空, 那么就panic(而不是内存污染memory corruption). 而当传入的是值的时候, 不可能为空

  2. 指针无法很好地利用局部性原理

    现代计算机往往会基于局部性原理做优化, 如果使用复制, 那么函数用到的所有的值都在栈中, 从而大大提高了所需值在CPU cache中的机会以及减少在prefecting中未命中风险

  3. 复制一个足够小(在一个缓存行)的对象成本等同于复制一个指针

    CPU是基于固定大小的缓存行在缓存层和主存之间移动数据. 在x86下缓存行大小为64字节, 这意味着如果对象足够小, 那么复制对象的成本不会高于指针

使用指针的最主要原因应该是表达属主语义(ownership semantics)以及可变性. 实践上, 为了避免复制而使用指针应该慎重, 过早的优化是万恶之源. 一个好的习惯是优先使用复制, 当有必要的时候才考虑使用指针.

除此之外, 使用值而不是指针的好处还包括

  • 减轻GC负担

    GC的时候会自动跳过确定不包含指针的领域. 比方说[]byte 以及确定不包含指针的结构体切片

  • 减少缓存颠簸(cache thrashing)

一些技巧

值得注意的是, 下面的技巧除了重用buffer比较通用, 其他的都需要较高的代价. 这意味着类似的优化一定是需要严格的性能剖析, 找到热点路径然后再进行这种级别的优化

  1. 当发现GC缓慢的时候, 可以先分析判断, 然后通过谨慎的替换指针或者包含指针结构体的字段来提高性能(因为GC可以跳过)

  2. 避免返回string等函数, 永远优先考虑使用可以自行提供内存的函数(如AppendFormat而不是Format)

  3. 接口虽然提供了抽象, 但是某种程度上也牺牲了性能. 对于热点代码, 可以恰当的牺牲通用性, 比方说针对字符串等常用类型做优化.

    例如当我们试图使用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.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

脚注


  1. go build -gcflags '-m'可以返回编译优化信息, 可以通过更多的-m(如-gcflags '-m -m')来提供更细致的解释 ↩︎

发布了31 篇原创文章 · 获赞 32 · 访问量 722

猜你喜欢

转载自blog.csdn.net/a348752377/article/details/105604592