天天看點

golang 中函數使用值傳回與指針傳回的差別,底層原理分析

變量記憶體配置設定與回收

Go 程式會在兩個地方為變量配置設定記憶體,一個是全局的堆上,另一個是函數調用棧,Go 語言有垃圾回收機制,在Go中變量配置設定在堆還是棧上是由編譯器決定的,是以開發者無需過多關注變量是配置設定在棧上還是堆上。但如果想寫出高品質的代碼,了解語言背後的實作是有必要的,變量在棧上配置設定和在堆上配置設定底層實作的機制完全不同,變量的配置設定與回收流程不同,性能差異是非常大的。

堆與棧的差別

程式運作時動态配置設定的記憶體都位于堆中,這部分記憶體由記憶體配置設定器負責管理,該區域的大小會随着程式的運作而變化,即當我們向堆請求配置設定記憶體但配置設定器發現堆中的記憶體不足時,它會向作業系統核心申請向高位址方向擴充堆的大小,而當我們釋放記憶體把它歸還給堆時如果記憶體配置設定器發現剩餘空閑記憶體太多則又會向作業系統請求向低位址方向收縮堆的大小,從記憶體申請和釋放流程可以看出,從堆上配置設定的記憶體用完之後必須歸還給堆,否則記憶體配置設定器可能會反複向作業系統申請擴充堆的大小進而導緻堆記憶體越用越多,最後出現記憶體不足,這就是所謂的記憶體洩漏。值的一提的是傳統的 c/c++ 代碼需要手動處理記憶體的配置設定和釋放,而在 Go 語言中,有垃圾回收器來回收堆上的記憶體,是以程式員隻管申請記憶體,而不用管記憶體的釋放,大大降低了程式員的心智負擔,這不光是提高了程式員的生産力,更重要的是還會減少很多bug的産生。

函數調用棧簡稱棧,在程式運作過程中,不管是函數的執行還是函數調用,棧都起着非常重要的作用,它主要被用來:

  • 儲存函數的局部變量;
  • 向被調用函數傳遞參數;
  • 傳回函數的傳回值;
  • 儲存函數的傳回位址,傳回位址是指從被調用函數傳回後調用者應該繼續執行的指令位址;

每個函數在執行過程中都需要使用一塊棧記憶體用來儲存上述這些值,我們稱這塊棧記憶體為某函數的棧幀(stack frame)。當發生函數調用時,因為調用者還沒有執行完,其棧記憶體中儲存的資料還有用,是以被調用函數不能覆寫調用者的棧幀,隻能把被調用函數的棧幀“push”到棧上,等被調函數執行完成後再把其棧幀從棧上“pop”出去,這樣,棧的大小就會随函數調用層級的增加而生長,随函數的傳回而縮小,也就是說函數調用層級越深,消耗的棧空間就越大。棧的生長和收縮都是自動的,由編譯器插入的代碼自動完成,是以位于棧記憶體中的函數局部變量所使用的記憶體随函數的調用而配置設定,随函數的傳回而自動釋放,是以程式員不管是使用有垃圾回收還是沒有垃圾回收的進階程式設計語言都不需要自己釋放局部變量所使用的記憶體,這一點與堆上配置設定的記憶體截然不同。

golang 中函數使用值傳回與指針傳回的差別,底層原理分析

程序是作業系統資源配置設定的基本機關,每個程序在啟動時作業系統會程序的棧配置設定固定大小的記憶體,Linux 中程序預設棧的大小可以通過

ulimit -s

檢視,當函數退出時配置設定在棧上的記憶體通過修改寄存器指針的偏移量會自動進行回收,程序在運作時堆中記憶體的大小都需要向作業系統申請,程序堆可用記憶體的大小也取決于目前作業系統可用記憶體的量。

那麼在 Go 中變量配置設定在堆上與棧上編譯器是如何決定的?

變量記憶體配置設定逃逸分析

