Go內存逃逸

前言

很久以前就聽過過內存逃逸這個詞, 最近了解了一下, 才發現是個很簡單的概念. 只要把前言部分看完, 就已經瞭解了. 來吧...

在介紹內存逃逸之前, 我們先用C語言來引出這個概念.

我們的進程在內存中有棧內存堆內存的概念, 棧內存是函數執行的局部內存, 會隨着函數的結束而全部釋放, 而堆內存是需要手動申請和管理的一部分內存. 這個概念大家都比較熟悉了, 在此就不再贅述.

c語言版本

C中, 如果我們在函數中想要返回一個整形數組, 怎麼寫呢? 比如這樣?

#include "stdio.h"
int* test(){
    int a[2] = {1, 3};
    return a;
}
int main() {
    int* a= test();
    printf("address: %p, %d", a, a[1]);
}

如果你這樣做了, 可能會發現讀到的數組數據是正確的, 但在使用gcc編譯的時候會報警, 提示返回的a變量是一個棧內存地址. 這是因爲test執行結束後, 這部分內存未來就會被其他地方使用, 結果正確僅僅是因爲內存中的內容還沒有被修改.

那麼正確的寫法應該是什麼呢? 比如這樣:

#include "stdio.h"
#include "stdlib.h"
int* test(){
    int *a = (int*) malloc(2);
    a[0] = 1;
    a[1] = 2;
    return a;
}
int main() {
    int* a = test();
    printf("address: %p, %d", a, a[1]);
    free(a);
}

test函數中申請一段內存, 並將內存的指針返回. 申請的內存就保存在堆內存中. 但是, 這樣一來, 就不能享受棧內存的自動釋放了, 需要再使用後調用free釋放內存, 以便後續使用.

Go版本

那麼在Go中如果我們想在函數中返回一個數組, 怎麼寫呢?

package main
import "fmt"
func test() *[3]int {
	var a [3]int
	a = [3]int{1, 2, 3}
	return &a
}
func main() {
	a := test()
	fmt.Printf("address: %p, %d", a, a[1])
}

這段代碼和上面C版本的功能相同, 都是返回了數組的地址. 那麼問題來了, 爲什麼同樣是局部變量, Go就可以在函數返回之後仍能讀到呢?

原因很簡單, Go的編譯器在檢測到數組指針會在函數外部使用時, 自行將其放到了堆內存中. 而這, 就是Go中所說的內存逃逸現象了. 是不是看過之後感覺只是一個很簡單的道理換了個名詞而已.

其實到這裏, Go的內存逃逸已經介紹完了, 一句話介紹就是: 局部變量被放到了堆內存中.

逃逸情況

因爲內存逃逸後會放到堆內存中, 需要依賴GC進行釋放, 而棧內存會自動釋放, 無需GC參與. 因此在開發中減少內存逃逸, 可以減輕GC壓力.

既如此, 有沒有辦法在一個Go程序中檢查哪裏會發生內存逃逸呢? (逃逸是發生在編譯期的呦). 就是build命令:

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

  • -m: 打印逃逸分析內容. 最多的添加4個-m, 獲取更詳細的信息
    • all=-m: 若編譯時不止一個文件, 對所有文件應用-m
  • -l: 禁用函數內聯. 可以更準確的定位逃逸位置.
    • all=-l: 同理

好, 基於此, 我們簡單介紹幾種內存逃逸的情況, 更多的情況可自行摸索. (以下所有情況, 可自行通過build命令分析查看)

返回局部變量指針

比如前言中的情況, 再或者:

func test() *int {
	a := 5
	return &a
}

超出棧大小

若對象在棧中放不下了, 也會發生逃逸. 棧的大小可通過命令查看: ulimit -a | grep stack

func test() {
	// 當內存申請超出棧大小時, 逃逸
	_ = make([]int, 8192*1024/8)
	// 當使用變量進行初始化時, 因爲無法預知變量的大小, 也會逃逸
  // 如果可以的話, 將 n 改爲 const, 就可以避免內存逃逸
	n := 2
	_ = make([]int, n)
}

閉包

閉包也很好理解, 因爲變量在函數返回之後仍需要訪問, 因此需要逃逸到堆上.

func test() func() int {
	a := 0
	return func() int {
		return a
	}
}

fmt 包

當使用fmt包中的大部分函數時, 均會發生內存逃逸. 相關isuse: 8618 7218

func main() {
	// 沒有發生內存逃逸
	_ = reflect.TypeOf("1")
	// string kind 等發發會發生內存逃逸
	_ = reflect.TypeOf("1").String()
	_ = reflect.TypeOf("1").Kind()
	// 會發生內存逃逸, 因爲其內部調用了 reflect.TypeOf("223").String()
	// 調用鏈: Println->Fprintln->doPrintln->printArg->reflect.TypeOf(arg).String()
	fmt.Println("223")
}

具體原因未做分析, 感興趣的可以查看其內部實現. 期待後續版本可以優化吧.

其他情況

  • 切片擴容後棧空間不足
  • channel發送指針變量. stackoverflow
  • 等等

總結

綜上, 介紹了內存逃逸的概念及常見情況. 當發生逃逸的時候, 會增加GC的壓力. 變量放在哪裏簡單來說就是:

  1. 若在函數外部使用了, 則必放在堆中
  2. 若在函數外部沒有使用, 則優先放到棧中, 若棧中放不下, 則放到堆中

那麼我們在函數返回結構體使經常碰到的疑問: 返回"值類型"還是"指針類型"??

如果返回"值類型"就不會發生逃逸, 但是會觸發內存複製. 如果返回"指針類型"就無需內存複製, 但是會發生逃逸. 因此就需要在GC與內存複製之間進行平衡, 判斷哪個開銷比較大. 一般來說, 若變量佔用內存較小, 傳值更爲合適. 若內存較大, 則傳遞指針更爲合適. (不過, 一般的項目都沒有到"需要考慮 GC"的情況吧???)


原文地址: https://hujingnb.com/archives/884

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章