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

 

 可以看到接口方法调用不能内联,而断言和具体类型调用可以继续内联,直接接口方法调用,会发生内存逃逸。

posted @ 2020-05-12 11:11  顽强的allin  阅读(1025)  评论(0编辑  收藏  举报