Go 語言內存逃逸之謎
我們在高中學過一些天體物理的知識,比如常見的三個宇宙速度:
-
第一宇宙速度:航天器逃離地面圍繞地球做圓周運動的最小速度:7.9km/s
-
第二宇宙速度:航天器逃離地球的最小速度:11.18km/s
-
第三宇宙速度:航天器逃離太陽系的最小速度:16.64km/s
瞭解了航天器的逃逸行爲,我們今天來點特別的:內存逃逸。
通過本文你將瞭解到以下內容:
-
C/C++ 的內存佈局和堆棧
-
Go 的內存逃逸和逃逸分析
-
內存逃逸的小結
Part1C/C++ 的內存佈局和堆棧
這應該是一道出現頻率極高的面試題。
C/C++ 作爲靜態強類型語言,編譯成二進制文件後,運行時整個程序的內存空間分爲:
-
內核空間 Kernel Space
-
用戶空間 User Space
內核空間主要存放進程運行時的一些控制信息,用戶空間則是存放程序本身的一些信息,我們來看下用戶空間的佈局:
堆和棧的主要特點:
-
棧區 (Stack):由編譯器自動分配釋放,存儲函數的參數值,局部變量值等,但是空間一般較小數 KB~ 數 MB
-
堆區 (Heap):C/C++ 沒有 GC 機制,堆內存一般由程序員申請和釋放,空間較大,能否用好取決於使用者的水平
Go 語言與 C 語言淵源極深,C 語言面臨的問題,Go 同樣會面對,比如:變量的內存分配問題。
-
在 C 語言中,需要程序員自己根據需要來確定採用堆還是棧,棧內存由 OS 全權負責,但是堆內存需要顯式調用 malloc/new 等函數申請,並且對應調用 free/delete 來釋放。
-
Go 語言具有垃圾回收 Garbage Collection 機制來進行堆內存管理,並且沒有像 malloc/new 這種堆內存分配的關鍵字。
-
棧內存的分配和釋放開銷非常小,堆內存對於 Go 來說開銷比棧內存大很多。
Part2Go 的內存逃逸和逃逸分析
如果寫過 C/C++ 都會知道,在函數內部聲明局部變量,然後返回其指針,如果外部調用則會報錯:
#include <iostream>
using namespace std;
int* getValue()
{
int val = 10086;
return &val;
}
int main()
{
cout<<*getValue()<<endl;
return 0;
}
編譯上述代碼:main.cpp: In function ‘int* getValue()’: main.cpp:7:9: warning: address of local variable ‘val’ returned [-Wreturn-local-addr]
用同樣的思想,寫一個 go 版本的代碼:
package main
import (
"fmt"
)
func main() {
str := GetString()
fmt.Println(*str)
}
func GetString() *string {
var s string
s = "hello world"
return &s
}
代碼卻可以正常運行,我們本意是在棧上分配一個變量,用完就銷燬,但是外部卻調用了,甚至可以正常進行,表現和 C++ 完全不同。
其實,這就是 Go 的內存逃逸現象,Go 模糊了棧內存和堆內存的界限,具體來說變量究竟分配到哪裏,是由編譯器來決定的。
1 逃逸分析 escape analysis
所謂逃逸分析就是在編譯階段由編譯器根據變量的類型、外部使用情況等因素來判定是分配到堆還是棧,從而替代人工處理。
一般將局部變量和參數分配到棧上,但是並不絕對:
-
如果編譯器不能確定在函數返回時,變量是否被使用則分配到堆上
-
如果局部變量非常大,也會分配到堆上
-
......
編譯器不清楚局部變量是否會被外部使用時,就會傾向於分配到堆上。
Go 編譯器在確定函數返回後不會再被引用時才分配到棧上,其他情況下都是分配到堆上。
這樣做雖然浪費堆空間,但是有效避免了懸掛指針的出現,並且由於 GC 的存在也不會出現內存泄漏,權衡之下也是一種合理的做法。
2 哪些情況會出現內存逃逸
對於 Go 來說,在日常使用中有幾種常見的做法會導致內存逃逸現象的出現:
-
指針逃逸
-
棧空間不足逃逸
-
map/slice/interface/channel 的使用
-
......
指針逃逸
在上一個例子中我們使用一個 int 指針來說明內存逃逸的現象,接下來我們擴展一下變爲結構體指針,並且使用 gcflags 來給編譯器傳特定參數來觀察逃逸現象:
// test.go
package main
import "fmt"
type Escape struct {
who string
}
func CallInstance(caller string) (*Escape) {
instance := new(Escape)
instance.who = caller
return instance
}
func main() {
outer := CallInstance("hello world")
fmt.Println(outer.who)
}
執行:go build -gcflags=-m test.go 如下:
# command-line-arguments
./test.go:9:6: can inline CallInstance
./test.go:16:23: inlining call to CallInstance
./test.go:17:13: inlining call to fmt.Println
./test.go:9:19: leaking param: caller
./test.go:10:17: new(Escape) escapes to heap
./test.go:16:23: main new(Escape) does not escape
./test.go:17:19: outer.who escapes to heap
./test.go:17:13: main []interface {} literal does not escape
./test.go:17:13: io.Writer(os.Stdout) escapes to heap
<autogenerated>:1: (*File).close .this does not escape
我們可以看到 "escapes to heap",確實出現了內存逃逸,本該在棧上逃逸到堆上了。
棧空間不足逃逸
對於 64bit 的 Linux 系統而言棧的大小一般是 8MB,Go 中每個 goroutine 初始化棧大小是 2KB,在 goroutine 的運行過程中棧的大小可能會變化,但也不會超過 OS 對線程棧大小的限制。
在網上找了個例子,用 mac 跑了一下:
package main
import "math/rand"
func generate8191() {
nums := make([]int, 8191) // < 64KB
for i := 0; i < 8191; i++ {
nums[i] = rand.Int()
}
}
func generate8192() {
nums := make([]int, 8192) // = 64KB
for i := 0; i < 8192; i++ {
nums[i] = rand.Int()
}
}
func generate(n int) {
nums := make([]int, n) // 不確定大小
for i := 0; i < n; i++ {
nums[i] = rand.Int()
}
}
func main() {
generate8191()
generate8192()
generate(1)
}
# command-line-arguments
./test_3.go:6:14: generate8191 make([]int, 8191) does not escape
./test_3.go:13:14: make([]int, 8192) escapes to heap
./test_3.go:20:14: make([]int, n) escapes to heap
可以看到在分配 8191 個大小時未發生逃逸,在分配 8192 時發生了逃逸,不定長度也發生了逃逸。
其他情況
在 go 中 map、interface、slice、interface 是非常常見的數據結構,也是非常容易觸發內存逃逸的根源。
-
向 channel 中發送指針或者帶指針的值,因爲在編譯時沒有辦法知道哪個 goroutine 會在 channel 上接收數據。所以編譯器沒法知道變量什麼時候纔會被釋放。
-
slice 中指針或帶指針的值,這會導致切片的內容逃逸,儘管其後面的數組可能是在棧上分配的,但其引用的值一定是在堆上。
-
slice 數組擴容也可能導致內存逃逸,如果切片背後的存儲要基於運行時的數據進行擴充,就會在堆上分配。
-
interface 類型可以代表任意類型,編譯器不知道參數會是什麼類型,只有運行時才知道,因此只能分配到堆上。
Part3 內存逃逸小結
我們該如何評價內存逃逸呢?
-
Go 語言對用戶來說模糊了堆內存和棧內存的分配,編譯器藉助於逃逸分析來實現特定場景的內存逃逸。
-
任何事情都是兩面性,Go 語言藉助於內存逃逸和 GC 機制解放了程序員,但是同時也帶來了性能問題,因爲堆內存的分配和釋放都是需要成本的。
-
Go 的編譯器在很多時候無法確定該如何分配內存,因此只能採用一種穩妥但有失性能的做法,分配到堆上。
-
意識裏指針傳遞比值傳遞更高效,但是在 Go 中並非如此,如果指針傳遞出現內存逃逸將內存分配到堆上後續就有會 GC 操作,消耗比值傳遞更大。
-
如果明確不需要外部使用,就需要儘量避免內存逃逸,不要一味完全依賴編譯器本身。
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/Jd7rKYm7d_yOEyXn3gKb6A