Go 垃圾回收
https://studygolang.com/articles/11904
通常C++通過指標引用計數來回收物件,但是這不能處理迴圈引用。為了避免引用計數的缺陷,後來出現了標記清除,分代等垃圾回收演算法。Go的垃圾回收官方形容為 非分代 非緊縮 寫屏障 併發標記清理。標記清理演算法的字面解釋,就是將可達的記憶體塊進行標記mark
,最後沒有標記的不可達記憶體塊將進行清理sweep
。
三色標記法
判斷一個物件是不是垃圾需不需要標記,就看是否能從當前棧或全域性資料區 直接或間接的引用到這個物件。這個初始的當前goroutine的棧和全域性資料區稱為GC的root區。掃描從這裡開始,通過markroot
- 標記清理能不能與使用者程式碼併發
- 如何獲得物件的型別而找到所有可達區域 標記位記錄在哪裡
- 何時觸發標記清理
如何併發標記
標記清掃演算法在標記和清理時需要停止所有的goroutine,來保證已經被標記的區域不會被使用者修改引用關係,造成清理錯誤。但是每次GC都要StopTheWorld顯然是不能接受的。Go的各個版本為減少STW做了各種努力。從Go1.5開始採用三色標記法實現標記階段的併發。
- 最開始所有物件都是白色
- 掃描所有可達物件,標記為灰色,放入待處理佇列
- 從佇列提取灰色物件,將其引用物件標記為灰色放入佇列,自身標記為黑色
- 寫屏障監控物件記憶體修改,重新標色或是放入佇列
完成標記後 物件不是白色就是黑色,清理操作只需要把白色物件回收記憶體回收就好。
大概理解所謂併發標記,首先是指能夠跟使用者程式碼併發的進行,其次是指標記工作不是遞迴地進行,而是多個goroutine併發的進行。前者通過write-barrier解決併發問題,後者通過gc-work佇列實現非遞迴地mark可達物件。
write-barrier
用下面這個例子解釋併發帶來的問題,原文引用自CMS垃圾回收器原理。當從A這個GC root找到引用物件B時,B變灰A變黑。這時使用者goroutine執行把A到B的引用改成了A到C的引用,同時B不再引用C。然後GC goroutine又執行,發現B沒有引用物件,B變黑。而這時由於A已經變黑完成了掃描,C將當做白色不可達物件被清除。
解決辦法:引入寫屏障。當發現A已經標記為黑色了,若A又引用C,那麼把C變灰入隊。這個write_barrier是編譯器在每一處記憶體寫操作前生成一小段程式碼來做的。
// 寫屏障虛擬碼
write_barrier(obj,field,newobj){
if(newobj.mark == FALSE){
newobj.mark = TRUE
push(newobj,$mark_stack)
}
*field = newobj
}
gc-work
如何非遞迴的實現遍歷mark可達節點,顯然需要一個佇列。
這個佇列也幫助區分黑色物件和灰色物件,因為標記位只有一個。標記並且在佇列中的是灰色物件,標記了但是不在佇列中的黑色物件,末標記的是白色物件。
root node queue
while(queue is not nil) {
dequeue // 節點出隊
process // 處理當前節點
child node queue // 子節點入隊
}
總結一下併發標記的過程:
gcstart
啟動階段準備了N個goMarkWorkers
。每個worker都處理以下相同流程。- 如果是第一次mark則首先
markroot
將所有root區的指標入隊。 - 從gcw中取節點出對開始掃描處理
scanobject
,節點出佇列就是黑色了。 - 掃描時獲取該節點所有子節點的型別資訊判斷是不是指標,若是指標且並沒有被標記則
greyobject
入隊。 - 每個worker都去gcw中拿任務直到為空break。
// 每個markWorker都執行gcDrain這個標記過程
func gcDrain(gcw *gcWork, flags gcDrainFlags) {
// 如果還沒有root區域入隊則markroot
markroot(gcw, job)
if idle && pollWork() {
goto done
}
// 節點出隊
b = gcw.get()
scanobject(b, gcw)
done:
}
func scanobject(b uintptr, gcw *gcWork) {
hbits := heapBitsForAddr(b)
s := spanOfUnchecked(b)
n := s.elemsize
for i = 0; i < n; i += sys.PtrSize {
// Find bits for this word.
if bits&bitPointer == 0 {
continue // not a pointer
}
....
// Mark the object.
if obj, hbits, span, objIndex := heapBitsForObject(obj, b, i); obj != 0 {
greyobject(obj, b, i, hbits, span, gcw, objIndex)
}
}
gcw.bytesMarked += uint64(n)
gcw.scanWork += int64(i)
}
func greyobject(obj, base, off uintptr, hbits heapBits,
span *mspan, gcw *gcWork, objIndex uintptr) {
mbits := span.markBitsForIndex(objIndex)
// If marked we have nothing to do.
if mbits.isMarked() {
return
}
if !hbits.hasPointers(span.elemsize) {
return
}
gcw.put(obj)
}
標記位
實現精確地垃圾回收的前提,就是能獲得物件區域的型別資訊,從而判斷是否是指標。如何判斷,最後又把可達標記記在哪裡:通過堆區arena前面對應的bitmap。
結構體中不包含指標,其實不需要遞迴地標記結構體成員。如果沒有型別資訊只能對所有的結構體成員遞迴地標記下去。還有如果非指標成員剛好儲存的內容對應著合法地址,那這個地址的物件就會碰巧被標記,導致無法回收。
這個bitmap點陣圖區域每個字(32位或64位)會對應4位的標記位。heapBitsForAddr
可以獲取對應堆地址的bitmap位hbits,根據它可以判斷是否是指標,如果是指標且之前沒有被標記過,則mark當前物件為可達,並且greayObject
入隊,供給其他的markWorker來處理。
// 獲取b對應的bitmap點陣圖
obj, hbits, span, objIndex := heapBitsForObject(obj, b, i)
mbits := span.markBitsForIndex(objIndex)
// 判斷是否被標記過 已標記或不是指標都不入隊
mbits.isMarked()
hbits.hasPointers(span.elemsize)
gc_trigger最開始是4MB,next_gc初始為4MB,之後每次標記完成時將重新計算動態調整值大小。但gc_trigger至少要大於初始的4MB,同時至少要比當前使用的heap大1MB。
gcmark在每次標記結束後重置閾值大小。當前使用了4MB記憶體,這時設定gc_trigger為2*4MB,也就是當記憶體分配到8MB時會再次觸發GC。回收之後記憶體為5MB,那下一次要達到10MB才會觸發GC。這個比例triggerRatio是由gcpercent/100決定的。
func gcinit() {
_ = setGCPercent(readgogc())
memstats.gc_trigger = heapminimum
memstats.next_gc = uint64(float64(memstats.gc_trigger) / (1 +
gcController.triggerRatio) * (1 + float64(gcpercent)/100))
work.startSema = 1
work.markDoneSema = 1
}
func gcMark() {
memstats.gc_trigger = uint64(float64(memstats.heap_marked) *
(1 + gcController.triggerRatio))
}
強制垃圾回收
如果系統啟動或短時間內大量分配物件,會將垃圾回收的gc_trigger推高。當服務正常後,活躍物件遠小於這個閾值,造成垃圾回收無法觸發。這個問題交給sysmon解決。它每隔2分鐘force觸發GC一次。這個forcegc的goroutine一直park在後臺,直到sysmon將它喚醒開始執行gc而不用檢查閾值。
// proc.go
var forcegcperiod int64 = 2 * 60 * 1e9
func init() { go forcegchelper()}
func sysmon() {
lastgc := int64(atomic.Load64(&memstats.last_gc))
if gcphase == _GCoff && lastgc != 0 &&
unixnow-lastgc > forcegcperiod &&
atomic.Load(&forcegc.idle) != 0 {
injectglist(forcegc.g)
}
}
func forcegchelper() {
for {
goparkunlock(&forcegc.lock, "force gc (idle)", traceEvGoBlock, 1)
gcStart(gcBackgroundMode, true)
}
}
標記與清理過程
這裡結合gc-work那一節從頭梳理一下gc的啟動和流程。下面這個圖總結了mark-sweep所有的狀態變化。在程式碼裡只有三個GC狀態,分別對應這幾個階段。總結兩個問題:
- 為什麼markTermination需要rescan全域性指標和棧。因為mark階段是跟使用者程式碼併發的,所以有可能棧上都分了新的物件,這些物件通過write barrier記錄下來,在rescan的時候再檢查一遍。
- 為什麼還需要兩個stopTheWorld 在GCtermination時需要STW不然永遠都可能棧上出現新物件。在GC開始之前做準備工作(比如 enable write barrier)的時候也要STW。
- Off:
_GCoff
- Stack scan + Mark:
_GCmark
- Mark termination: _GCmarktermination
Goff to Gmark
gcstart
由每次mallocgc觸發,當然要滿足gc_trriger等閾值條件才觸發。整個啟動過程都是STW的,它啟動了所有將併發執行標記工作的goroutine,然後進入GCMark狀態使能寫屏障,啟動gcController。
func gcStart(mode gcMode, forceTrigger bool) {
// 啟動MarkStartWorkers的goroutine
if mode == gcBackgroundMode {
gcBgMarkStartWorkers()
}
gcResetMarkState()
systemstack(stopTheWorldWithSema)
// 完成之前的清理工作
systemstack(func() {
finishsweep_m()
})
// 進入Mark狀態 使能寫屏障
if mode == gcBackgroundMode {
gcController.startCycle()
setGCPhase(_GCmark)
gcBgMarkPrepare()
gcMarkRootPrepare()
atomic.Store(&gcBlackenEnabled, 1)
systemstack(startTheWorldWithSema)
}
}
Gmark
解釋一下gcMarkWorker跟gcController的關係。gcstart中只是為所有的P都準備好對應的goroutine來做標記。但是他們一開始就gopark住當前G,直到被gccontroller的findRunnableGCWorker
喚醒。goroutine原始碼記錄講了goroutine的過程,m啟動後會一直通過schedule查詢可執行的G,其中gcworker也是G的來源,但是首先要檢查當前狀態是不是Gmark。如果是就喚醒worker開始標記工作。
func gcBgMarkStartWorkers() {
for _, p := range &allp {
go gcBgMarkWorker(p)
notetsleepg(&work.bgMarkReady, -1)
noteclear(&work.bgMarkReady)
}
}
func schedule() {
...//schedule優先喚醒markworkerG 但首先gcBlackenEnabled != 0
if gp == nil && gcBlackenEnabled != 0 {
gp = gcController.findRunnableGCWorker(_g_.m.p.ptr())
}
}
喚醒後開始進入mark標記工作gcDrain
。gc-work那一節講了併發標記的過程,這裡不重複。總結來說就是每個worker都去佇列中拿節點(黑化節點),然後處理當前節點看有沒有指標和沒標記的物件,繼續入隊子節點(灰化節點),直到佇列為空再也找不到可達物件。
func gcBgMarkWorker(_p_ *p) {
notewakeup(&work.bgMarkReady)
for {
gopark(func(g *g, parkp unsafe.Pointer) bool {
}, unsafe.Pointer(park), "GC worker (idle)", traceEvGoBlock, 0)
systemstack(func() {
casgstatus(gp, _Grunning, _Gwaiting)
gcDrain(&_p_.gcw, ...)
casgstatus(gp, _Gwaiting, _Grunning)
})
// 標記完成gcMarkDone()
if incnwait == work.nproc && !gcMarkWorkAvailable(nil) {
gcMarkDone()
}
}
}
Gmarktermination
mark結束後呼叫gcMarkDone
,它主要是StopTheWorld
然後進入gcMarkTermination
中的gcMark
。大概是做了rescan root區域的工作,但是看到有部落格說Go1.8已經沒有再rescan了,細節沒看懂,程式碼裡看起來卻是又重新掃描了一次啊。
func gcMarkTermination() {
atomic.Store(&gcBlackenEnabled, 0)
setGCPhase(_GCmarktermination)
casgstatus(gp, _Grunning, _Gwaiting)
gp.waitreason = "garbage collection"
systemstack(func() {
gcMark(startTime)
setGCPhase(_GCoff)
gcSweep(work.mode)
})
casgstatus(gp, _Gwaiting, _Grunning)
systemstack(startTheWorldWithSema)
}
func gcMark(start_time int64) {
gcMarkRootPrepare()
gchelperstart()
gcDrain(gcw, gcDrainBlock)
gcw.dispose()
// gc結束後重置gc_trigger等閾值
...
}
Gsweep
有多個地方可以觸發sweep,比如GC標記結束會觸發gcsweep。如果是併發清除,需要回收從gc_trigger到當前活躍記憶體的那麼多heap區域,喚醒後臺的sweep goroutine。
func gcSweep(mode gcMode) {
lock(&mheap_.lock)
mheap_.sweepgen += 2
mheap_.sweepdone = 0
unlock(&mheap_.lock)
// Background sweep.
ready(sweep.g, 0, true)
}
// 在runtime初始化時進行gcenable
func gcenable() {
go bgsweep(c)
}
func bgsweep(c chan int) {
goparkunlock(&sweep.lock, "GC sweep wait", traceEvGoBlock, 1)
for {
for gosweepone() != ^uintptr(0) {
sweep.nbgsweep++
Gosched()
}
goparkunlock(&sweep.lock, "GC sweep wait", traceEvGoBlock, 1)
}
}
也就是系統初始化的時候開啟了後臺的bgsweep goroutine。這個G也是一進去就park了,喚醒後執行gosweepone。seepone的過程大概是:遍歷所有的spans看它的sweepgen是否需要檢查,如果要就檢查這個mspan裡所有的object的bit位看是否需要回收。這個過程可能觸發mspan到mcentral的回收,最終可能回收到mheap的freelist當中。在freelist當中的記憶體再超過一定閾值時間後會被sysmon建議交還給核心。