golang 中函數(shù)使用值返回與指針返回的區(qū)別,底層原理分析

變量內(nèi)存分配與回收

Go 程序會(huì)在兩個(gè)地方為變量分配內(nèi)存,一個(gè)是全局的堆上,另一個(gè)是函數(shù)調(diào)用棧,Go 語言有垃圾回收機(jī)制,在Go中變量分配在堆還是棧上是由編譯器決定的,因此開發(fā)者無需過多關(guān)注變量是分配在棧上還是堆上。但如果想寫出高質(zhì)量的代碼,了解語言背后的實(shí)現(xiàn)是有必要的,變量在棧上分配和在堆上分配底層實(shí)現(xiàn)的機(jī)制完全不同,變量的分配與回收流程不同,性能差異是非常大的。

堆與棧的區(qū)別

程序運(yùn)行時(shí)動(dòng)態(tài)分配的內(nèi)存都位于堆中,這部分內(nèi)存由內(nèi)存分配器負(fù)責(zé)管理,該區(qū)域的大小會(huì)隨著程序的運(yùn)行而變化,即當(dāng)我們向堆請求分配內(nèi)存但分配器發(fā)現(xiàn)堆中的內(nèi)存不足時(shí),它會(huì)向操作系統(tǒng)內(nèi)核申請向高地址方向擴(kuò)展堆的大小,而當(dāng)我們釋放內(nèi)存把它歸還給堆時(shí)如果內(nèi)存分配器發(fā)現(xiàn)剩余空閑內(nèi)存太多則又會(huì)向操作系統(tǒng)請求向低地址方向收縮堆的大小,從內(nèi)存申請和釋放流程可以看出,從堆上分配的內(nèi)存用完之后必須歸還給堆,否則內(nèi)存分配器可能會(huì)反復(fù)向操作系統(tǒng)申請擴(kuò)展堆的大小從而導(dǎo)致堆內(nèi)存越用越多,最后出現(xiàn)內(nèi)存不足,這就是所謂的內(nèi)存泄漏。值的一提的是傳統(tǒng)的 c/c++ 代碼需要手動(dòng)處理內(nèi)存的分配和釋放,而在 Go 語言中,有垃圾回收器來回收堆上的內(nèi)存,所以程序員只管申請內(nèi)存,而不用管內(nèi)存的釋放,大大降低了程序員的心智負(fù)擔(dān),這不光是提高了程序員的生產(chǎn)力,更重要的是還會(huì)減少很多bug的產(chǎn)生。

函數(shù)調(diào)用棧簡稱棧,在程序運(yùn)行過程中,不管是函數(shù)的執(zhí)行還是函數(shù)調(diào)用,棧都起著非常重要的作用,它主要被用來:

  • 保存函數(shù)的局部變量;
  • 向被調(diào)用函數(shù)傳遞參數(shù);
  • 返回函數(shù)的返回值;
  • 保存函數(shù)的返回地址,返回地址是指從被調(diào)用函數(shù)返回后調(diào)用者應(yīng)該繼續(xù)執(zhí)行的指令地址;

每個(gè)函數(shù)在執(zhí)行過程中都需要使用一塊棧內(nèi)存用來保存上述這些值,我們稱這塊棧內(nèi)存為某函數(shù)的棧幀(stack frame)。當(dāng)發(fā)生函數(shù)調(diào)用時(shí),因?yàn)檎{(diào)用者還沒有執(zhí)行完,其棧內(nèi)存中保存的數(shù)據(jù)還有用,所以被調(diào)用函數(shù)不能覆蓋調(diào)用者的棧幀,只能把被調(diào)用函數(shù)的棧幀“push”到棧上,等被調(diào)函數(shù)執(zhí)行完成后再把其棧幀從棧上“pop”出去,這樣,棧的大小就會(huì)隨函數(shù)調(diào)用層級的增加而生長,隨函數(shù)的返回而縮小,也就是說函數(shù)調(diào)用層級越深,消耗的棧空間就越大。棧的生長和收縮都是自動(dòng)的,由編譯器插入的代碼自動(dòng)完成,因此位于棧內(nèi)存中的函數(shù)局部變量所使用的內(nèi)存隨函數(shù)的調(diào)用而分配,隨函數(shù)的返回而自動(dòng)釋放,所以程序員不管是使用有垃圾回收還是沒有垃圾回收的高級編程語言都不需要自己釋放局部變量所使用的內(nèi)存,這一點(diǎn)與堆上分配的內(nèi)存截然不同。

函數(shù)調(diào)用棧

進(jìn)程是操作系統(tǒng)資源分配的基本單位,每個(gè)進(jìn)程在啟動(dòng)時(shí)操作系統(tǒng)會(huì)進(jìn)程的棧分配固定大小的內(nèi)存,Linux 中進(jìn)程默認(rèn)棧的大小可以通過 ulimit -s 查看,當(dāng)函數(shù)退出時(shí)分配在棧上的內(nèi)存通過修改寄存器指針的偏移量會(huì)自動(dòng)進(jìn)行回收,進(jìn)程在運(yùn)行時(shí)堆中內(nèi)存的大小都需要向操作系統(tǒng)申請,進(jìn)程堆可用內(nèi)存的大小也取決于當(dāng)前操作系統(tǒng)可用內(nèi)存的量。

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

變量內(nèi)存分配逃逸分析

上文已經(jīng)提到 Go 中變量分配在堆還是棧上是由編譯器決定的,這種由編譯器決定內(nèi)存分配位置的方式稱之為逃逸分析(escape analysis)。Go 中聲明一個(gè)函數(shù)內(nèi)局部變量時(shí),當(dāng)編譯器發(fā)現(xiàn)變量的作用域沒有逃出函數(shù)范圍時(shí),就會(huì)在棧上分配內(nèi)存,反之則分配在堆上,逃逸分析由編譯器完成,作用于編譯階段。

檢查該變量是在棧上分配還是堆上分配

有兩種方式可以確定變量是在堆還是在棧上分配內(nèi)存:

  • 通過編譯后生成的匯編函數(shù)來確認(rèn),在堆上分配內(nèi)存的變量都會(huì)調(diào)用 runtime 包的 newobject 函數(shù);
  • 編譯時(shí)通過指定選項(xiàng)顯示編譯優(yōu)化信息,編譯器會(huì)輸出逃逸的變量;

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

package main

type demo struct {
    Msg string
}

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

func main() {
    example()
}

1、通過匯編來確認(rèn)變量內(nèi)存分配是否有逃逸

$ 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)  // 調(diào)用 runtime.newobject 函數(shù)
    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 函數(shù)編譯后的匯編代碼,可以看到在程序的第8行調(diào)用了 runtime.newobject 函數(shù)。

