Go內存模型

鏈客,專爲開發者而生,有問必答!linux

此文章來自區塊鏈技術社區,未經容許拒絕轉載。
圖片描述程序員

Go的內存管理話題很大,一邊學習,一邊記錄,持續更新。算法

提綱挈領
和C、C++不一樣,通常來講,Go程序員無需關心變量是在堆上仍是在棧上。數組

Go語言中內存分配大體有3種模式:Stack、Heap、Fixed Size Segment。緩存


棧的概念相似傳統Linux下C、C++的概念,即經過棧頂指針移動來分配內存。通常用來存儲局部變量、函數參數等。每一個Goroutine都有本身的執行棧,互相獨立,無需加鎖。Goroutine的棧是從Heap中分配的,若是運行中棧須要擴展,會使用連續棧技術進行擴展(經過Heap的操做---- allocate new,copy old to new,free old)。性能優化

Goroutine中的棧很小,初始只有2K,所以建立代價很小。併發

堆和GC
Go語言支持GC,針對堆中的對象。所以,它在分配內存時須要着重考慮以下兩個問題:app

如何平衡內存分配效率和內存利用率?
如何支持快速的GC迭代,不對業務形成較大沖擊?
同時,因爲堆是全部Goroutine共有的,所以須要加鎖,後面詳解中能夠觀察下它是如何優化這個問題的。less

具體到實現上,Go採用了相似tmalloc的作法,在系統調用上封裝了一層,減小直接系統調用的性能損耗;同時,會按期掃描釋放長時間不使用的空閒內存。具體實現技巧,詳見下文。ide

垃圾回收掃描會STW,從Go1.5起,不會超過執行時間的1/5(eg. 10ms of 50ms execution)。回收時只掃描堆上的對象,但若是對象在棧上有引用,也會分析棧上對應變量,具體以下文。

The garbage collector has to be aware of both heap and stack allocated items. This is easy to see if you consider a heap allocated item, H, referenced by a stack allocated item, S. Clearly, the garbage collector cannot free H until S is freed and so the garbage collector must be aware of lifetime of S, the stack allocated item.

固定大小對象分配
顧名思義,固定大小內存分配器。通常用做,內存管理對象的分配和回收,如mspan、mcache、mcentral等;另外也被用來分配data segment和code segment。在運行期內,data segment的大小不能變化,所以,動態內存對象不能在data segment內分配。

Fixed sized segments are defined at compile time and do not change size at runtime. Read-write fixed size segments (e.g., the data segment) contain global variables while read-only segments (e.g., code segment and rodata segment) contain constant values and instructions.

性能優化
使用runtime/pprof和go tool pprof採樣分析,若是growslices和newobject調用佔用不少,便可考慮內存方面的優化 ---- 減小堆上分配(變量逃逸),增長棧上分配。

Reuse memory you’ve already allocated.
Restructure your code so the compiler can make stack allocations instead of heap allocations. Use go tool compile -m to help you identify escaped variables that will be heap allocated and then rewrite your code so that they can be stack allocated.
Restructure your CPU bound code to pre-allocate memory in a few big chunks rather than continuously allocating small chunks.


Goroutine的棧管理模式和線程棧相似,但實現差別巨大。

棧的內存分配
Goroutine的棧是做爲一個object,由內存管理器管理的span上分配的。當對象在棧上分配時,因爲棧內存已存在,僅僅移動寄存器和棧頂指針便可,性能天然高不少。對GC而言,整個棧內存被視做單一的對象。整體數量少了,壓力天然減輕。但對象在棧仍是堆中,是由編譯器決定的。較小的棧是從fixed-size的freelist進行分配的(32k如下),更大的棧從空閒的span分配。

所謂的fixed-size,指固定大小棧的數組,如linux下爲2k、4k、8k、16k這樣的棧,fixed-order=4

fixed-size stack
32k如下的棧,有兩種分配方式:

