Go1.13 defer 的性能是如何提高的?

16

go1.13 defer

最近 Go1.13 終于發布了,其中一個值得關注的特性就是 defer 在大部分的場景下性能提升了30%,但是官方并沒有具體寫是怎麼提升的,這讓大家非常的疑惑。而我因為之前寫過《深入理解 Go defer》《Go defer 會有性能損耗,盡量不要用?》 這類文章,因此我挺感興趣它是做了什麼改變才能得到這樣子的結果,所以今天和大家一起探索其中奧妙。

原文地址:Go1.13 defer 的性能是如何提高的?

一、測試

Go1.12

$ go test -bench=. -benchmem -run=none
goos: darwin
goarch: amd64
pkg: github.com/EDDYCJY/awesomeDefer
BenchmarkDoDefer-4          20000000            91.4 ns/op          48 B/op           1 allocs/op
BenchmarkDoNotDefer-4       30000000            41.6 ns/op          48 B/op           1 allocs/op
PASS
ok      github.com/EDDYCJY/awesomeDefer    3.234s

Go1.13

$ go test -bench=. -benchmem -run=none
goos: darwin
goarch: amd64
pkg: github.com/EDDYCJY/awesomeDefer
BenchmarkDoDefer-4          15986062            74.7 ns/op          48 B/op           1 allocs/op
BenchmarkDoNotDefer-4       29231842            40.3 ns/op          48 B/op           1 allocs/op
PASS
ok      github.com/EDDYCJY/awesomeDefer    3.444s

在開場,我先以不标準的測試基準驗證了先前的測試用例,确确實實在這兩個版本中,defer 的性能得到了提高,但是看上去似乎不是百分百提高 30 %。

二、看一下

之前(Go1.12)

    0x0070 00112 (main.go:6)    CALL    runtime.deferproc(SB)
    0x0075 00117 (main.go:6)    TESTL    AX, AX
    0x0077 00119 (main.go:6)    JNE    137
    0x0079 00121 (main.go:7)    XCHGL    AX, AX
    0x007a 00122 (main.go:7)    CALL    runtime.deferreturn(SB)
    0x007f 00127 (main.go:7)    MOVQ    56(SP), BP

現在(Go1.13)

    0x006e 00110 (main.go:4)    MOVQ    AX, (SP)
    0x0072 00114 (main.go:4)    CALL    runtime.deferprocStack(SB)
    0x0077 00119 (main.go:4)    TESTL    AX, AX
    0x0079 00121 (main.go:4)    JNE    139
    0x007b 00123 (main.go:7)    XCHGL    AX, AX
    0x007c 00124 (main.go:7)    CALL    runtime.deferreturn(SB)
    0x0081 00129 (main.go:7)    MOVQ    112(SP), BP

從彙編的角度來看,像是 runtime.deferproc 改成了 runtime.deferprocStack 調用,難道是做了什麼優化,我們抱着疑問繼續看下去。

三、觀察源碼

_defer

