深入理解GO語言之記憶體詳解

奇犽發表於2017-10-28

一,前言

深入學習golang,必須要了解記憶體這塊,這次會仔細講解下記憶體這塊,包括記憶體分配,記憶體模型,逃逸分析。讓我們在程式設計中能注意下這塊。

二,記憶體分配

(1) 這裡先了解四個相關資料結構

1,mspan

通過next和prev,組成一個雙向連結串列,mspan負責管理從startAddr開始的N個page的地址空間。是基本的記憶體分配單位。是一個管理記憶體的基本單位。

//保留重要成員變數
type mspan struct {
	next *mspan     // 連結串列中下個span
	prev *mspan     // 連結串列中上個span	startAddr uintptr // 該mspan的起始地址
	freeindex uintptr // 表示分配到第幾個塊
	npages    uintptr // 一個span中含有幾頁
	sweepgen    uint32 // GC相關
	incache     bool       // 是否被mcache佔用
	spanclass   spanClass  // 0 ~ _NumSizeClasses之間的一個值,比如,為3,那麼這個mspan被分割成32byte的塊
}
複製程式碼

2,mcache

在go中,每個P都會被分配一個mcache,是私有的,從這裡分配記憶體不需要加鎖

type mcache struct {
	tiny             uintptr // 小物件分配器
	tinyoffset       uintptr // 小物件分配偏移
	local_tinyallocs uintptr // number of tiny allocs not counted in other stats
	alloc [numSpanClasses]*mspan // 儲存不同級別的mspan
}
複製程式碼

3,mcentral

當mcache不夠時候,會向mcentral申請記憶體。該結構實際上是在mheap中的,所以在我看來,這起到橋樑的作用。

type mcentral struct {
	lock      mutex    // 多個P會訪問,需要加鎖
	spanclass spanClass  // 對應了mspan中的spanclass
	nonempty  mSpanList // 該mcentral可用的mspan列表
	empty     mSpanList // 該mcentral中已經被使用的mspan列表
}
複製程式碼

4,mheap

mheap是真實擁有虛擬地址的,當mcentral不夠時候,會向mheap申請。

type mheap struct {
	lock      mutex                    // 是公有的,需要加鎖
	free      [_MaxMHeapList]mSpanList // 未分配的spanlist,比如free[3]是由包含3個 page 的 mspan 組成的連結串列	
	freelarge mTreap                   // mspan組成的連結串列,每個mspan的 page 個數大於_MaxMHeapList
	busy      [_MaxMHeapList]mSpanList // busy lists of large spans of given length
	busylarge mSpanList                // busy lists of large spans length >= _MaxMHeapList
	allspans []*mspan                  // 所有申請過的 mspan 都會記錄在 allspans
	spans []*mspan                     // 記錄 arena 區域頁號(page number)和 mspan 的對映關係

	arena_start uintptr // arena是Golang中用於分配記憶體的連續虛擬地址區域,這是該區域開始的指標
	arena_used  uintptr // 已經使用的記憶體的指標
	arena_alloc uintptr
	arena_end   uintptr

	central [numSpanClasses]struct {
		mcentral mcentral
		pad      [sys.CacheLineSize - unsafe.Sizeof(mcentral{})%sys.CacheLineSize]byte //避免偽共享(false sharing)問題
	}
	
	spanalloc             fixalloc // allocator for span*
	cachealloc            fixalloc // mcache分配器

}

複製程式碼

接下來請細看下圖,結合前面的講解進行理解(務必看懂)。

深入理解GO語言之記憶體詳解

(2) 記憶體分配細節

這裡不展開原始碼,知道分配規則即可。(在golang1.10,MacOs 10.12中,下面的32K改為64K)

1, object size > 32K;則使用 mheap 直接分配。

2,object size < 16 byte;則使用 mcache 的小物件分配器 tiny 直接分配。

3,object size > 16 byte && size <= 32K byte時,先在mcache申請分配。如果 mcache對應的已經沒有可用的塊,則向mcentral請求,如果mcentral也沒有可用的塊,則向mheap申請,如果 mheap 也沒有合適的span,則向作業系統申請。

三,記憶體模型

這裡說下在golang中的happen-before(假設A和B表示一個多執行緒的程式執行的兩個操作。如果A happens-before B,那麼A操作對記憶體的影響將對執行B的執行緒(且執行B之前)可見)

(1) Init 函式

1, P1中匯入了包P2,則P2中的init函式Happens BeforeP1中所有的操作

2, 所有的init函式Happens Before Main函式

(2) Channel

