1. 程式人生 > 其它 >go語言記憶體管理(3):逃逸分析

go語言記憶體管理(3):逃逸分析

Go 語言較之 C 語言一個很大的優勢就是自帶 GC 功能,可 GC 並不是沒有代價的。寫 C 語言的時候,在一個函式內宣告的變數,在函式退出後會自動釋放掉,因為這些變數分配在棧上。如果你想要變數的資料能在函式退出後還能訪問,就需要呼叫 malloc 方法在堆上申請記憶體,如果程式不再需要這塊記憶體了,再呼叫 free 方法釋放掉。Go 語言不需要你主動呼叫 malloc 來分配堆空間,編譯器會自動分析,找出需要 malloc 的變數,使用堆記憶體。編譯器的這個分析過程就叫做逃逸分析。

所以你在一個函式中通過 dict := make(map[string]int) 建立一個 map 變數,其背後的資料是放在棧空間上還是堆空間上,是不一定的。這要看編譯器分析的結果。

可逃逸分析並不是百分百準確的,它有缺陷。有的時候你會發現有些變數其實在棧空間上分配完全沒問題的,但編譯後程序還是把這些資料放在了堆上。如果你瞭解 Go 語言編譯器逃逸分析的機制,在寫程式碼的時候就可以有意識的繞開這些缺陷,使你的程式更高效。

關於堆疊

Go 語言雖然在記憶體管理方面降低了程式設計門檻,即使你不瞭解堆疊也能正常開發,但如果你要在效能上較真的話,還是要掌握這些基礎知識。

這裡不對堆記憶體和棧記憶體的區別做太多闡述。簡單來說就是,棧分配廉價,堆分配昂貴。棧空間會隨著一個函式的結束自動釋放,堆空間需要 GC 模組不斷的跟蹤掃描回收。如果對這兩個概念有些迷糊,建議閱讀下面 2 個文章:

這裡舉一個小例子,來對比下堆疊的差別:

func stack() int { 
    // 變數 i 會在棧上分配
     i := 10
     return i
}
func heap() *int {
    // 變數 j 會在堆上分配
    j := 10
    return &j
}

stack 函式中的變數 i 在函式退出會自動釋放;而 heap 函式返回的是對變數i的引用,也就是說 heap()退出後,表示變數 i

還要能被訪問,它會自動被分配到堆空間上。

他們編譯出來的程式碼如下:

// go build --gcflags '-l' test.go
// go tool objdump ./test


TEXT main.stack(SB) /tmp/test.go
  test.go:7     0x487240        48c74424080a000000  MOVQ $0xa, 0x8(SP)  
  test.go:7     0x487249        c3          RET         

TEXT main.heap(SB) /tmp/test.go
  test.go:9     0x487250        64488b0c25f8ffffff  MOVQ FS:0xfffffff8, CX          
  test.go:9     0x487259        483b6110        CMPQ 0x10(CX), SP           
  test.go:9     0x48725d        7639            JBE 0x487298                
  test.go:9     0x48725f        4883ec18        SUBQ $0x18, SP              
  test.go:9     0x487263        48896c2410      MOVQ BP, 0x10(SP)           
  test.go:9     0x487268        488d6c2410      LEAQ 0x10(SP), BP           
  test.go:10        0x48726d        488d05ac090100      LEAQ 0x109ac(IP), AX            
  test.go:10        0x487274        48890424        MOVQ AX, 0(SP)              
  test.go:10        0x487278        e8f33df8ff      CALL runtime.newobject(SB)      
  test.go:10        0x48727d        488b442408      MOVQ 0x8(SP), AX            
  test.go:10        0x487282        48c7000a000000      MOVQ $0xa, 0(AX)            
  test.go:11        0x487289        4889442420      MOVQ AX, 0x20(SP)           
  test.go:11        0x48728e        488b6c2410      MOVQ 0x10(SP), BP           
  test.go:11        0x487293        4883c418        ADDQ $0x18, SP              
  test.go:11        0x487297        c3          RET                 
  test.go:9     0x487298        e8a380fcff      CALL runtime.morestack_noctxt(SB)   
  test.go:9     0x48729d        ebb1            JMP main.heap(SB)           
// ...

