go服务的内存优化总结

go的内存分配方式

  1. 一个全局对空间用来动态分配内存;堆分配内存要求:运行时进行动态分配,垃圾回收期扫描堆空间寻找不再被使用的对象回收内存。
  2. 每个goroutine自身的栈空间,最小栈大小位2KB~8KB左右;栈分配内存要求:编译期确定变量的生命周期和内存足迹。

注:内存足迹:代表和一个变量相关的所有内存块。比如一个 struct 中含有成员 *int, 那么这个 *int 所指向的内存块属于该 struct 的足迹。

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:5:6: can inline main
./main.go:7:13: inlining call to fmt.Println
./main.go:7:13: x escapes to heap
./main.go:7:13: io.Writer(os.Stdout) escapes to heap
./main.go:7:13: main []interface {} literal does not escape
<autogenerated>:1: os.(*File).close .this does not escape

看到x escapes to heap,表示它会在运行时在堆空间上动态分配。直觉上x并没有逃出main()函数外。为什么x会逃逸?
多传几个-m参数给编译,打印更详细的内容。

go build -gcflags '-m -m' main.go
# command-line-arguments
./main.go:5:6: can inline main as: func() { x := 100; fmt.Println(x) }
./main.go:7:13: inlining call to fmt.Println func(...interface {}) (int, error) { return fmt.Fprintln(io.Writer(os.Stdout), fmt.a...) }
./main.go:7:13: x escapes to heap
./main.go:7:13: 	from ~arg0 (assign-pair) at ./main.go:7:13
./main.go:7:13: io.Writer(os.Stdout) escapes to heap
./main.go:7:13: 	from io.Writer(os.Stdout) (passed to call[argument escapes]) at ./main.go:7:13
./main.go:7:13: main []interface {} literal does not escape
<autogenerated>:1: os.(*File).close .this does not escape

上面显示变量x之所以逃逸,是因为它被传入了一个逃逸函数内!!!

什么能引起逃逸

  • 发送指针或带有指针的值到 channel 中。在编译时,是没有办法知道哪个 goroutine 会在 channel 上接收数据。所以编译器没法知道变量什么时候才会被释放。
  • 在一个切片上存储指针或带指针的值。一个典型的例子就是 []*string。这会导致切片的内容逃逸。尽管其后面的数组可能是在栈上分配的,但其引用的值一定是在堆上。
  • slice 的背后数组被重新分配了,因为 append 时可能会超出其容量(cap)。slice 初始化的地方在编译时是可以知道的,它最开始会在栈上分配。如果切片背后的存储要基于运行时的数据进行扩充,就会在堆上分配。
  • 在interface类型上调用方法。在interface类型上调用方法都是动态调度的----方法的真正实现只能再运行时知道。例如io.Reader类型的变量r,调用r.Read(b)使得r的切片b的背后存储都逃逸调,所以会在对上分配。

总结

以上四点是Go程序中最常见的导致堆分配的原因。如何定位线上系统的内存性能问题?

定位内存性能问题

关于指针

经验

  • 指针指向的数据都是在堆上分配的。因此,在程序中减少指针的运用可以减少堆分配。这不是绝对的,但最常见。
  • 值的拷贝是昂贵的,所以用一个指针来代替。但在很多情况下,直接的值拷贝要比使用指针廉价。

为什么值拷贝要比指针廉价

  • 编译器会在接触指针时做检查。目的是在指针是nil的情况下直接panic()以避免内存泄漏。这就必须在运行时执行更多的代码。如果数据时值按值传递的,那就不需要做这些了,它不可能是nil。
  • 指针通常有糟糕的拒不引用。一个函数内部的所有值都会在栈空间上分配。局部引用是编写搞笑代码的重要环节。它会使得数据在CPU Cache(cpu的一级二级缓存)中热度更高,进而减少指令预取时Cache不命中的几率。
  • 在Cache层拷贝一堆对象,可粗略地认为和拷贝一个指针效率是一样的。CPU在各Cache层和主内存中以固定大小的cache进行内存移动。x86机器上是64字节。而且Go使用了Duff’s device技术使得常规内存操作变得更高效。

减少使用指针的好处

  • 指针应该主要被用来做映射数据的所有权和可变性的。实际项目中用指针来避免拷贝的方式应该尽量少用。
  • 不要掉进过早优化的陷阱。养成一个按值传递的习惯,只在需要的时候用指针传递。另个一个好处就是可以减少nil带来的安全问题。
  • 如果可以证明变量里面没有指针,垃圾回收期会直接越过这块内存。例如,一块座位[]byte背后存储的堆上内存,是不需要进行扫描的。对于那些包含指针的数组和struct数据类型也是一样的。

注:垃圾回收器回收一个变量时,要检查该类型里寿司否有指针。如果有,要检查指针所指向的内存是否可被回收,进而才能决定这个变量能否被会回收。如此递归下去。如果被回收的变量里没有指针,就不需要进行递归扫描了,直接回收掉就行。

减少指针的使用不仅可以降低垃圾回收的工作量,也会对cache生成更加友好代码避免cache抖动

总结

  1. 不要过早优化,用数据驱动优化工作
  2. 占空间分配是廉价的,堆空间分配是昂贵的
  3. 了解逃逸机制可以让我们写出更高效的代码
  4. 指针的使用会导致栈分配更不可行
  5. 找到在第小代码块中提供分配控制的API
  6. 在调用频繁的地方慎用interface

原文链接

高性能Go服务的内存优化(译)

猜你喜欢

转载自blog.csdn.net/renwotao2009/article/details/89293822