探索 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內聯的策略與限制

相關文章
相關標籤/搜索