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

 

 可以看到接口方法調用不能內聯,而斷言和具體類型調用可以繼續內聯,直接接口方法調用,會發生內存逃逸。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM