欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页

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

程序员文章站 2022-07-02 11:02:11
...

绝大多数时候我们不需要关注内存管理, 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, 函数, 指针 ↩︎