探索 Go 中接口的性能

问题

在 Go 中使用接口(interface{})好像有性能问题,可是真的如此吗,或者咱们有哪些能够提高的空间,来看一下 golang 的一个 issue。例子中跑了三个 benchmark,一个是接口调用,一个是直接调用,后面我又加了一个接口断言后调用。html

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 += 1 {
        s.Append(Strings{""})
    }
}

func BenchmarkConcrete(b *testing.B) {
    s := Strings{} // only difference is that I'm not casting it to the generic interface
    for i := 0 ; i < b.N ; i += 1 {
        s.Append(Strings{""})
    }
}

func BenchmarkInterfaceTypeAssert(b *testing.B) {
    s := D(Strings{})
    for i := 0 ; i < b.N ; i += 1 {
        s.(Strings).Append(Strings{""})
    }
}
复制代码

我用的版本是 go version 1.13,执行结果以下,git

执行了多遍结果没啥大的误差,能够看到直接使用接口调用确实效率比直接调用低了非 常多。可是,当咱们将类型断言以后,能够发现这个效率基本没有差异的。这是为何呢?答案是内联和内存逃逸,注意红框内的内存分配。github

内联 inline

什么是内联

内联是一个基本的编译器优化,它用被调用函数的主体替换函数调用。以消除调用开销,但更重要的是启用了其余编译器优化。这是在编译过程当中自动执行的一类基本优化之一。它对于咱们程序性能的提高主要有两方面golang

  1. 消除了函数调用自己的开销
  2. 容许编译器更有效地应用其余优化策略(例如常量折叠,公共子表达式消除,循环不变代码移动和更好的寄存器分配)

能够经过一个例子直观看一下内联的做用shell

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
}
复制代码

执行一下express

go test -bench=. -benchmem -run=none
复制代码

能够看到结果bash

而后咱们容许 max 函数内联,也就是把 //go:noinline 这行代码删除,再执行一遍。能够看到less

对比使用内联的先后,咱们能够看到性能有极大的提高,从 2.31 ns/op -> 0.519 ns/op函数

内联作了什么

首先,减小了相关函数的调用,将 max 的内容嵌入调用方减小了处理器执行指令的数量,消除了调用分支。oop

因为 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
}
复制代码

替换成上述的代码,在执行一下能够看到性能是差很少的

上面讨论的这种状况是叶子内联,将调用栈底部的函数内联到直接调用方的行为。内联是一个递归的过程,一旦函数被内联到其调用方,编译器就能够将结果代码嵌入至调用方,以此类推。

内联的限制

并非任何函数都是能够内联的,从 golang 的 wiki 能够看到下面这句话

Function Inlining

Only short and simple functions are inlined. To be inlined a function must contain less than ~40 expressions and does not contain complex things like loops, labels, closures, panic's, recover's, select's, switch'es, etc.

  • gc: 1.0+
  • gccgo: -O1 and above.

也就是说,仅能内联简短和简单的函数。 要内联,函数必须包含少于〜40个表达式,而且不包含复杂的语句,例如loop, label, closure, panic, recover, select, switch 等。

固然这种是有提示的,好比 for 语句,能够从提示里看到不支持内联。

堆栈中间内联 mid-stack

Go 1.8 开始,编译器默认不内联堆栈中间(mid-stack)函数(即调用了其余不可内联的函数)。堆栈中间内联(mid-stack)由 David Lazar 在 GO1.9 中引入 proposal,通过压测代表这种栈中内联能够将性能提升 9%,带来的反作用是编译的二进制文件大小会增长 15%。继续看一个例子

package main

import (
    "fmt"
    "strconv"
)

type Rectangle struct {}

//go:noinline
func (r *Rectangle) Height() int {
    h, _ := strconv.ParseInt("7", 10, 0)
    return int(h)
}

func (r *Rectangle) Width() int {
    return 6
}

func (r *Rectangle) Area() int { return r.Height() * r.Width() }

func main() {
    var r Rectangle
    fmt.Println(r.Area())
}
复制代码

在这个例子中,r.Area() 调用了r.Width()r.Height(),前者能够内联,后者因为添加了 //go:noinline 不能内联。咱们执行下面的命令来看一下内联的状况。

go build -gcflags='-m=2' square.go  
复制代码

在输出的第 三、4 行能够看到,widthArea 函数都是能够被内联的,而且红框内是内联后的语句。

在第 6 行输出了如下内容,说明是不符合内联的条件的,有一个 budget 限制,这一块能够参考Go语言inline内联的策略与限制

./square.go:22:6: cannot inline main: function too complex: cost 150 exceeds budget 80
复制代码