上文已經提到 Go 中變量配置設定在堆還是棧上是由編譯器決定的,這種由編譯器決定記憶體配置設定位置的方式稱之為逃逸分析(escape analysis)。Go 中聲明一個函數内局部變量時,當編譯器發現變量的作用域沒有逃出函數範圍時,就會在棧上配置設定記憶體,反之則配置設定在堆上,逃逸分析由編譯器完成,作用于編譯階段。

檢查該變量是在棧上配置設定還是堆上配置設定

有兩種方式可以确定變量是在堆還是在棧上配置設定記憶體:

  • 通過編譯後生成的彙編函數來确認,在堆上配置設定記憶體的變量都會調用 runtime 包的

    newobject

    函數;
  • 編譯時通過指定選項顯示編譯優化資訊,編譯器會輸出逃逸的變量;

通過以上兩種方式來分析以下代碼示例中的變量是否存在逃逸:

package main

type demo struct {
    Msg string
}

func example() *demo {
    d := &demo{}
    return d
}

func main() {
    example()
}           

1、通過彙編來确認變量記憶體配置設定是否有逃逸

$ go tool compile -S main.go
go tool compile -S main.go
"".example STEXT size=72 args=0x8 locals=0x18
    0x0000 00000 (main.go:7)    TEXT    "".example(SB), ABIInternal, $24-8
    0x0000 00000 (main.go:7)    MOVQ    (TLS), CX
    0x0009 00009 (main.go:7)    CMPQ    SP, 16(CX)
    0x000d 00013 (main.go:7)    PCDATA    $0, $-2
    0x000d 00013 (main.go:7)    JLS    65
    0x000f 00015 (main.go:7)    PCDATA    $0, $-1
    0x000f 00015 (main.go:7)    SUBQ    $24, SP
    0x0013 00019 (main.go:7)    MOVQ    BP, 16(SP)
    0x0018 00024 (main.go:7)    LEAQ    16(SP), BP
    0x001d 00029 (main.go:7)    PCDATA    $0, $-2
    0x001d 00029 (main.go:7)    PCDATA    $1, $-2
    0x001d 00029 (main.go:7)    FUNCDATA    $0, gclocals·9fb7f0986f647f17cb53dda1484e0f7a(SB)
    0x001d 00029 (main.go:7)    FUNCDATA    $1, gclocals·69c1753bd5f81501d95132d08af04464(SB)
    0x001d 00029 (main.go:7)    FUNCDATA    $2, gclocals·9fb7f0986f647f17cb53dda1484e0f7a(SB)
    0x001d 00029 (main.go:8)    PCDATA    $0, $1
    0x001d 00029 (main.go:8)    PCDATA    $1, $0
    0x001d 00029 (main.go:8)    LEAQ    type."".demo(SB), AX
    0x0024 00036 (main.go:8)    PCDATA    $0, $0
    0x0024 00036 (main.go:8)    MOVQ    AX, (SP)
    0x0028 00040 (main.go:8)    CALL    runtime.newobject(SB)  // 調用 runtime.newobject 函數
    0x002d 00045 (main.go:8)    PCDATA    $0, $1
    0x002d 00045 (main.go:8)    MOVQ    8(SP), AX
    0x0032 00050 (main.go:9)    PCDATA    $0, $0
    0x0032 00050 (main.go:9)    PCDATA    $1, $1
    0x0032 00050 (main.go:9)    MOVQ    AX, "".~r0+32(SP)
    0x0037 00055 (main.go:9)    MOVQ    16(SP), BP
    0x003c 00060 (main.go:9)    ADDQ    $24, SP
    0x0040 00064 (main.go:9)    RET
    0x0041 00065 (main.go:9)    NOP
    0x0041 00065 (main.go:7)    PCDATA    $1, $-1
    0x0041 00065 (main.go:7)    PCDATA    $0, $-2
    0x0041 00065 (main.go:7)    CALL    runtime.morestack_noctxt(SB)
    0x0046 00070 (main.go:7)    PCDATA    $0, $-1
    0x0046 00070 (main.go:7)    JMP    0           

以上僅僅列出了 example 函數編譯後的彙編代碼,可以看到在程式的第8行調用了 runtime.newobject 函數。

