GO中的GC


go中的垃圾回收

前言

對於go中的垃圾回收,總是不太熟悉。來具體分析下,具體的流程。本次探究的go版本go version go1.13.15 darwin/amd64

垃圾回收

垃圾回收(Garbage Collection,簡稱GC)是編程語言中提供的自動的內存管理機制,自動釋放不需要的對象,讓出存儲器資源,無需程序員手動執行。

當程序向操作系統申請的內存不再需要時,垃圾回收主動將其回收並供其他代碼進行內存申請時候復用,或者將其歸還給操作系統,這種針對內存級別資源的自動回收過程,即為垃圾回收。而負責垃圾回收的程序組件,即為垃圾回收器。

go中的垃圾回收方式

所有的 GC 算法其存在形式可以歸結為追蹤(Tracing)和引用計數(Reference Counting)這兩種形式的混合運用。

  • 追蹤式 GC

從根對象出發,根據對象之間的引用信息,一步步推進直到掃描完畢整個堆並確定需要保留的對象,從而回收所有可回收的對象。Go、 Java、V8 對 JavaScript 的實現等均為追蹤式 GC。

  • 引用計數式 GC

每個對象自身包含一個被引用的計數器,當計數器歸零時自動得到回收。因為此方法缺陷較多,在追求高性能時通常不被應用。Python、Objective-C 等均為引用計數式 GC。

目前比較常見的 GC 實現方式包括:

追蹤式

  • 標記清掃:從根對象出發,將確定存活的對象進行標記,並清掃可以回收的對象;
  • 標記整理:為了解決內存碎片問題而提出,在標記過程中,將對象盡可能整理到一塊連續的內存上;
  • 增量式:將標記與清掃的過程分批執行,每次執行很小的部分,從而增量的推進垃圾回收,達到近似實時、幾乎無停頓的目的;
  • 增量整理:在增量式的基礎上,增加對對象的整理過程;
  • 分代式:將對象根據存活時間的長短進行分類,存活時間小於某個值的為年輕代,存活時間大於某個值的為老年代,永遠不會參與回收的對象為永久代。並根據分代假設(如果一個對象存活時間不長則傾向於被回收,如果一個對象已經存活很長時間則傾向於存活更長時間)對對象進行回收;

引用計數

  • 引用計數:根據對象自身的引用計數來回收,當引用計數歸零時立即回收;

go中目前使用的是無分代(對象沒有代際之分)、不整理(回收過程中不對對象進行移動與整理)、並發(與用戶代碼並發執行)的三色標記清掃算法。

原因:

1、對象整理的優勢是解決內存碎片問題以及“允許”使用順序內存分配器。但 Go 運行時的分配算法基於 tcmalloc,基本上沒有碎片問題。並且順序內存分配器在多線程的場景下並不適用。Go 使用的是基於 tcmalloc 的現代內存分配算法,對對象進行整理不會帶來實質性的性能提升。

2、分代 GC 依賴分代假設,即 GC 將主要的回收目標放在新創建的對象上(存活時間短,更傾向於被回收),而非頻繁檢查所有對象。但 Go 的編譯器會通過逃逸分析將大部分新生對象存儲在棧上(棧直接被回收),只有那些需要長期存在的對象才會被分配到需要進行垃圾回收的堆中。也就是說,分代 GC 回收的那些存活時間短的對象在 Go 中是直接被分配到棧上,當 goroutine 死亡后棧也會被直接回收,不需要 GC 的參與,進而分代假設並沒有帶來直接優勢。並且 Go 的垃圾回收器與用戶代碼並發執行,使得 STW 的時間與對象的代際、對象的 size 沒有關系。Go 團隊更關注於如何更好地讓 GC 與用戶代碼並發執行(使用適當的 CPU 來執行垃圾回收),而非減少停頓時間這一單一目標上。

go中使用的是三色標記法

三色標記法

三色標記,通過字面意思我們就可以知道它由3種顏色組成:

回收器通過將對象圖划分為三種狀態來指示其掃描過程。

白色對象

白色 White:潛在的垃圾,其內存可能會被垃圾收集器回收,如果掃描完成后,對象依然還是白色的,說明此對象是垃圾對象。

灰色對象