從全局的stackpool分配(加鎖),計算對應的fixed-order,從鏈表中獲取,若是失敗,從heap中分配一個手動管理的span,並串成span鏈表。從span中獲取一個manualFreeList的對象返回。
從線程M私有的stackcache進行分配,計算對應的fixed-order,從鏈表中獲取,並更新統計。
stackpool和stackcache相似,也是fixed-size的數組,元素是雙向鏈表。
stackcache是M的mcache中的成員,線程私有,無需加鎖。和alloc同級別(堆中的概念,詳見下文),也是個數組[fixed-order],每一個元素是個單向鏈表。

更大的棧直接使用span塊。從stackLarge中分配,stackLarge是一個全局的大span的緩存池(使用加鎖,數組,每一個元素是一個雙向鏈表),若是stackLarge.free[npages]爲空,則從heap中分配一個手動管理的span(確定是goroutine釋放時,執行了stack的free操做放入pool或者stackcache中)。

棧增加難題
在C語言中,啓動一個線程,標準庫(the standard lib)會負責分配一塊內存(默認8M)給線程看成棧來使用。標準庫會分配一個內存塊,而後告訴內核開始執行代碼。假設進程要執行一個高度遞歸的函數,耗盡了棧的空間,這時怎麼辦呢?

修改系統進程棧大小,統一調大(設爲16M)。這樣會浪費內存空間,即便其餘進程不須要那麼多的棧空間,依然會在啓動進程時分配。
精確計算每一個進程的棧空間大小,這樣過於繁瑣,對開發人員很不友好。
那麼Go裏面,是如何處理這個問題的呢?Go嘗試給每一個Goroutine按需分配棧空間。在Goroutine建立時,會初始分配2K內存用做棧空間。當檢測到棧空間不夠時,會調用morestack增加棧空間。

Go中如何檢測Goroutine棧空間耗盡呢?
Go中每一個函數在執行前,會先檢查是否已經用盡了棧空間(runtime包裝了代碼邏輯,無需開發者關注)

分離棧
在Go1.5以前,採用分離棧Segmented stacks技術解決這個問題。顧名思義,在Goroutine執行中發現棧空間不夠時,會從新分配一塊內存做爲這個Goroutine的延續棧,用指針串聯,無需虛擬地址空間上相鄰。當執行函數迴歸後,Goroutine不須要這麼多棧空間了,會將以前分配的棧釋放。

分離棧示意圖

+---------------+
| |
| unused |
| stack |
| space |
+---------------+
| Foobar |
| |
+---------------+
| |
| lessstack |
+---------------+
| Stack info |
| |-----+
+---------------+ |

|
                    |

+---------------+ |
| Foobar | |
| | <---+
+---------------+
| rest of stack |
| |
上圖是執行Foobar函數耗盡棧空間後,調用morestack造成的新的棧空間。上面的是新分配的棧stack1,下面的是被耗盡的棧stack。stack1底部stack info是stack的相關信息。在stack1分配後,在stack1上從新執行Foobar函數,執行完後,陷入lessstack邏輯。lessstack經過stack info找到原始返回地址,返回舊棧stack,並釋放stack1。

Hot Split
可是分離棧有個瑕疵 ---- hot split,若是剛好有一些Goroutine在這個臨界點反覆執行(如for loop),那麼會形成反覆的morestack、lessstack、morestack、lessstack操做(Shrinking stack is a relatively expensive op to runtime),影響服務性能。爲了解決這個問題,從Go1.5以後,改成採用連續棧技術來處理棧增加的問題。

連續棧
連續棧(stack copying),Goroutine初始棧大小和棧耗盡檢測邏輯不變。連續棧會新分配一個size = stack*2的新棧stack1,同時把stack的數據拷貝到stack1。這樣,後續棧縮減僅僅是一個free操做,對runtime來講,無需作任何事,即便縮減後再次要擴充,runtime也無需作任何操做,能夠複用以前分配的空間。

棧拷貝的細節
主要是棧裏面若是有指針,那麼copy後,指針的地址也要相應改變。

棧裏面的對象指可能被棧裏面的指針關聯,不可能被堆上的指針關聯(變量逃逸)。

GC時,已經知道了棧上哪些變量時指針,所以遷移棧時,更新棧裏面對應的指針指向新的棧裏面的target便可。對於不能肯定指針變量的棧(主要是runtime中不少C代碼,沒法進行GC,確認指針信息),回退到分離棧。 ---- 這也是爲何如今在用Go重寫runtime邏輯的緣由。同時,因爲棧上指針信息已知,也爲後續進行併發GC提供了可能。