2、通過編譯選項檢查

執行 go tool compile -l -m -m main.go 或者 go build -gcflags "-m -m -l" main.go

$ go build -gcflags "-m -l" main.go
# command-line-arguments
./main.go:8:7: &demo literal escapes to heap:
./main.go:8:7:   flow: d = &{storage for &demo literal}:
./main.go:8:7:     from &demo literal (spill) at ./main.go:8:7
./main.go:8:7:     from d := &demo literal (assign) at ./main.go:8:4
./main.go:8:7:   flow: ~r0 = d:
./main.go:8:7:     from return d (return) at ./main.go:9:2
./main.go:8:7: &demo literal escapes to heap

$ go tool compile -l -m -m main.go
main.go:8:7: &demo literal escapes to heap:
main.go:8:7:   flow: d = &{storage for &demo literal}:
main.go:8:7:     from &demo literal (spill) at main.go:8:7
main.go:8:7:     from d := &demo literal (assign) at main.go:8:4
main.go:8:7:   flow: ~r0 = d:
main.go:8:7:     from return d (return) at main.go:9:2
main.go:8:7: &demo literal escapes to heap           

可以使用

go tool compile --help

檢視幾個選項的含義。

Go 官方 faq 文檔

stack_or_heap

一節也說了如何知道一個變量是在堆上還是在粘上配置設定記憶體的,文檔描述的比較簡單,下面再看幾個特定類型的示例。

函數内變量在堆上配置設定的一些 case

1、指針類型的變量,指針逃逸

代碼示例,和上節示例一緻:

package main

type demo struct {
    Msg string
}

func example() *demo {
    d := &demo{}
    return d
}

func main() {
    example()
}

$ go tool compile -l -m main.go
main.go:8:7: &demo literal escapes to heap           

2、棧空間不足

package main

func generate8191() {
    nums := make([]int, 8191) // < 64KB
    for i := 0; i < 8191; i++ {
        nums[i] = i
    }
}

func generate8192() {
    nums := make([]int, 8192) // = 64KB
    for i := 0; i < 8192; i++ {
        nums[i] = i
    }
}

func generate(n int) {
    nums := make([]int, n) // 不确定大小
    for i := 0; i < n; i++ {
        nums[i] = i
    }
}

func main() {
    generate8191()
    generate8192()
    generate(1)
}

$ go tool compile -l -m main.go
main.go:4:14: make([]int, 8191) does not escape
main.go:9:14: make([]int, 8192) escapes to heap
main.go:14:14: make([]int, n) escapes to heap           

在 Go 編譯器代碼中可以看到,對于有聲明類型的變量大小超過 10M 會被配置設定到堆上,隐式變量預設超過64KB 會被配置設定在堆上。

var (
    // maximum size variable which we will allocate on the stack.
    // This limit is for explicit variable declarations like "var x T" or "x := ...".
    // Note: the flag smallframes can update this value.
    maxStackVarSize = int64(10 * 1024 * 1024)

    // maximum size of implicit variables that we will allocate on the stack.
    //   p := new(T)          allocating T on the stack
    //   p := &T{}            allocating T on the stack
    //   s := make([]T, n)    allocating [n]T on the stack
    //   s := []byte("...")   allocating [n]byte on the stack
    // Note: the flag smallframes can update this value.
    maxImplicitStackVarSize = int64(64 * 1024)
)           

3、動态類型,interface{} 動态類型逃逸

package main

type Demo struct {
    Name string
}

func main() {
    _ = example()
}

func example() interface{} {
    return Demo{}
}

$ go tool compile -l -m main.go
main.go:12:13: Demo literal escapes to heap           

4、閉包引用對象

package main

import "fmt"

func increase(x int) func() int {
    return func() int {
        x++
        return x
    }
}

func main() {
    x := 0
    in := increase(x)
    fmt.Println(in())
    fmt.Println(in())
}