TEXT runtime.newobject(SB) /usr/share/go/src/runtime/malloc.go
  malloc.go:1067    0x40b070        64488b0c25f8ffffff  MOVQ FS:0xfffffff8, CX          
  malloc.go:1067    0x40b079        483b6110        CMPQ 0x10(CX), SP           
  malloc.go:1067    0x40b07d        763d            JBE 0x40b0bc                
  malloc.go:1067    0x40b07f        4883ec28        SUBQ $0x28, SP              
  malloc.go:1067    0x40b083        48896c2420      MOVQ BP, 0x20(SP)           
  malloc.go:1067    0x40b088        488d6c2420      LEAQ 0x20(SP), BP           
  malloc.go:1068    0x40b08d        488b442430      MOVQ 0x30(SP), AX           
  malloc.go:1068    0x40b092        488b08          MOVQ 0(AX), CX              
  malloc.go:1068    0x40b095        48890c24        MOVQ CX, 0(SP)              
  malloc.go:1068    0x40b099        4889442408      MOVQ AX, 0x8(SP)            
  malloc.go:1068    0x40b09e        c644241001      MOVB $0x1, 0x10(SP)         
  malloc.go:1068    0x40b0a3        e888f4ffff      CALL runtime.mallocgc(SB)       
  malloc.go:1068    0x40b0a8        488b442418      MOVQ 0x18(SP), AX           
  malloc.go:1068    0x40b0ad        4889442438      MOVQ AX, 0x38(SP)           
  malloc.go:1068    0x40b0b2        488b6c2420      MOVQ 0x20(SP), BP           
  malloc.go:1068    0x40b0b7        4883c428        ADDQ $0x28, SP              
  malloc.go:1068    0x40b0bb        c3          RET                 
  malloc.go:1067    0x40b0bc        e87f420400      CALL runtime.morestack_noctxt(SB)   
  malloc.go:1067    0x40b0c1        ebad            JMP runtime.newobject(SB)       

邏輯的複雜度不言而喻,上面的彙編中可看到, heap() 函式呼叫了 runtime.newobject() 方法,它會呼叫 mallocgc 方法從 mcache 上申請記憶體,申請的內部邏輯前面文章已經講述過。堆記憶體分配不僅分配上邏輯比棧空間分配複雜,它最致命的是會帶來很大的管理成本,Go 語言要消耗很多的計算資源對其進行標記回收(也就是 GC 成本)。

不要以為使用了堆記憶體就一定會導致效能低下,使用棧記憶體會帶來效能優勢。因為實際專案中,系統的效能瓶頸一般都不會出現在記憶體分配上。千萬不要盲目優化,找到系統瓶頸,用資料驅動優化。

逃逸分析

Go 編輯器會自動幫我們找出需要進行動態分配的變數,它是在編譯時追蹤一個變數的生命週期,如果能確認一個數據只在函式空間內訪問,不會被外部使用,則使用棧空間,否則就要使用堆空間。

我們在 go build 編譯程式碼時,可使用 -gcflags '-m' 引數來檢視逃逸分析日誌。

go build -gcflags '-m -m' test.go

以上面的兩個函式為例,編譯的日誌輸出是:

/tmp/test.go:11:9: &i escapes to heap
/tmp/test.go:11:9:  from ~r0 (return) at /tmp/test.go:11:2
/tmp/test.go:10:2: moved to heap: i
/tmp/test.go:16:18: heap() escapes to heap
/tmp/test.go:16:18:     from ... argument (arg to ...) at /tmp/test.go:16:13
/tmp/test.go:16:18:     from *(... argument) (indirection) at /tmp/test.go:16:13
/tmp/test.go:16:18:     from ... argument (passed to call[argument content escapes]) at /tmp/test.go:16:13
/tmp/test.go:16:13: main ... argument does not escape

日誌中的 &i escapes to heap 表示該變數資料逃逸到了堆上。

逃逸分析的缺陷

需要使用堆空間則逃逸,這沒什麼可爭議的。但編譯器有時會將不需要使用堆空間的變數,也逃逸掉。這裡是容易出現效能問題的大坑。網上有很多相關文章,列舉了一些導致逃逸情況,其實總結起來就一句話:

多級間接賦值容易導致逃逸

這裡的多級間接指的是,對某個引用類物件中的引用類成員進行賦值。Go 語言中的引用類資料型別有 func, interface, slice, map, chan, *Type(指標)

記住公式 Data.Field = Value,如果 Data, Field 都是引用類的資料型別,則會導致 Value 逃逸。這裡的等號 = 不單單隻賦值,也表示引數傳遞。

根據公式,我們假設一個變數 data 是以下幾種型別,相應的可得出結論:

  • []interface{}: data[0] = 100 會導致 100 逃逸
  • map[string]interface{}: data["key"] = "value" 會導致 "value" 逃逸
  • map[interface{}]interface{}: data["key"] = "value" 會導致 keyvalue 都逃逸
  • map[string][]string: data["key"] = []string{"hello"} 會導致切片逃逸
  • map[string]*int: 賦值時 *int 會 逃逸
  • []*int: data[0] = &i 會使 i 逃逸
  • func(*int): data(&i) 會使 i 逃逸
  • func([]string): data([]{"hello"}) 會使 []string{"hello"} 逃逸
  • chan []string: data <- []string{"hello"} 會使 []string{"hello"} 逃逸
  • 以此類推,不一一列舉了