1, 對一個元素的send操作Happens Before對應的receive操作

2, 對channel的close操作Happens Before receive端的收到關閉通知操作

3, 對於無快取的Channel,對一個元素的receive 操作Happens Before對應的send完成操作

4, 對於帶快取的Channel,假設Channel 的buffer 大小為C,那麼對第k個元素的receive操作,Happens Before第k+C個send完成操作。 。

四,逃逸分析

為什麼要做逃逸分析呢,因為在棧上分配的代價要遠小於在堆上進行分配,這塊是目前很多人缺乏的一個思維,包括我。最近看了一些這方面的文章,再回去看自己的程式碼,發現很多不合理的地方,希望通過這次講解,能一起進步。

(1) 什麼是記憶體逃逸

簡單來說就是原本應在棧上分配記憶體的物件,逃逸到了堆上進行分配。如果能在棧上進行分配,那麼只需要兩個指令,入棧和出棧,GC壓力也小了。所以相比之下,在棧上分配代價會小很多。

(2) 引起逃逸的情況

個人總結了一下,如果無法在編譯期確定變數的作用域和佔用記憶體大小,則會逃逸到堆上。

1,指標

我們平時會知道,傳遞指標可以減少底層值的拷貝,可以提高效率,在一般情況下是如此,但是如果拷貝的是少量的資料,那麼傳遞指標效率不一定會高於值拷貝。

(1) 指標是間接訪址,所指向的地址大多儲存在堆上,因此考慮到GC,指標不一定是高效的。看個例子

type test struct{}

func main() {
	t1 := test1()
	t2 := test2()
	println("t1", &t1, "t2", &t2)
}

func test1() test {
	t1 := test{}
	println("t1", &t1)
	return t1
}

func test2() *test {
	t2 := test{}
	println("t2", &t2)
	return &t2
}
複製程式碼

執行檢視逃逸情況(禁止內聯)

go run -gcflags '-m -l' main.go
# command-line-arguments
./main.go:36:16: test1 &t1 does not escape
./main.go:43:9: &t2 escapes to heap
./main.go:41:2: moved to heap: t2
./main.go:42:16: test2 &t2 does not escape
./main.go:31:16: main &t1 does not escape
./main.go:31:27: main &t2 does not escape
t1 0xc420049f50
t2 0x10c1648
t1 0xc420049f70 t2 0xc420049f70

複製程式碼

從上面可以看出,返回指標的test2函式中的t2逃逸到堆上,等待它的將是殘忍的GC。

2,切片

如果編譯期無法確定切片的大小或者切片大小過大,超出棧大小限制,或者在append時候會導致重新分配記憶體,這時候很可能會分配到堆上。

// 切片超過棧大小
func main(){
	s := make([]byte, 1, 64 * 1024)
	_ = s
}

// 無法確定切片大小
func main() {
	s := make([]byte, 1, rand2.Intn(10))
	_ = s
}
複製程式碼

看完上述的例子,我們來看個有意思的例子。我們知道,切片比陣列高效,但是,確實是如此嗎?

func array() [1000]int {
	var x [1000]int
	for i := 0; i < len(x); i++ {
		x[i] = i
	}
	return x
}

func slice() []int {
	x := make([]int, 1000)
	for i := 0; i < len(x); i++ {
		x[i] = i
	}
	return x
}

func BenchmarkArray(b *testing.B) {
	for i := 0; i < b.N; i++ {
		array()
	}
}

func BenchmarkSlice(b *testing.B) {
	for i := 0; i < b.N; i++ {
		slice()
	}
}
複製程式碼

執行結果如下

go test -bench . -benchmem -gcflags "-N -l -m"
BenchmarkArray-4        30000000                52.8 ns/op             0 B/op          0 allocs/op
BenchmarkSlice-4        20000000                82.4 ns/op           160 B/op          1 allocs/op
複製程式碼

可見,我們不一定是一定要用切片代替陣列,因為切片底層陣列可能會在堆上分配記憶體,而且小陣列在棧上拷貝的消耗也未必比切片大。

3,interface

interface是我們在go中經常會用到的特性,非常好用,但是由於interface型別在編譯期間,編譯期很難確定其具體型別,因此也導致了逃逸現象。舉個最簡單的例子

func main() {
	s := "abc"
	fmt.Println(s)
}
複製程式碼

上述程式碼會產生逃逸,原因是fmt.Println這個方法接收的引數是interface型別。但是這塊只是作為科普,畢竟interface帶來的好處要大於它這個缺陷

五,參考文獻

segment.com/blog/alloca…

相關文章