type _defer struct {
    siz     int32
    siz     int32 // includes both arguments and results
    started bool
    heap    bool
    sp      uintptr // sp at time of defer
    pc      uintptr
    fn      *funcval
    ...

相較于以前的版本,最小單元的 _defer 結構體主要是新增了 heap 字段,用于标識這個 _defer 是在堆上,還是在棧上進行分配,其餘字段并沒有明确變更,那我們可以把聚焦點放在 defer 的堆棧分配上了,看看是做了什麼事。

deferprocStack

func deferprocStack(d *_defer) {
    gp := getg()
    if gp.m.curg != gp {
        throw("defer on system stack")
    }
    
    d.started = false
    d.heap = false
    d.sp = getcallersp()
    d.pc = getcallerpc()

    *(*uintptr)(unsafe.Pointer(&d._panic)) = 0
    *(*uintptr)(unsafe.Pointer(&d.link)) = uintptr(unsafe.Pointer(gp._defer))
    *(*uintptr)(unsafe.Pointer(&gp._defer)) = uintptr(unsafe.Pointer(d))

    return0()
}

這一塊代碼挺常規的,主要是獲取調用 defer 函數的函數棧指針、傳入函數的參數具體地址以及PC(程序計數器),這塊在前文 《深入理解 Go defer》 有詳細介紹過,這裡就不再贅述了。

那這個 deferprocStack 特殊在哪呢,我們可以看到它把 d.heap 設置為了 false,也就是代表 deferprocStack 方法是針對将 _defer 分配在棧上的應用場景的。

deferproc

那麼問題來了,它又在哪裡處理分配到堆上的應用場景呢?

func newdefer(siz int32) *_defer {
    ...
    d.heap = true
    d.link = gp._defer
    gp._defer = d
    return d
}

那麼 newdefer 是在哪裡調用的呢,如下:

func deferproc(siz int32, fn *funcval) { // arguments of fn follow fn
    ...
    sp := getcallersp()
    argp := uintptr(unsafe.Pointer(&fn)) + unsafe.Sizeof(fn)
    callerpc := getcallerpc()

    d := newdefer(siz)
    ...
}

非常明确,先前的版本中調用的 deferproc 方法,現在被用于對應分配到堆上的場景了。

小結

  • 第一點:可以确定的是 deferproc 并沒有被去掉,而是流程被優化了。
  • 第二點:編譯器會根據應用場景去選擇使用 deferproc 還是 deferprocStack 方法,他們分别是針對分配在堆上和棧上的使用場景。

四、編譯器如何選擇

esc

// src/cmd/compile/internal/gc/esc.go
case ODEFER:
    if e.loopdepth == 1 { // top level
        n.Esc = EscNever // force stack allocation of defer record (see ssa.go)
        break
    }

ssa

// src/cmd/compile/internal/gc/ssa.go
case ODEFER:
    d := callDefer
    if n.Esc == EscNever {
        d = callDeferStack
    }
    s.call(n.Left, d)

小結

這塊結合來看,核心就是當 e.loopdepth == 1 時,會将逃逸分析結果 n.Esc 設置為 EscNever,也就是将 _defer 分配到棧上,那這個 e.loopdepth 到底又是何方神聖呢,我們再詳細看看代碼,如下:

// src/cmd/compile/internal/gc/esc.go
type NodeEscState struct {
    Curfn             *Node
    Flowsrc           []EscStep 
    Retval            Nodes    
    Loopdepth         int32  
    Level             Level
    Walkgen           uint32
    Maxextraloopdepth int32
}

這裡重點查看 Loopdepth 字段,目前它共有三個值标識,分别是:

  • -1:全局。
  • 0:返回變量。
  • 1:頂級函數,又或是内部函數的不斷增長值。

這個讀起來有點繞,結合我們上述 e.loopdepth == 1 的表述來看,也就是當 defer func 是頂級函數時,将會分配到棧上。但是若在 defer func 外層出現顯式的疊代循環,又或是出現隐式疊代,将會分配到堆上。其實深層表示的還是疊代深度的意思,我們可以來證實一下剛剛說的方向,顯式疊代的代碼如下:

func main() {
    for p := 0; p < 10; p++ {
        defer func() {
            for i := 0; i < 20; i++ {
                log.Println("EDDYCJY")
            }
        }()
    }
}

查看彙編情況:

$ go tool compile -S main.go
"".main STEXT size=122 args=0x0 locals=0x20
    0x0000 00000 (main.go:15)    TEXT    "".main(SB), ABIInternal, $32-0
    ...
    0x0048 00072 (main.go:17)    CALL    runtime.deferproc(SB)
    0x004d 00077 (main.go:17)    TESTL    AX, AX
    0x004f 00079 (main.go:17)    JNE    83
    0x0051 00081 (main.go:17)    JMP    33
    0x0053 00083 (main.go:17)    XCHGL    AX, AX
    0x0054 00084 (main.go:17)    CALL    runtime.deferreturn(SB)
    ...

顯然,最終 defer 調用的是 runtime.deferproc 方法,也就是分配到堆上了,沒毛病。而隐式疊代的話,你可以借助 goto 語句去實現這個功能,再自己驗證一遍,這裡就不再贅述了。

總結

從分析的結果上來看,官方說明的 Go1.13 defer 性能提高 30%,主要來源于其延遲對象的堆棧分配規則的改變,措施是由編譯器通過對 deferfor-loop 疊代深度進行分析,如果 loopdepth 為 1,則設置逃逸分析的結果,将分配到棧上,否則分配到堆上。

的确,我個人覺得對大部分的使用場景來講,是優化了不少,也解決了一些人吐槽 defer 性能 “差” 的問題。另外,我想從 Go1.13 起,你也需要稍微了解一下它這塊的機制,别随随便便就來個狂野版嵌套疊代 defer,可能沒法效能最大化。

如果你還想了解更多細節,可以看看 defer 這塊的的提交内容,官方的測試用例也包含在裡面。


如果覺得我的文章對你有用,請随意贊賞

你可能感興趣的

載入中...