下面給出一些實際的例子:

函式變數

如果變數值是一個函式,函式的引數又是引用型別,則傳遞給它的引數都會逃逸。

func test(i int)        {}
func testEscape(i *int) {}

func main() {
    i, j, m, n := 0, 0, 0, 0
    t, te := test, testEscape // 函式變數

    // 直接呼叫
    test(m)        // 不逃逸
    testEscape(&n) // 不逃逸
    // 間接呼叫
    t(i)   // 不逃逸
    te(&j) // 逃逸
}
./test.go:4:17: testEscape i does not escape
./test.go:11:5: &j escapes to heap
./test.go:11:5:     from te(&j) (parameter to indirect call) at ./test.go:11:4
./test.go:7:5: moved to heap: j
./test.go:14:13: main &n does not escape

上例中 te 的型別是 func(*int),屬於引用型別,引數 *int 也是引用型別,則呼叫 te(&j) 形成了為 te 的引數(成員) *int 賦值的現象,即 te.i = &j 會導致逃逸。程式碼中其他幾種呼叫都沒有形成多級間接賦值情況。
同理,如果函式的引數型別是 slice, mapinterface{} 都會導致引數逃逸。

func testSlice(slice []int)       {}
func testMap(m map[int]int)       {}
func testInterface(i interface{}) {}

func main() {
    x, y, z := make([]int, 1), make(map[int]int), 100
    ts, tm, ti := testSlice, testMap, testInterface
    ts(x) // ts.slice = x 導致 x 逃逸
    tm(y) // tm.m = y 導致 y 逃逸
    ti(z) // ti.i = z 導致 z 逃逸
}
./test.go:3:16: testSlice slice does not escape
./test.go:4:14: testMap m does not escape
./test.go:5:20: testInterface i does not escape
./test.go:8:17: make([]int, 1) escapes to heap
./test.go:8:17:     from x (assign-pair) at ./test.go:8:10
./test.go:8:17:     from ts(x) (parameter to indirect call) at ./test.go:10:4
./test.go:8:33: make(map[int]int) escapes to heap
./test.go:8:33:     from y (assign-pair) at ./test.go:8:10
./test.go:8:33:     from tm(y) (parameter to indirect call) at ./test.go:11:4
./test.go:12:4: z escapes to heap
./test.go:12:4:     from ti(z) (parameter to indirect call) at ./test.go:12:4

匿名函式的呼叫也是一樣的,它本質上也是一個函式變數。有興趣的可以自己測試一下。

間接賦值

type Data struct {
    data  map[int]int
    slice []int
    ch    chan int
    inf   interface{}
    p     *int
}

func main() {
    d1 := Data{}
    d1.data = make(map[int]int) // GOOD: does not escape
    d1.slice = make([]int, 4)   // GOOD: does not escape
    d1.ch = make(chan int, 4)   // GOOD: does not escape
    d1.inf = 3                  // GOOD: does not escape
    d1.p = new(int)             //  GOOD: does not escape

    d2 := new(Data)             // d2 是指標變數, 下面為該指標變數中的指標成員賦值
    d2.data = make(map[int]int) // BAD: escape to heap
    d2.slice = make([]int, 4)   // BAD:  escape to heap
    d2.ch = make(chan int, 4)   // BAD:  escape to heap
    d2.inf = 3                  // BAD:  escape to heap
    d2.p = new(int)             // BAD:  escape to heap
}
./test.go:20:16: make(map[int]int) escapes to heap
./test.go:20:16:    from d2.data (star-dot-equals) at ./test.go:20:10
./test.go:21:17: make([]int, 4) escapes to heap
./test.go:21:17:    from d2.slice (star-dot-equals) at ./test.go:21:11
./test.go:22:14: make(chan int, 4) escapes to heap
./test.go:22:14:    from d2.ch (star-dot-equals) at ./test.go:22:8
./test.go:23:9: 3 escapes to heap
./test.go:23:9:     from d2.inf (star-dot-equals) at ./test.go:23:9
./test.go:24:12: new(int) escapes to heap
./test.go:24:12:    from d2.p (star-dot-equals) at ./test.go:24:7
./test.go:13:16: main make(map[int]int) does not escape
./test.go:14:17: main make([]int, 4) does not escape
./test.go:15:14: main make(chan int, 4) does not escape
./test.go:16:9: main 3 does not escape
./test.go:17:12: main new(int) does not escape
./test.go:19:11: main new(Data) does not escape

Interface

只要使用了 Interface 型別(不是 interafce{}),那麼賦值給它的變數一定會逃逸。因為 interfaceVariable.Method() 先是間接的定位到它的實際值,再呼叫實際值的同名方法,執行時實際值作為引數傳遞給方法。相當於interfaceVariable.Method.this = realValue