堆內存分配與回收詳解
三級管理組件:Cache(M私有)、Central、Heap。

image.png

image.png
Heap是全局的,全部Goroutine共享,管理的單位是span。span是n個頁的內存塊,free是個128個元素的數組,每一個元素是對應頁數的span鏈表,如free65表示65個頁的span鏈表碼,超過127頁以上的span位於freelarge中。對象分配是以字節數爲單位的。Heap初始化時,初始化了以下規格表,共67種,分配完span後,會把對應頁數的span切分爲對應objsize的小對象,掛在span.freelist上。Central數組用於管理每一種objsize對應的span鏈表。

image.png
Mcentral是中間管理層,提升複用效率。包含兩個指針,分別掛載對應objsize的empty span和nonempty span。當cache中內存不足時,向central請求分配,須要加鎖。

Cache是M獨有的,爲了減小共享鎖的損耗,每一個線程一個Cache。分配內存時優先從當前M的cache中去獲取。alloc爲67種objsize對應的span指針數組。

組件內存如Span結構、Cache結構都是從FixAlloc中分配的,圖中的spanalloc負責span對象的分配,cachealloc負責cache對象的分配。

初始化
全部的空間分配都是虛擬地址空間。64bit機器目前低48位可用於尋址,所以地址空間爲256TB。Heap在初始化時,首先嚐試從固定地址佔用以下地址:

image.png
其中,spans爲256M,bitmap爲32G,arena爲512G。

arena是用戶內存對象分配區域,內存擴展,即移動arena.used的標記位。

bitmap爲每一個對象提供4bit的標記爲,用以保存指針、GC標記等信息。

spans TODO。

arena、bitmap、spans是同步擴張的,意即TODO。

分配
假設Goroutine須要從分配14byte的動態對象,

roundup 到合適的objsize,此處爲16byte。
找到g當前的m,從m的cache的alloc中嘗試分配,從規格表可知應尋找alloc2上的span,從span.freelist中提取可用object。
若是span.freelist爲空,從heap對應的central2獲取新的span。若是central2.nonempty非空,則返回central2.nonempty上的span。 加鎖
若是central.nonempty爲空,從heap.free中提取,從規格表可知,應該找size=1的span,即free1,提取後切分爲16byte的obj鏈表,返回。 加鎖
若是heap1爲空,繼續查找heap2.....,若是heap的free、freelarge都爲空,則向操做系統申請新內存塊(最少位1MB),分配後切分爲span。 ---- 檢查arena.used+size < arena.end,經過後,調用mmap(FIXED)從arena.used開始分配內存。使用線程私有的M,減小鎖發生的機率,提升效率。
以上討論的都是常規對象的分配,另有若是是大對象,會直接到heap上去獲取span,tinyobj會有對應的優化措施,複用16bit的內存塊,利用偏移量記錄分配位置等。

結合GC,這裏須要考慮central中獲取可用span時,span可能正在被sweep。對應的邏輯,若是span要執行sweep會先執行sweep,再獲取span。參見源碼。

回收
內存回收並不意味着釋放,也會考慮複用。內存管理器的目的是要在內存使用率和內存分配效率之間作平衡。

內存回收的單位不是obj,而是span。經過掃描bitmap中對象的標記位,逐步將obj收歸span,上交給central或者heap.free複用。

遍歷span,將可回收的obj合併到freelist,若是所有obj都被回收了,則嘗試從central交還給heap複用。交還給heap時,會檢查左右是否可合併,合併爲更大的span一塊兒放到heap.free中。

image.png
釋放
運行時入口函數main.main中啓動了一個sysmon的goroutine,每一個一段時間,會檢查heap裏面的閒置內存塊。若是超過了閒置時間,則釋放其關聯的物理內存。這個釋放,並非真正的釋放,而是經過madvise告知os,建議內核回收該虛地址對應的物理內存。內核收到建議後,若是內存充足,就會被忽略,避免性能損耗。而當再次使用該虛擬地址內存塊時,內核會捕捉到缺頁,從新關聯對應物理頁。

