在 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
內聯是一個基本的編譯器優化,它用被調用函數的主體替換函數調用。以消除調用開銷,但更重要的是啓用了其餘編譯器優化。這是在編譯過程當中自動執行的一類基本優化之一。它對於咱們程序性能的提高主要有兩方面golang
能夠經過一個例子直觀看一下內聯的做用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
語句,能夠從提示裏看到不支持內聯。
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 行能夠看到,width
和 Area
函數都是能夠被內聯的,而且紅框內是內聯後的語句。
在第 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
複製代碼
首先咱們知道,內存分爲堆內存(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) }
複製代碼
查看一下編譯器報告,
I.M1(42)
不能內聯,而斷言和具體類型調用能夠繼續內聯。咱們在看一下文章開始的例子,
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 的壓力,提升性能。