一篇文章把 Go 中的內存分配扒得乾乾淨淨

今天給大家盤一盤 Go 中關於內存管理比較常問幾個知識點。

1. 分配內存三大組件


Go 分配內存的過程,主要由三大組件所管理,級別從上到下分別是:

 mheap

Go 在程序啓動時,首先會向操作系統申請一大塊內存,並交由mheap結構全局管理。

具體怎麼管理呢?mheap 會將這一大塊內存,切分成不同規格的小內存塊,我們稱之爲 mspan,根據規格大小不同,mspan 大概有 70 類左右,劃分得可謂是非常的精細,足以滿足各種對象內存的分配。

那麼這些 mspan 大大小小的規格,雜亂在一起,肯定很難管理對吧?

因此就有了 mcentral 這下一級組件

 mcentral

啓動一個 Go 程序,會初始化很多的 mcentral ,每個 mcentral 只負責管理一種特定規格的 mspan。

相當於 mcentral 實現了在 mheap 的基礎上對 mspan 的精細化管理。

但是 mcentral 在 Go 程序中是全局可見的,因此如果每次協程來 mcentral 申請內存的時候,都需要加鎖。

可以預想,如果每個協程都來 mcentral 申請內存,那頻繁的加鎖釋放鎖開銷是非常大的。

因此需要有一個 mcentral 的二級代理來緩衝這種壓力

 mcache

在一個 Go 程序裏,每個線程M會綁定給一個處理器P,在單一粒度的時間裏只能做多處理運行一個goroutine,每個P都會綁定一個叫 mcache 的本地緩存。

當需要進行內存分配時,當前運行的goroutine會從mcache中查找可用的mspan。從本地mcache裏分配內存時不需要加鎖,這種分配策略效率更高。

 mspan 供應鏈

mcache 的 mspan 數量並不總是充足的,當供不應求的時候,mcache 會從 mcentral 再次申請更多的 mspan,同樣的,如果 mcentral 的 mspan 數量也不夠的話,mcentral 也會向它的上級 mheap 申請 mspan。再極端一點,如果 mheap 裏的 mspan 也無法滿足程序的內存申請,那該怎麼辦?

那就沒辦法啦,mheap 只能厚着臉皮跟操作系統這個老大哥申請了。

以上的供應流程,只適用於內存塊小於 64KB 的場景,原因在於 Go 沒法使用工作線程的本地緩存mcache和全局中心緩存 mcentral 上管理超過 64KB 的內存分配,所以對於那些超過 64KB 的內存申請,會直接從堆上 (mheap) 上分配對應的數量的內存頁(每頁大小是 8KB)給程序。

2. 什麼是堆內存和棧內存?


根據內存管理(分配和回收)方式的不同,可以將內存分爲 堆內存棧內存

那麼他們有什麼區別呢?

堆內存:由內存分配器和垃圾收集器負責回收

棧內存:由編譯器自動進行分配和釋放

一個程序運行過程中,也許會有多個棧內存,但肯定只會有一個堆內存。

每個棧內存都是由線程或者協程獨立佔有,因此從棧中分配內存不需要加鎖,並且棧內存在函數結束後會自動回收,性能相對堆內存好要高。

而堆內存呢?由於多個線程或者協程都有可能同時從堆中申請內存,因此在堆中申請內存需要加鎖,避免造成衝突,並且堆內存在函數結束後,需要 GC (垃圾回收)的介入參與,如果有大量的 GC 操作,將會吏程序性能下降得歷害。

3. 逃逸分析的必要性


由此可以看出,爲了提高程序的性能,應當儘量減少內存在堆上分配,這樣就能減少 GC 的壓力。

在判斷一個變量是在堆上分配內存還是在棧上分配內存,雖然已經有前人已經總結了一些規律,但依靠程序員能夠在編碼的時候時刻去注意這個問題,對程序員的要求相當之高。

好在 Go 的編譯器,也開放了逃逸分析的功能,使用逃逸分析,可以直接檢測出你程序員所有分配在堆上的變量(這種現象,即是逃逸)。

方法是執行如下命令

go build -gcflags '-m -l' demo.go 

# 或者再加個 -m 查看更詳細信息
go build -gcflags '-m -m -l' demo.go

內存分配位置的規律


如果逃逸分析工具,其實人工也可以判斷到底有哪些變量是分配在堆上的。

那麼這些規律是什麼呢?

經過總結,主要有如下四種情況

  1. 根據變量的使用範圍

  2. 根據變量類型是否確定

  3. 根據變量的佔用大小

  4. 根據變量長度是否確定

接下來我們一個一個分析驗證

 根據變量的使用範圍

當你進行編譯的時候,編譯器會做逃逸分析 (escape analysis),當發現一個變量的使用範圍僅在函數中,那麼可以在棧上爲它分配內存。

比如下邊這個例子