type Iface interface {
    Dummy()
}
type Integer int
func (i Integer) Dummy() {}

func main() {
    var (
        iface Iface
        i     Integer
    )
    iface = i
    iface.Dummy() //  make i escape to heap
    // 形成 iface.Dummy.i = i
}

引用型別的 channel

向 channel 中傳送資料,本質上就是為 channel 內部的成員賦值,就像給一個 slice 中的某一項賦值一樣。所以 chan *Type, chan map[Type]Type, chan []Type, chan interface{} 型別都會導致傳送到 channel 中的資料逃逸。

這本來也是情理之中的,傳送給 channel 的資料是要與其他函式分享的,為了保證傳送過去的指標依然可用,只能使用堆分配。

func test() {
    var (
        chInteger   = make(chan *int)
        chMap       = make(chan map[int]int)
        chSlice     = make(chan []int)
        chInterface = make(chan interface{})
        a, b, c, d  = 0, map[int]int{}, []int{}, 32
    )
    chInteger <- &a  // 逃逸
    chMap <- b       // 逃逸
    chSlice <- c     // 逃逸
    chInterface <- d // 逃逸
}
./escape.go:11:15: &a escapes to heap
./escape.go:11:15:  from chInteger <- &a (send) at ./escape.go:11:12
./escape.go:9:3: moved to heap: a
./escape.go:9:31: map[int]int literal escapes to heap
./escape.go:9:31:   from b (assigned) at ./escape.go:9:3
./escape.go:9:31:   from chMap <- b (send) at ./escape.go:12:8
./escape.go:9:40: []int literal escapes to heap
./escape.go:9:40:   from c (assigned) at ./escape.go:9:3
./escape.go:9:40:   from chSlice <- c (send) at ./escape.go:13:10
./escape.go:14:14: d escapes to heap
./escape.go:14:14:  from chInterface <- (interface {})(d) (send) at ./escape.go:14:14
./escape.go:5:21: test make(chan *int) does not escape
./escape.go:6:21: test make(chan map[int]int) does not escape
./escape.go:7:21: test make(chan []int) does not escape
./escape.go:8:21: test make(chan interface {}) does not escape

可變引數

可變引數如 func(arg ...string) 實際與 func(arg []string) 是一樣的,會增加一層訪問路徑。這也是 fmt.Sprintf 總是會使引數逃逸的原因。

例子非常多,這裡不能一一列舉,我們只需要記住分析方法就好,即,2 級或更多級的訪問賦值會容易導致資料逃逸。這裡加上容易二字是因為隨著語言的發展,相信這些問題會被慢慢解決,但現階段,這個可以作為我們分析逃逸現象的依據。

下面程式碼中包含 2 種很常規的寫法,但他們卻有著很大的效能差距,建議自己想下為什麼。

type User struct {
    roles []string
}

func (u *User) SetRoles(roles []string) {
    u.roles = roles
}

func SetRoles(u User, roles []string) User {
    u.roles = roles
    return u
}

Benchmark 和 pprof 給出的結果:

BenchmarkUserSetRoles-8     50000000            22.3 ns/op        16 B/op          1 allocs/op
BenchmarkSetRoles-8         2000000000           0.51 ns/op        0 B/op          0 allocs/op

  768.01MB   768.01MB (flat, cum)   100% of Total
         .          .      3:import "testing"
         .          .      4:
         .          .      5:func BenchmarkUserSetRoles(b *testing.B) {
         .          .      6:   u := new(User)
         .          .      7:   for i := 0; i < b.N; i++ {
  768.01MB   768.01MB      8:       u.SetRoles([]string{"admin"}) <- 看這裡
         .          .      9:   }
         .          .     10:}
         .          .     11:
         .          .     12:func BenchmarkSetRoles(b *testing.B) {
         .          .     13:   for i := 0; i < b.N; i++ {
ROUTINE ======================== testing.(*B).launch in /usr/share/go/src/testing/benchmark.go
......

結論

熟悉堆疊概念可以讓我們更容易看透 Go 程式的效能問題,並進行優化。

多級間接賦值會導致 Go 編譯器出現不必要的逃逸,在一些情況下可能我們只需要修改一下資料結構就會使效能有大幅提升。這也是很多人不推薦在 Go 中使用指標的原因,因為它會增加一級訪問路徑,而 map, slice, interface{}等型別是不可避免要用到的,為了減少不必要的逃逸,只能拿指標開刀了。

大多數情況下,效能優化都會為程式帶來一定的複雜度。建議實際專案中還是怎麼方便怎麼寫,功能完成後通過效能分析找到瓶頸所在,再對區域性進行優化。


連結:https://www.jianshu.com/p/518466b4ee96

全世界的程式設計師們聯合起來吧!