灰色 Gary:活躍的對象,因為存在指向白色對象的外部指針,垃圾收集器會掃描這些對象的子對象;

黑色

黑色 Black:活躍的對象,包括不存在任何引用外部指針的對象以及從根對象可達的對象;

三色標記規則:黑色不能指向白色對象。即黑色可以指向灰色,灰色可以指向白色。

根對象

在介紹三色標記法之前,首先了解下什么是根對象。

根對象在垃圾回收的術語中又叫做根集合,它是垃圾回收器在標記過程時最先檢查的對象,包括:

全局變量:程序在編譯期就能確定的那些存在於程序整個生命周期的變量。
執行棧:每個 goroutine 都包含自己的執行棧,這些執行棧上包含棧上的變量及指向分配的堆內存區塊的指針。
寄存器:寄存器的值可能表示一個指針,參與計算的這些指針可能指向某些賦值器分配的堆內存區塊。

在GC的標記階段首先需要標記的就是"根對象", 從根對象開始可到達的所有對象都會被認為是存活的。
根對象包含了全局變量, 各個G的棧上的變量等, GC會先掃描根對象然后再掃描根對象可到達的所有對象。

在垃圾收集器開始工作時,程序中不存在任何的黑色對象,垃圾收集的根對象會被標記成灰色,垃圾收集器只會從灰色對象集合中取出對象開始掃描,當灰色集合中不存在任何對象時,標記階段就會結束。

gc

三色標記垃圾收集器的工作原理很簡單,我們可以將其歸納成以下幾個步驟:

1、從灰色對象的集合中選擇一個灰色對象並將其標記成黑色;
2、將黑色對象指向的所有對象都標記成灰色,保證該對象和被該對象引用的對象都不會被回收;
3、重復上述兩個步驟直到對象圖中不存在灰色對象;

三色標記結束之后,里面只剩下白色和黑色的,最后回收掉白色的對象。

STW

因為用戶程序可能在標記執行的過程中修改對象的指針,所以三色標記清除算法本身是不可以並發或者增量執行的。

gc

比如上面所示的三色標記過程中,用戶程序建立了從 A 對象到 D 對象的引用,但是因為程序中已經不存在灰色對象了,所以 D 對象會被垃圾收集器錯誤地回收。

本來不應該被回收的對象卻被回收了,這會給我們的程序帶來不可預知的問題。

什么是STW?

STW 是 StoptheWorld 的縮寫,即萬物靜止,是指在垃圾回收過程中為了保證實現的正確性、防止無止境的內存增長等問題而不可避免的需要停止賦值器進一步操作對象圖的一段過程。

STW的過程有明顯的資源浪費,對所有的用戶程序都有很大影響。早期 Go 對垃圾回收器的實現中 STW 長達幾百毫秒,盡管 STW 如今已經優化到了半毫秒級別以下,但是STW的影響還是存在的。

想要並發或者增量地標記對象還是需要使用屏障技術。

屏障技術

在Golang中使用並發的垃圾回收,也就是多個賦值器與回收器並發執行,與此同時,應用屏障技術來保證回收器的正確性。其原理主要就是破壞上述兩個條件之一。

內存屏障技術是一種屏障指令,它可以讓 CPU 或者編譯器在執行內存相關操作時遵循特定的約束,目前的多數的現代處理器都會亂序執行指令以最大化性能,但是該技術能夠保證代碼對內存操作的順序性,在內存屏障前執行的操作一定會先於內存屏障后執行的操作。

想要在並發或者增量的標記算法中保證正確性,我們需要達成以下兩種三色不變性(Tri-color invariant)中的任意一種:

弱三色不變式

所有被黑色對象引用的白色對象都處於灰色保護狀態(直接或間接從灰色對象可達)。 強三色不變式:不存在黑色對象到白色對象的指針。

強三色不變式

黑色對象不會指向白色對象,只會指向灰色對象或者黑色對象;

強三色不變式很好理解,強制性的不允許黑色對象引用白色對象即可。而弱三色不變式中,黑色對象可以引用白色對象,但是這個白色對象仍然存在其他灰色對象對它的引用,或者可達它的鏈路上游存在灰色對象。

插入屏障