2、通過編譯選項(xiàng)檢查

執(zhí)行 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 查看幾個(gè)選項(xiàng)的含義。

Go 官方 faq 文檔 stack_or_heap 一節(jié)也說了如何知道一個(gè)變量是在堆上還是在粘上分配內(nèi)存的,文檔描述的比較簡單,下面再看幾個(gè)特定類型的示例。

函數(shù)內(nèi)變量在堆上分配的一些 case

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

代碼示例,和上節(jié)示例一致:

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 會(huì)被分配到堆上,隱式變量默認(rèn)超過64KB 會(huì)被分配在堆上。

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、動(dòng)態(tài)類型,interface{} 動(dòng)態(tài)類型逃逸

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

函數(shù)使用值與指針返回時(shí)性能的差異

上文介紹了 Go 中變量內(nèi)存分配方式,通過上文可以知道在函數(shù)中定義變量并使用值返回時(shí),該變量會(huì)在棧上分配內(nèi)存,函數(shù)返回時(shí)會(huì)拷貝整個(gè)對象,使用指針返回時(shí)變量在分配內(nèi)存時(shí)會(huì)逃逸到堆中,返回時(shí)只會(huì)拷貝指針地址,最終變量會(huì)通過 Go 的垃圾回收機(jī)制回收掉。

那在函數(shù)中返回時(shí)是使用值還是指針,哪種效率更高呢,雖然值有拷貝操作,但是返回指針會(huì)將變量分配在堆上,堆上變量的分配以及回收也會(huì)有較大的開銷。對于該問題,跟返回的對象和平臺(tái)也有一定的關(guān)系,不同的平臺(tái)需要通過基準(zhǔn)測試才能得到一個(gè)比較準(zhǔn)確的結(jié)果。

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 個(gè) int 類型的結(jié)構(gòu)體返回值更快些,小于 200000 時(shí)返回指針會(huì)更快。 如果對于代碼有更高的性能要求,需要在實(shí)際平臺(tái)上進(jìn)行基準(zhǔn)測試來得出結(jié)論。

其他的一些使用經(jīng)驗(yàn)

1、有狀態(tài)的對象必須使用指針返回,如系統(tǒng)內(nèi)置的 sync.WaitGroup、sync.Pool 之類的值,在 Go 中有些結(jié)構(gòu)體中會(huì)顯式存在 noCopy 字段提醒不能進(jìn)行值拷貝;

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

        ......
}

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

3、大對象推薦使用指針返回,對象大小臨界值需要在具體平臺(tái)進(jìn)行基準(zhǔn)測試得出數(shù)據(jù);

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

總結(jié)

本文通過分析在 Go 函數(shù)中使用變量時(shí)的一些問題,變量在分配內(nèi)存時(shí)會(huì)在堆和棧兩個(gè)地方存在,在堆和棧上分配內(nèi)存的不同,以及何時(shí)需要在堆上分配內(nèi)存的變量。

參考:

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

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。
  • 序言:七十年代末,一起剝皮案震驚了整個(gè)濱河市,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 228,345評論 6 531
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機(jī),發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 98,494評論 3 416
  • 文/潘曉璐 我一進(jìn)店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 176,283評論 0 374
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經(jīng)常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,953評論 1 309
  • 正文 為了忘掉前任,我火速辦了婚禮,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當(dāng)我...
    茶點(diǎn)故事閱讀 71,714評論 6 410
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 55,186評論 1 324
  • 那天,我揣著相機(jī)與錄音,去河邊找鬼。 笑死,一個(gè)胖子當(dāng)著我的面吹牛,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,255評論 3 441
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 42,410評論 0 288
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個(gè)月后,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 48,940評論 1 335
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 40,776評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點(diǎn)故事閱讀 42,976評論 1 369
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,518評論 5 359
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 44,210評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,642評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,878評論 1 286
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個(gè)月前我還...
    沈念sama閱讀 51,654評論 3 391
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個(gè)殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 47,958評論 2 373

推薦閱讀更多精彩內(nèi)容