GO性能优化指北-高效内存分配

绝大多数时候我们不需要关注内存管理, go运行时会自行处理, 但是对于热点路径, 我们必须确保高效地内存分配来榨取每一点性能

就内存分配而言, 有两件事情可以帮助我们提升性能: 1. 减少分配, 如将fmt.Sprintf改为fmt.Fprintf来避免创建新的字符串或者尽量使用[]byte而不是string来重用 2. 尽量避免在堆上的分配, 分配在堆的内存会增加GC花费从而降低性能, 本文将试图对此继续分析

需要提醒的是, 一切的性能优化都必须以性能分析为起点, 绝对不能进行未测量的优化.

堆分配与栈分配花销对比

通常而言, 堆分配的花销是远比栈分配更昂贵的. 因此高效的内存分配一定要尽量降低不必要的堆分配1. 认真分析的话, 栈分配和堆分配的花销

栈分配的花销比较简单, 仅仅需要两条CPU指令: 一个是推入栈中(来分配), 一个是从栈中释放.

堆分配的花销则主要在于分配和GC: malloc需要寻找一个足够大的闲置空间来存储, 而GC也需要扫描堆来发现可回收的对象. 这两种操作的时间成本显然高于栈分配的花销

什么情况下会触发堆分配?

在很多其他语言里面, 一个值会被分配在栈还是堆是清晰的:对于JAVA来说, 所有的对象(object)都会被分配至堆, 所有的基础类型都会分配在栈. 对于C/C++来说, 使用new创建的值就会被分配在堆, 否则默认是栈. 然而在GO中, 事情会变得更加复杂点.

比方说, 对于下面的代码段, 你是否能确定它分配在栈还是堆?

type user struct {
	name string
	age int
}
...
u:=user{
		name:"li",
		age:15,
}

答案是, 你不能, 你需要更多的信息判断. 比方说如果是下面的完整代码, 那么u会被分配在栈

func test()user{
   u:=user{
      name:"li",
      age:15,
   }
   return u
}

而如果是下面的代码, u就会被分配至堆

func test()*user{
   u:=user{
      name:"li",
      age:15,
   }
   return &u
}

在GO中, 一个值是否被分配至堆还是栈, 取决于GO编译器对其做的逃逸分析结果. 如果一个变量的生命周期和大小可以在编译时确定, 那么该变量就会栈分配, 否则就会触发堆分配.

这样做的目的有两点:

  1. 降低心智负担. 如果需要自行决定是否分配至堆, 那么往往我们还需要考虑怎么释放堆资源. (也就是没有GC, 类似于C++和C)
  2. 降低GC负担, 因为逃逸分析可以避免将所有对象都分配至堆中.

尽管我们无法实际控制分配至堆还是栈, 但是我们仍然有必要了解, 什么情况下值会被分配至堆, 以及对此我们有什么绕过去的办法.

下面列举的是常见的堆分配的例子, 有一些可能意料之中, 而有一些也许会出乎意料:他们也是性能优化的目标

跨越栈帧(stack frame)的值传递

最为常见也为大家所理解的情况, 莫过于下面的类似例子

func New()*user{
   u:=user{
      name:"li",
      age:15,
   }
   return &u
}

向channel发送一个指针或者包含指针的对象

编译时无法获知哪个goroutine会获取数据, 因此编译器无法确定该数据什么时候不再被引用

c:=make(chan *string,1)
s:="hello"
c<-&s

//输出结果
moved to heap: s

在切片中存储指针或者包含指针的对象

一个常见的例子是[]*string, 在这种情况下, 即便切片背后的数组是分配在栈, 数组中的元素(*string)会分配在堆

var c []*string
s:="hello"
c=append(c,&s)

//输出结果
moved to heap: s

初始化切片的容量编译时未知或者append操作会让切片容量需要扩展

如果用make初始化切片的时候, 容量值编译时未知(比方说需要调用函数), 那么切片会被分配至堆

_=make([]int,len(os.Args))
//输出结果
make([]int, len(os.Args)) escapes to heap

