Go接口的性能探索
在Go中使用接口(interface{})好像有性能问题,来看一个例子:跑了三个benchmark,一个是接口调用,一个是直接使用,后面又加了一个接口断言后调用
lib_test.go
package main import "testing" type D interface { Append(D) } type Strings []string func (s Strings) Append(d D) { } func BenchmarkInterface(b *testing.B) { s := D(Strings{}) for i := 0; i < b.N; i++ { s.Append(Strings{""}) } } func BenchmarkConcrete(b *testing.B) { s := Strings{} for i := 0; i < b.N; i++ { s.Append(Strings{""}) } } func BenchmarkInterfaceTypeAssert(b *testing.B) { s := D(Strings{}) for i := 0; i < b.N; i++ { s.(Strings).Append(Strings{""}) } }
运行:go test -bench=. -benchmem -run=none
可以看到直接使用接口调用确实效率比直接调用低了很多,但是,当我们将类型断言之后,可以发现这个效率基本没有差别,这是为什么呢?答案是内联和内存逃逸!
内联inline
什么是内联,内联是一个基本的编译器优化,它用被调用函数的主体替换函数调用,以消除调用开销,但更重要的是启用了其他编译器优化,这是在编译过程中自动执行的一类基本优化之一。它对于我们程序性能提升主要有两方面:
1.消除了函数调用本身的开销
2.允许编译器更有效地应用其他优化策略(例如常量折叠,公共子表达式消除,循环不变代码移动和更好的寄存器分配)
可以通过一个例子直观看一下内联的作用:
package main import "testing" //go:noinline func max(a, b int) int { if a >b { return a } return b } var Result int func BenchmarkMax(b *testing.B) { var r int for i := 0; i < b.N; i++ { r = max(-1, i) } Result = r }
执行:go test -bench=. -benchmem -run=none
然后允许max函数内联,也就是把 //go:noinline 这行代码删除,再执行一遍:
对比使用内联的前后,我们可以看到性能有极大的提升:2.18 ns/op -> 0.500 ns/op
内联做了什么
首先,减少了相关函数的调用,将max的内容嵌入调用方减少了处理器执行指令的数量,消除了调用分支。
由于 r = max(-1, i) ,i 是从0开始的,所以 i > -1 ,那么 max 函数的 a > b 分支永远不会发生。编译器可以把这部分代码直接内联 至调用方,优化后的代码:
func BenchmarkMax(b *testing.B) { var r int for i := 0; i < b.N; i++ { if -1 > i { r = -1 } else { r = i } } Result = r }
上面讨论的这种情况是叶子内联,将调用栈底部的函数内两到直接调用方的行为。内联是一个递归的过程,一旦函数被内联到其调用方,编译器就可以将结果代码嵌入至调用方,以此类推。
内联的限制
并不是任何函数都是可以内联的,仅能内联简短和简单的函数。要内联,函数必须包含少于 40 个表达式,并且不包含复杂的语句,例如: loop, label, closure, panic, recover, select, switch 等。
堆栈中间内联 mid - stack
Go1.8开始,编译器默认不内联堆栈中间(mid - stack)函数(即调用了其他不可内联的函数)。堆栈中间内联经过压力测试可以将性能提高9%,带来的副作用是编译的二进制文件大小会增加15%。
逃逸分析
什么是内存逃逸?首先我们知道,内存分为堆内存(heap)和栈内存(stack)。对于堆内存来说,是需要清理的。堆上没有被指针引用的值都需要删除。随着检查和删除的值越多,GC每次执行的工作就越多。
如果一个函数返回对一个变量的引用,那么它就会发生逃逸。因为在别的地方会引用这个变量,如果放在栈离里,函数退出后,内存就被回收了,所以需要逃逸到堆上。
简而言之,逃逸分析决定了内存被分配到栈上还是堆上。
可以通过查看编译器的报告来了解是否发生了内存逃逸。使用 go build -gcflags=-m 即可。总共有4个级别的 -m , 但是超过2个 -m 级别的返回的信息比较多。通常使用2个 -m 级别。
接口类型的方法调用
go中的接口类型的方法调用时动态调度,因此不能够在编译阶段确定,所有类型结构转换成接口的过程会涉及到内存逃逸的情况发生。
package main type S struct { s1 int } func (s *S) M1(i int) { s.s1 = i } type I interface { M1(int) } func g() { var s1 S var s2 S var s3 S f1(&s1) f2(&s2) f3(&s3) } func f1(s I) { s.M1(42) } func f2(s *S) { s.M1(42) } func f3(s I) { s.(*S).M1(42) }
执行 go build -gcflags=-m channleDemo1.go
可以看到接口方法调用不能内联,而断言和具体类型调用可以继续内联,直接接口方法调用,会发生内存逃逸。