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编译器对其做的逃逸分析结果. 如果一个变量的生命周期和大小可以在编译时确定, 那么该变量就会栈分配, 否则就会触发堆分配.
这样做的目的有两点:
- 降低心智负担. 如果需要自行决定是否分配至堆, 那么往往我们还需要考虑怎么释放堆资源. (也就是没有GC, 类似于C++和C)
- 降低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指针-性能优化
正如之前提到, 大部分情况下, 指针语义可能会更适合. 但是如果具体到性能优化, 我们就必须指出, 指针类型语义往往会成为性能提升的绊脚石, 以下是几个原因:
-
当使用指针方法的时候, 编译器会自行插入非空检查.
其目的在于如果指针为空, 那么就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
Language Mechanics On Stacks And Pointers
脚注
上一篇: 瞧你色样