由于与调用 r.Area() 的开销相比,r.Area() 执行的乘法是比较简单的,因此内联 r.Area() 的单个表达式,即便它调用的 r.Height() 不符合内联条件。

快速路径内联

因为 mid-stack 的优化,致使能够内联其余调用了不可内联的函数,快速路径内联就采用的这个思想,也就是说将复杂函数的复杂部分拆分称分支函数,这样快速路径就能够被内联了。例子来源于golang code-review,做者使用快速路径内联的手段将 RUnlock 可以被内联,从而实现了性能提高。

因为左侧的老代码包含了不少条件,致使 RUnlock 函数不能被内联。这里做者把条件复杂的逻辑拆分出去一个函数,称为慢路径函数。这里咱们能够拿一个例子试试,这是个没有意义的例子,只为证实内联优化的存在。

package main

import (
    "sync"
)

var rw sync.RWMutex

func test(num int) int {
    rw.RLock()
    num += 1
    rw.RUnlock()
    return num
}
复制代码

使用 go 1.9 版本的输出,

使用 go 1.13 版本的输出,

从上面的输出,能够看到快路径优化后,内联生效了,据做者的压测代表,性能节省了 9%,固然咱们能够本身压一下试试,通过屡次测试,能有 18 ns/op -> 15 ns/op 的提高。

// go version 1.9
BenchmarkRlock-4        100000000               18.9 ns/op             0 B/op          0 allocs/op

// go version 1.13
BenchmarkRlock-4        76204650                15.3 ns/op             0 B/op          0 allocs/op
复制代码

逃逸分析 escape-analysis

什么是内存逃逸

首先咱们知道,内存分为堆内存(heap)和栈内存(stack)。对于堆内存来讲,是须要清理的。好比 c 语言中的 malloc 就是用来分配堆内存的,申请了堆内存以后必定要手动释放,否则就形成内存泄露。可是 Go 语言是有 GC 的,因此不须要手动释放。因此对于这一点而言,使用堆的成本比栈高,会给 GC 带来压力,由于堆上没有被指针引用的值都须要删除。随着检查和删除的值越多,GC 每次执行的工做就越多。

若是一个函数返回对一个变量的引用,那么它就会发生逃逸。由于在别的地方会引用这个变量,若是放在栈里,函数退出后,内存就被回收了,因此须要逃逸到堆上。

简而言之,逃逸分析决定了内存被分配到栈上仍是堆上

如何监测内存逃逸

能够经过查看编译器的报告来了解是否发生了内存逃逸。使用 go build -gcflags='-m=2' 便可。总共有 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) }
复制代码

查看一下编译器报告,

  1. 直接使用接口方法调用,不能内联。咱们能够看第一个红框内,经过接口调用 I.M1(42) 不能内联,而断言和具体类型调用能够继续内联。
  2. 直接使用接口方法调用,会发生内存逃逸。而具体类型调用或者断言后调用,不会发生内存逃逸。这也验证了文章开头部分的压测,接口调用发生了内存分配,这些内存分配便是逃逸到堆上的内存。

回顾

咱们在看一下文章开始的例子,

package main

type D interface {
    Append(D)
}

type Strings []string

func (s Strings) Append(d D) {}

func concreteTest() {
    s := Strings{} // only difference is that I'm not casting it to the generic interface
    for i := 0 ; i < 10 ; i += 1 {
        s.Append(Strings{""})
    }
}

func interfaceTest() {
    s := D(Strings{})
    for i := 0 ; i < 10 ; i += 1 {
        s.Append(Strings{""})
    }
}

func assertTest() {
    s := D(Strings{})
    for i := 0 ; i < 10 ; i += 1 {
        s.(Strings).Append(Strings{""})
    }
}
复制代码

执行

go build -gcflags='-m=2' iterface.go
复制代码

能够看到输出,

也就是接口直接调用,没有内联,而且发生了内存逃逸。当咱们经过断言后再调用方法,发生了内联,而且没有内存逃逸。因此,接口直接调用的性能是有问题的。

总结

经过以上分析,咱们在使用接口的时候必定要注意,最好将接口断言出来再使用,这样会提升性能。同时平常开发中,能够多加分析,避免内存逃逸带来的内存消耗和 GC 的压力,提升性能。

参考

go issue 20116

go 性能调优

Go 编译优化 wiki

inlining opt by dave

mid-stack inline proposal

golang mid-stack issue

golang 内存逃逸

golang: Escape analysis and interfaces Go语言inline内联的策略与限制

相关文章
相关标签/搜索