Dijkstra 在 1978 年提出了插入寫屏障,通過如下所示的寫屏障,用戶程序和垃圾收集器可以在交替工作的情況下保證程序執行的正確性:

writePointer(slot, ptr):
    shade(ptr)
    *slot = ptr

插入屏障攔截將白色指針插入黑色對象的操作,標記其對應對象為灰色狀態,這樣就不存在黑色對象引用白色對象的情況了,滿足強三色不變式。

比如上面黑色A指向白色D,A在引用D的時候直接將D標記為灰色就可以了。

gc

1、垃圾回收器將A對象標記為黑色,然后A對象指向的B對象標記為灰色;
2、用戶程序修改A對象的指針,將原本指向B對象的指針,指向了C對象,觸發寫屏障,根據強三色不變式,黑色不能直接指向白色,更改C對象的顏色為灰色;
3、垃圾收集器依次遍歷程序中的其他灰色對象,將它們分別標記成黑色;

插入式的 Dijkstra 寫屏障雖然實現非常簡單並且也能保證強三色不變性,但是它也有很明顯的缺點。因為棧上的對象在垃圾收集中也會被認為是根對象,所以為了保證內存的安全,Dijkstra 必須為棧上的對象增加寫屏障或者在標記階段完成重新對棧上的對象進行掃描,這兩種方法各有各的缺點,前者會大幅度增加寫入指針的額外開銷,后者重新掃描棧對象時需要暫停程序。

在Golang中,對棧上指針的寫入添加寫屏障的成本很高,所以Go選擇僅對堆上的指針插入增加寫屏障,這樣就會出現在掃描結束后,棧上仍存在引用白色對象的情況,這時的棧是灰色的,不滿足三色不變式,所以需要對棧進行重新掃描使其變黑,完成剩余對象的標記,這個過程需要STW。這期間會將所有goroutine掛起,當有大量應用程序時,時間可能會達到10~100ms。

刪除屏障

Yuasa 在 1990 年的論文 Real-time garbage collection on general-purpose machines 中提出了刪除寫屏障,因為一旦該寫屏障開始工作,它就會保證開啟寫屏障時堆上所有對象的可達,所以也被稱作快照垃圾收集(Snapshot GC)。

writePointer(slot, ptr):
    if (isGery(slot) || isWhite(slot))
        shade(*slot)
    *slot = ptr

刪除屏障也是攔截寫操作的,但是是通過保護灰色對象到白色對象的路徑不會斷來實現的。也就是若三色不變式。

gc

1、首先將A對象標記成黑色,然后A對象指向的B對象標記為灰色;
2、程序將A對象指向C對象,觸發刪除寫屏障,根據若三色不變式,因為C對象有灰色對象B的指向,所以不用做改變;
3、接着程序刪除B對象對C的指向,觸發刪除寫屏障,因為對象D不存在直接和間接的灰色可達對象,需要改變對象C的顏色為灰色;
4、垃圾收集器依次遍歷,標記GC。

上面的步驟2違反了強三色不變式,黑色對象直接指向了白色對象。接下來的步驟三違反了弱三色不變式,對象D沒有一個直接或間接可達的灰色對象。通過對C重新着色,來保證C和D對象的安全。

混合寫屏障

插入寫屏障和刪除寫屏障的短板:

插入寫屏障:結束時需要STW來重新掃描棧,標記棧上引用的白色對象的存活;

刪除寫屏障:回收精度低,GC開始時STW掃描堆棧來記錄初始快照,這個過程會保護開始時刻的所有存活對象。

在 Go 語言 v1.7 版本之前,運行時會使用 Dijkstra 插入寫屏障保證強三色不變性,但是運行時並沒有在所有的垃圾收集根對象上開啟插入寫屏障。因為 Go 語言的應用程序可能包含成百上千的 Goroutine,而垃圾收集的根對象一般包括全局變量和棧對象,如果運行時需要在幾百個 Goroutine 的棧上都開啟寫屏障,會帶來巨大的額外開銷,所以 Go 團隊在實現上選擇了在標記階段完成時暫停程序、將所有棧對象標記為灰色並重新掃描,在活躍 Goroutine 非常多的程序中,
重新掃描的過程需要占用 10 ~ 100ms 的時間。

