• 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

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

    人生就是要不断折腾
  • 相关阅读:
    0005 数组(array)的静态和动态声明、最大值最小值、数组的反转、复制
    0004day04_15循环结构-循环嵌套、break和continue与标签随机数的另外一种方式 break、continue、求最大公约数、循环嵌套、求水仙花数 out标签等
    0003java.util.Scanner、输出语句、分支结构 if else 随机数 switch case选择结构和equals
    Maven配置阿里镜像
    中国大学MOOC-陈越、何钦铭-数据结构-2020春——最大子列和问题Java实现代码
    IDEA中jsp下out.println标红处理方法
    log4j:WARN No appenders could be found for logger (org.apache.ibatis.logging.LogFactory).
    IDEA下Java项目中创建xml文件
    Eclipse中web项目的导出和导入操作步骤
    EL(Expression Language)表达式语言理解
  • 原文地址:https://www.cnblogs.com/xiangxiaolin/p/12874936.html
Copyright © 2020-2023  润新知