釋放,並不釋放虛擬內存地址,所以虛地址不會造成空洞,這個地址空間依然可被訪問,與之相反mmap。

GC算法詳解
GC算法優化的目的是儘量減小GC致使的STW對用戶邏輯的影響。Go中GC的基本特徵是: 非分代、非緊縮、寫屏障、併發標記清理。此處的併發是指,GC thread和mutator thread(用戶線程)併發執行。相關代碼位於: runtime/mgc.go,詳細說明也可參考代碼頭文件說明。

併發會帶來不少問題,例如mutator thread可能隨時修改已經被GC掃描過的區域;標記過程當中還不斷新分配對象。

核心問題:抑制堆增加、充分利用CPU資源。

三色標記和寫屏障
Mark
三色標記是GC標記和mutator thread併發執行的基本保障。基本原理:

起始全部對象都是白色。
掃描出全部可達對象,標記位灰色,放入待處理隊列。
從隊列提取灰色對象,將其引用對象標記爲灰色放入隊列,自身標記爲黑色。
寫屏障監視對象內存修改,從新標色或放入隊列。
Sweep
當完成所有標記掃描工做後,剩餘的不是黑色就是白色,分別表明活躍對象和待回收對象,清理操做將白色對象內存回收便可。

輔助回收
若是對象分配速度高於GC後臺標記速度,會形成一系列嚴重的後果,例如堆惡性擴張,甚至讓GC永遠沒法完成。
所以,讓mutator thread在爲堆對象分配內存時適當參與GC後臺標記就很是有必要,具體可參見堆對象分配中的一些源碼邏輯。

控制器
控制器會記錄GC過程當中的一些狀態信息,並根據當前GC信息(經過反饋)動態調整下一次GC的相關策略(例如肯定下一次GC執行的時間),平衡CPU資源佔用。

變量逃逸
簡單來講,編譯器會自動選擇變量應該在stack仍是在heap中分配,並不受var聲明仍是new聲明影響(和CC++不一樣)。通常來講,

編譯器經過逃逸分析來決定一個對象放在棧上仍是堆上,逃逸的對象放在堆上,不逃逸的對象放在棧上。
若是一個變量很大,那麼有可能被分配到棧上。
type struct T { xxx}
func f() *T {

var ret T
return &ret // ret從func f()中逃逸了,在堆中分配

}

func g() {

y := new(int)
*y = 1 // y雖然用了new,可是隻在g()做用域生效,在棧上分配

}
參考Go的FAQ,僅從正確性的角度出發,使用者無需關心變量是在stack仍是在heap中,Go會保證,若是變量還可能被訪問(經過地址),那就不會失效。

How do I know whether a variable is allocated on the heap or the stack?

From a correctness standpoint, you don't need to know. Each variable in Go exists as long as there are references to it. The storage location chosen by the implementation is irrelevant to the semantics of the language.

The storage location does have an effect on writing efficient programs. When possible, the Go compilers will allocate variables that are local to a function in that function's stack frame. However, if the compiler cannot prove that the variable is not referenced after the function returns, then the compiler must allocate the variable on the garbage-collected heap to avoid dangling pointer errors. Also, if a local variable is very large, it might make more sense to store it on the heap rather than the stack.

In the current compilers, if a variable has its address taken, that variable is a candidate for allocation on the heap. However, a basic escape analysis recognizes some cases when such variables will not live past the return from the function and can reside on the stack.

Yet if you are so determined to find out where actually Go allocates the variables, here is a quick trick that you can use, suggeseted here.

If you need to know where your variables are allocated pass the "-m" gc flag to "go build" or "go run" (e.g., go run -gcflags -m app.go).

具體示例可參考 下面附錄中1,2項。

參考文章
[1]Go的變量到底在堆仍是棧中分配

[2]Golang變量逃逸分析小探

[3]The Go Programming Language Report

[4]50 Shades of Go: Traps, Gotchas, and Common Mistakes for New Golang Devs

[5]Confused about Stack and Heap?

[6]Go Memory Management

[7]Memory Management in Go

[8]How Stacks are Handled in Go

[9]Go學習筆記

相關文章
相關標籤/搜索