Go 語言內存逃逸之謎

我們在高中學過一些天體物理的知識,比如常見的三個宇宙速度:

瞭解了航天器的逃逸行爲,我們今天來點特別的:內存逃逸。

通過本文你將瞭解到以下內容:

Part1C/C++ 的內存佈局和堆棧

這應該是一道出現頻率極高的面試題。

C/C++ 作爲靜態強類型語言,編譯成二進制文件後,運行時整個程序的內存空間分爲:

內核空間主要存放進程運行時的一些控制信息,用戶空間則是存放程序本身的一些信息,我們來看下用戶空間的佈局:

堆和棧的主要特點:

Go 語言與 C 語言淵源極深,C 語言面臨的問題,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 來說,在日常使用中有幾種常見的做法會導致內存逃逸現象的出現:

指針逃逸

在上一個例子中我們使用一個 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 是非常常見的數據結構,也是非常容易觸發內存逃逸的根源。

Part3 內存逃逸小結

我們該如何評價內存逃逸呢?

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