另一种情况是如果append时切片内部容量不足, 它会分配至堆中

使用接口方法

接口方法是称之为动态分发(dynamic dispatch), 在这种情况下, 其实际类型会在runtime确定, 而不是编译时, 因此无法分配至栈

比方说有类型为接口io.Reader的变量r, 当调用r.Read(p)时候, r背后的实际值和p背后的数组都会被分配至堆中.

type A struct{}
func (a A) Read([]byte)(int,error){
   return 0,nil
}
func main() {
   s:=A{}
   var r io.Reader=s
   var p[]byte
   r.Read(p)
}
//s escapes to heap

发现是否逃逸

尽管上面的情况已经覆盖了大部分场景, 我们必须承认, 程序的交互有时候远比想象中复杂. 而且逃逸分析一直在进步, 也许之前我们提到的某些例子会在未来的版本 不再使用.

不过庆幸的是, GO提供了工具帮助我们理解编译器做了什么决定, 以及为什么做这个决定.

具体到堆分配, 我们可以使用go build --gcflags "-m"来返回编译优化信息, 我们可以通过更多的-m来获得更细致的解释, 最多可以设置"-m -m -m -m". 但是其结果通常会过于复杂, 所以一般"-m -m"已经足够

如果是自己在做试验, 一个建议的选项是使用--gcflags "-l -m -m"来禁止内联, 因为内联会干扰判断(比方说内联后可能就不需要分配到堆)

值语义vs指针语义

我们很容易发现, 堆分配往往与指针相关联. 原因其实比较简单:如果没有指针, 那么就不会出现共享的问题, 就无需分配到堆了. 而指针的泛滥与我们频繁使用指针语义不无关系.

尽管绝大多数情况下, 指针语义都是一个合理的选择2, 我们需要指出这并不是没有代价的.

在这里, 我们首先解释什么是值类型语义, 指针类型语义. 然后我们重点分析什么情况下, 值类型语义可能会比指针类型语义更好(注意, 这需要详实的性能分析)

必须提示的是, 无论选择值类型语义还是指针类型语义. 必须从头到尾的保持语义一致.

什么是值类型语义(value sematic)?

值语义意思是说对于该数据类型, 数据的分享依赖于复制而不是传递指针. 基础数据类型如int, float, string等都属于值语义, 引用类型3虽然内部包含指针, 我们也应当将其视为值类型看待.

一个典型的值类型如下面代码所示

type User struct {
   name string
}
//值类型最明显的标志就是New函数返回的是对象而不是指针
func New() User {
   return User{}
}
//值类型的第二个特征就是方法的接收者是类型而不是类型指针
func (u User) Name() string {
   return u.name
}

//值类型的第三个特征就是对于对象的"修改"往往通过返回来实现(而不是直接修改内部状态)
//使用的方法应该是
//  u=u.UpdateName("Li")
func (u User) UpdateName(name string) User {
   u.name=name
   return u
}

什么是指针语义(pointer sematic)

指针类型语义常见于我们自己写的结构体中, 比方说Lock,RWLock都是指针类型语义的代表.

一个典型的指针类型语义的代码如下面代码所示

type User struct {
	name string
}
//指针类型语义的最重要特征就是New函数返回的是指针
func New() *User {
	return &User{}
}
//指针类型语义的接收者是指针
func (u *User) Name() string {
	return u.name
}
//指针类型语义通过直接修改内部状态来修改信息
func (u *User) UpdateName(name string)  {
	u.name=name
}


值vs指针-性能优化

正如之前提到, 大部分情况下, 指针语义可能会更适合. 但是如果具体到性能优化, 我们就必须指出, 指针类型语义往往会成为性能提升的绊脚石, 以下是几个原因:

  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

Golang escape analysis

Language Mechanics On Stacks And Pointers

脚注


  1. 最极端的例子就是零垃圾(zero garbage), 这意味着完全避免了使用堆 ↩︎

  2. 具体可以看这里的分析 ↩︎

  3. 切片, map, channel, 函数, 指针 ↩︎

你可能感兴趣的:(go性能优化指北,go)