具體操作:
1、GC開始將棧上的對象全部掃描並標記為黑色(之后不再進行第二次重復掃描,無需STW);
2、GC期間,任何在棧上創建的新對象,均為黑色;
3、被刪除的對象標記為灰色;
4、被添加的對象標記為灰色;

偽代碼

writePointer(slot, ptr):
    shade(*slot)
    if current stack is grey:
        shade(ptr)
    *slot = ptr

GO中GC的流程

1、准備階段

STW,初始化標記任務,啟用寫屏障

2、標記階段 GCMark

與賦值器並發執行,寫屏障處於開啟狀態

1、將狀態切換至 _GCmark、開啟寫屏障、用戶程序協助(Mutator Assiste)並將根對象入隊;
2、恢復執行程序,標記進程和用於協助的用戶程序會開始並發標記內存中的對象,寫屏障會將被覆蓋的指針和新指針都標記成灰色,而所有新創建的對象都會被直接標記成黑色;
3、開始掃描根對象,包括所有 Goroutine 的棧、全局對象以及不在堆中的運行時數據結構,掃描 Goroutine 棧期間會暫停當前處理器;
4、依次處理灰色隊列中的對象,將對象標記成黑色並將它們指向的對象標記成灰色;
5、使用分布式的終止算法檢查剩余的工作,發現標記階段完成后進入標記終止階段;

3、標記終止階段

STW, 保證一個周期內標記任務完成,停止寫屏障

4、清理階段

並發執行

1、將狀態切換至 _GCoff 開始清理階段,初始化清理狀態並關閉寫屏障;
2、恢復用戶程序,所有新創建的對象會標記成白色;
3、后台並發清理所有的內存管理單元,當 Goroutine 申請新的內存管理單元時就會觸發清理;

GC的觸發時機

Go 語言中對 GC 的觸發時機存在兩種形式:

  • 主動觸發,通過調用 runtime.GC 來觸發 GC,此調用阻塞式地等待當前 GC 運行完畢。
  • 被動觸發,分為兩種方式:
    1、使用系統監控,當超過兩分鍾沒有產生任何 GC 時,強制觸發 GC。
    2、使用步調(Pacing)算法,其核心思想是控制內存增長的比例。

例如:

  • 內存大小閾值, 內存達到上次gc后的2倍
  • 達到定時時間 ,2m interval

閾值是由一個gc percent的變量控制的,當新分配的內存占已在使用中的內存的比例超過gcprecent時就會觸發。比如一次回收完畢后,內存的使用量為5M,那么下次回收的時機則是內存分配達到10M的時候。也就是說,並不是內存分配越多,垃圾回收頻率越高。 如果一直達不到內存大小的閾值呢?這個時候GC就會被定時時間觸發,比如一直達不到10M,那就定時(默認2min觸發一次)觸發一次GC保證資源的回收。

如果內存分配速度超過了標記清除的速度怎么辦?

如果在后台執行的垃圾收集器不夠快,應用程序申請內存的速度超過預期,運行時就會讓申請內存的應用程序輔助完成垃圾收集的掃描階段,在標記和標記終止階段結束之后就會進入異步的清理階段,將不用的內存增量回收。

並發標記會設置一個標志,並在 mallocgc 調用時進行檢查。當存在新的內存分配時,會暫停分配內存過快的那些 goroutine,並將其轉去執行一些輔助標記(Mark Assist)的工作,從而達到放緩繼續分配、輔助 GC 的標記工作的目的。

如何觀察GC

1、使用GODEBUG=gctrace=1

$ GODEBUG=gctrace=1  go run main.go

測試的代碼片段

package main

import "sync"

func main() {

	wg := sync.WaitGroup{}
	wg.Add(10)
	for i := 0; i < 10; i++ {
		go func(wg *sync.WaitGroup) {
			var counter int
			for i := 0; i < 1e10; i++ {
				counter++
			}
			wg.Done()
		}(&wg)
	}

	wg.Wait()

}

來分析下結果

