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

煎魚發表於2019-09-07

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 到底又是何方神聖呢,我想它應該是迭代深度的意思,我們可以來證實一下,程式碼如下:

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 方法,也就是分配到堆上了,沒毛病。

總結

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

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

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

相關文章