這是一個建立於 的文章,其中的資訊可能已經有所發展或是發生改變。
通常C++通過指標引用計數來回收對象,但是這不能處理循環參考。為了避免引用計數的缺陷,後來出現了標記清除,分代等記憶體回收演算法。Go的記憶體回收官方形容為 非分代 非緊縮 寫屏障 並發標記清理。標記清理演算法的字面解釋,就是將可達的記憶體塊進行標記mark
,最後沒有標記的不可達記憶體塊將進行清理sweep
。
三色標記法
判斷一個對象是不是垃圾需不需要標記,就看是否能從當前棧或全域資料區 直接或間接的引用到這個對象。這個初始的當前goroutine的棧和全域資料區稱為GC的root區。掃描從這裡開始,通過markroot
將所有root地區的指標標記為可達,然後沿著這些指標掃描,遞迴地標記遇到的所有可達對象。因此引出幾個問題:
- 標記清理能不能與使用者代碼並發
- 如何獲得對象的類型而找到所有可達地區 標記位記錄在哪裡
- 何時觸發標記清理
如何並發標記
標記清掃演算法在標記和清理時需要停止所有的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 queuewhile(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.govar forcegcperiod int64 = 2 * 60 * 1e9func 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初始化時進行gcenablefunc 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建議交還給核心。
參考文章
Proposal: Eliminate STW stack re-scanning
go筆記-GC
go1.5的記憶體回收
go記憶體回收剖析