$ go tool compile -l -m main.go
main.go:5:15: moved to heap: x
main.go:6:9: func literal escapes to heap
main.go:15:13: ... argument does not escape
main.go:15:16: in() escapes to heap
main.go:16:13: ... argument does not escape
main.go:16:16: in() escapes to heap           

函數使用值與指針傳回時性能的差異

上文介紹了 Go 中變量記憶體配置設定方式,通過上文可以知道在函數中定義變量并使用值傳回時,該變量會在棧上配置設定記憶體,函數傳回時會拷貝整個對象,使用指針傳回時變量在配置設定記憶體時會逃逸到堆中,傳回時隻會拷貝指針位址,最終變量會通過 Go 的垃圾回收機制回收掉。

那在函數中傳回時是使用值還是指針,哪種效率更高呢,雖然值有拷貝操作,但是傳回指針會将變量配置設定在堆上,堆上變量的配置設定以及回收也會有較大的開銷。對于該問題,跟傳回的對象和平台也有一定的關系,不同的平台需要通過基準測試才能得到一個比較準确的結果。

return_value_or_pointer.go

package main

import "fmt"

const bigSize = 200000

type bigStruct struct {
    nums [bigSize]int
}

func newBigStruct() bigStruct {
    var a bigStruct

    for i := 0; i < bigSize; i++ {
        a.nums[i] = i
    }
    return a
}

func newBigStructPtr() *bigStruct {
    var a bigStruct

    for i := 0; i < bigSize; i++ {
        a.nums[i] = i
    }
    return &a
}

func main() {
    a := newBigStruct()
    b := newBigStructPtr()

    fmt.Println(a, b)
}           

benchmark_test.go

package main

import "testing"

func BenchmarkStructReturnValue(b *testing.B) {
    b.ReportAllocs()

    t := 0
    for i := 0; i < b.N; i++ {
        v := newBigStruct()
        t += v.nums[0]
    }
}

func BenchmarkStructReturnPointer(b *testing.B) {
    b.ReportAllocs()

    t := 0
    for i := 0; i < b.N; i++ {
        v := newBigStructPtr()
        t += v.nums[0]
    }
}           
$ go test -bench .
goos: darwin
goarch: amd64
BenchmarkStructReturnValue-12              4215        278542 ns/op           0 B/op           0 allocs/op
BenchmarkStructReturnPointer-12            4556        267253 ns/op     1605634 B/op           1 allocs/op
PASS
ok      _/Users/tianfeiyu/golang-dev/test    3.670s           

在我本地測試中,200000 個 int 類型的結構體傳回值更快些,小于 200000 時傳回指針會更快。 如果對于代碼有更高的性能要求,需要在實際平台上進行基準測試來得出結論。

其他的一些使用經驗

1、有狀态的對象必須使用指針傳回,如系統内置的 sync.WaitGroup、sync.Pool 之類的值,在 Go 中有些結構體中會顯式存在 noCopy 字段提醒不能進行值拷貝;

// A WaitGroup must not be copied after first use.
type WaitGroup struct {
    noCopy noCopy

        ......
}           

2、生命周期短的對象使用值傳回,如果對象的生命周期存在比較久或者對象比較大,可以使用指針傳回;

3、大對象推薦使用指針傳回,對象大小臨界值需要在具體平台進行基準測試得出資料;

4、參考一些大的開源項目中的使用方式,比如 kubernetes、docker 等;

總結

本文通過分析在 Go 函數中使用變量時的一些問題,變量在配置設定記憶體時會在堆和棧兩個地方存在,在堆和棧上配置設定記憶體的不同,以及何時需要在堆上配置設定記憶體的變量。

參考:

https://mojotv.cn/go/bad-go-pointer-returns https://github.com/eastany/eastany.github.com/issues/61 https://mp.weixin.qq.com/s/PXGCqxK97U8mLGxW07ZTqw https://golang.design/under-the-hood/zh-cn/part1basic/ch01basic/asm/ https://golang.org/doc/asm https://blog.csdn.net/qmhball https://golang.org/doc/faq#stack_or_heap https://geektutu.com/post/hpg-escape-analysis.html