func foo() int {
    v := 1024
    return v
}

func main() {
    m := foo()
    fmt.Println(m)
}

我們可以通過 go build -gcflags '-m -l' demo.go 來查看逃逸分析的結果,其中 -m 是打印逃逸分析的信息,-l 則是禁止內聯優化。

從分析的結果我們並沒有看到任何關於 v 變量的逃逸說明,說明其並沒有逃逸,它是分配在棧上的。

$ go build -gcflags '-m -l' demo.go 
# command-line-arguments
./demo.go:12:13: ... argument does not escape
./demo.go:12:13: m escapes to heap

而如果該變量還需要在函數範圍之外使用,如果還在棧上分配,那麼當函數返回的時候,該變量指向的內存空間就會被回收,程序勢必會報錯,因此對於這種變量只能在堆上分配。

比如下邊這個例子,返回的是指針

func foo() *int {
    v := 1024
    return &v
}

func main() {
    m := foo()
    fmt.Println(*m) // 1024
}

從逃逸分析的結果中可以看到 moved to heap: v ,v 變量是從堆上分配的內存,和上面的場景有着明顯的區別。

$ go build -gcflags '-m -l' demo.go 
# command-line-arguments
./demo.go:6:2: moved to heap: v
./demo.go:12:13: ... argument does not escape
./demo.go:12:14: *m escapes to heap

除了返回指針之外,還有其他的幾種情況也可歸爲一類:

第一種情況:返回任意引用型的變量:Slice 和 Map

func foo() []int {
    a := []int{1,2,3}
    return a
}

func main() {
    b := foo()
    fmt.Println(b)
}

逃逸分析結果

$ go build -gcflags '-m -l' demo.go 
# command-line-arguments
./demo.go:6:12: []int literal escapes to heap
./demo.go:12:13: ... argument does not escape
./demo.go:12:13: b escapes to heap

第二種情況:在閉包函數中使用外部變量

func Increase() func() int {
    n := 0
    return func() int {
        n++
        return n
    }
}

func main() {
    in := Increase()
    fmt.Println(in()) // 1
    fmt.Println(in()) // 2
}

逃逸分析結果

$ go build -gcflags '-m -l' demo.go 
# command-line-arguments
./demo.go:6:2: moved to heap: n
./demo.go:7:9: func literal escapes to heap
./demo.go:15:13: ... argument does not escape
./demo.go:15:16: in() escapes to heap

 根據變量類型是否確定

在上邊例子中,也許你發現了,所有編譯輸出的最後一行中都是 m escapes to heap

奇怪了,爲什麼 m 會逃逸到堆上?

其實就是因爲我們調用了 fmt.Println() 函數,它的定義如下

func Println(a ...interface{}) (n int, err error) {
    return Fprintln(os.Stdout, a...)
}

可見其接收的參數類型是 interface{} ,對於這種編譯期不能確定其參數的具體類型,編譯器會將其分配於堆上。

 根據變量的佔用大小

最開始的時候,就介紹到,以 64KB 爲分界線,我們將內存塊分爲 小內存塊 和 大內存塊。

小內存塊走常規的 mspan 供應鏈申請,而大內存塊則需要直接向 mheap,在堆區申請。

以下的例子來說明

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

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

-gcflags 多加個 -m 可以看到更詳細的逃逸分析的結果

$ go build -gcflags '-m -l' demo.go 
# command-line-arguments
./demo.go:5:15: make([]int, 8191) does not escape
./demo.go:12:15: make([]int, 8192) escapes to heap

那爲什麼是 64 KB 呢?

我只能說是試出來的 (8191 剛好不逃逸,8192 剛好逃逸),網上有很多文章千篇一律的說和  ulimit -a 中的 stack size 有關,但經過了解這個值表示的是系統棧的最大限制是 8192 KB,剛好是 8M。

ulimit -a
-t: cpu time (seconds)              unlimited
-f: file size (blocks)              unlimited
-d: data seg size (kbytes)          unlimited
-s: stack size (kbytes)             8192

我個人實在無法理解這個 8192 (8M) 和 64 KB 是如何對應上的,如果有朋友知道,還請指教一下。

 根據變量長度是否確定

由於逃逸分析是在編譯期就運行的,而不是在運行時運行的。因此避免有一些不定長的變量可能會很大,而在棧上分配內存失敗,Go 會選擇把這些變量統一在堆上申請內存,這是一種可以理解的保險的做法。

func foo() {
    length := 10
    arr := make([]int, 0 ,length)  // 由於容量是變量,因此不確定,因此在堆上申請
}

func bar() {
    arr := make([]int, 0 ,10)  // 由於容量是常量,因此是確定的,因此在棧上申請
}

參考文章


https://xie.infoq.cn/article/ee1d2416d884b229dfe57bbcc

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/8_aeGLEsTItXF3RSpj4T2g