gc 1 @0.054s 5%: 0.024+37+0.48 ms clock, 0.098+4.5/12/53+1.9 ms cpu, 4->4->3 MB, 5 MB goal, 4 P
字段 含義
gc 1 第一個gc周期
0.054s 程序開始后的 0.054 秒
5% 該 GC 周期中 CPU 的使用率
0.024 標記開始時, STW 所花費的時間(wall clock)
37 標記過程中,並發標記所花費的時間(wall clock)
0.48 標記終止時, STW 所花費的時間(wall clock)
0.098 標記開始時, STW 所花費的時間(cpu time)
4.5 標記過程中,標記輔助所花費的時間(cpu time)
12 標記過程中,標記輔助所花費的時間(cpu time)
53 標記過程中,GC 空閑的時間(cpu time)
1.9 標記終止時, STW 所花費的時間(cpu time)
4 標記開始時,堆的大小的實際值
4 標記結束時,堆的大小的實際值
3 標記結束時,標記為存活的對象大小
5 標記結束時,堆的大小的預測值
4 P 的數量

GC如何優化

多余gc的優化主要從下面幾個方面考慮

1、控制內存分配的速度,限制 goroutine 的數量,從而提高賦值器對 CPU 的利用率。
2、減少並復用內存,例如使用 sync.Pool 來復用需要頻繁創建臨時對象,例如提前分配足夠的內存來降低多余的拷貝。
3、需要時,增大 GOGC 的值,降低 GC 的運行頻率。

GO中GC的演進過程

  • v1.0 — 完全串行的標記和清除過程,需要暫停整個程序;
  • v1.1 — 在多核主機並行執行垃圾收集的標記和清除階段;
  • v1.3 — 運行時基於只有指針類型的值包含指針的假設增加了對棧內存的精確掃描支持,實現了真正精確的垃圾收集;
  • v1.5 — 實現了基於三色標記清掃的並發垃圾收集器;
  • v1.6 — 實現了去中心化的垃圾收集協調器;
  • v1.7 — 通過並行棧收縮將垃圾收集的時間縮短至 2ms 以內;
  • v1.8 — 使用混合寫屏障將垃圾收集的時間縮短至 0.5ms 以內;
  • v1.9 — 徹底移除暫停程序的重新掃描棧的過程;
  • v1.10 — 更新了垃圾收集調頻器(Pacer)的實現,分離軟硬堆大小的目標;
  • v1.12 — 使用新的標記終止算法簡化垃圾收集器的幾個階段;
  • v1.13 — 通過新的 Scavenger 解決瞬時內存占用過高的應用程序向操作系統歸還內存的問題;
  • v1.14 — 替代了僅存活了一個版本的 scavenger,全新的頁分配器,優化分配內存過程的速率與現有的擴展性問題,並引入了異步搶占,解決了由於密集循環導致的 STW 時間過長的問題;

總結

上面總結了go中gc的一些場景,go使用的是三色標記的回收策略,結合混合寫屏障來保證並發回收的安全性,但是在時機的回收過程中還是需要STW的。

參考

【Golang垃圾回收 屏障技術】https://zhuanlan.zhihu.com/p/74853110
【Garbage Collection In Go : Part I - Semantics】https://www.ardanlabs.com/blog/2018/12/garbage-collection-in-go-part1-semantics.html
【深入理解Go語言-GC與逃逸分析】https://zhuanlan.zhihu.com/p/103056375
【Go GC 20 問】https://zhuanlan.zhihu.com/p/109431787
【Golang源碼探索(三) GC的實現原理】https://www.cnblogs.com/zkweb/p/7880099.html
【【golang】變量的stack/heap分配與逃逸分析不解之情】https://www.jianshu.com/p/8a80d50d2f9c
【golang的gc回收針對堆還是棧?變量內存分配在堆還是棧?】https://www.mscto.com/blockchain/264512.html
【寫屏障技術】https://golang.design/under-the-hood/zh-cn/part2runtime/ch08gc/barrier/
【Golang三色標記、混合寫屏障GC模式圖文全分析】https://mp.weixin.qq.com/s/G7id1bNt9QpAvLe7tmRAGw
【垃圾收集器】https://draveness.me/golang/docs/part3-runtime/ch07-memory/golang-garbage-collector/
【golang gc 簡明過程(基於go 1.14)】https://zhuanlan.zhihu.com/p/92210761
【如何觀察 Go GC】https://www.bookstack.cn/read/qcrao-Go-Questions/195663


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM