記憶體回收(gc)是 golang 很重要的一部分。
GC 演算法簡介
三種經典的 GC 演算法:引用計數(reference counting)、標記-清掃(mark & sweep)、節點複製(Copying Garbage Collection),分代收集(Generational Garbage Collection)。
A.引用計數
OC 以前就用這種方法(現在我不知道是啥)。
引用計數的思想非常簡單:每個單元維護一個域,儲存其它單元指向它的引用數量(類似有向圖的入度)。當引用數量為 0 時,將其回收。引用計數是漸進式的,能夠將記憶體管理的開銷分布到整個程式之中。C++ 的 share_ptr 使用的就是引用計算方法。
引用計數演算法實現一般是把所有的單元放在一個單元池裡,比如類似 free list。這樣所有的單元就被串起來了,就可以進行引用計數了。新分配的單元計數值被設定為 1(注意不是 0,因為申請一般都說 ptr = new object 這種)。每次有一個指標被設為指向該單元時,該單元的計數值加 1;而每次刪除某個指向它的指標時,它的計數值減 1。當其引用計數為 0 的時候,該單元會被進行回收。
雖然這裡說的比較簡單,實現的時候還是有很多細節需要考慮,比如刪除某個單元的時候,那麼它指向的所有單元都需要對引用計數減 1。那麼如果這個時候,發現其中某個指向的單元的引用計數又為 0,那麼是遞迴的進行還是採用其他的策略呢?遞迴處理的話會導致系統顛簸。
優點
- 漸進式。記憶體管理與使用者程式的執行交織在一起,將 GC 的代價分散到整個程式。不像標記-清掃演算法需要 STW (Stop The World,GC 的時候掛起使用者程式)。
- 演算法易於實現。
- 記憶體單元能夠很快被回收。相比於其他記憶體回收演算法,堆被耗盡或者達到某個閾值才會進行記憶體回收。
缺點
- 原始的引用計數不能處理循環參考。大概這是被詬病最多的缺點了。不過針對這個問題,也除了很多解決方案,比如強引用等。
- 維護引用計數降低運行效率。記憶體單元的更新刪除等都需要維護相關的記憶體單元的引用計數,相比於一些追蹤式的記憶體回收演算法並不需要這些代價。
- 單元池 free list 實現的話不是 cache-friendly 的,這樣會導致頻繁的 cache miss,降低程式運行效率。
B.標記-清掃
標記-清掃演算法是第一種自動記憶體管理,基於追蹤的垃圾收集演算法。演算法思想在 70 年代就提出了,是一種非常古老的演算法。記憶體單元並不會在變成垃圾立刻回收,而是保持不可達狀態,直到到達某個閾值或者固定時間長度。這個時候系統會掛起使用者程式,也就是 STW,轉而執行記憶體回收程式。
記憶體回收程式對所有的存活單元進行一次全域遍曆確定哪些單元可以回收。演算法分兩個部分:標記(mark)和清掃(sweep)。標記階段表明所有的存活單元,清掃階段將垃圾單元回收。
標記-清掃演算法的優點也就是基於追蹤的記憶體回收演算法具有的優點:避免了引用計數演算法的缺點(不能處理循環參考,需要維護指標)。缺點也很明顯,需要 STW。
三色標記演算法
三色標記演算法是對標記階段的改進,原理如下:
- 起初所有對象都是白色。
- 從根出發掃描所有可達對象,標記為灰色,放入待處理隊列。
- 從隊列取出灰色對象,將其引用對象標記為灰色放入隊列,自身標記為黑色。
- 重複 3,直到灰色對象隊列為空白。此時白色對象即為垃圾,進行回收。
如下所示。
三色標記的一個明顯好處是能夠讓使用者程式和 mark 並發的進行。
c. 節點複製
節點複製也是基於追蹤的演算法。其將整個堆等分為兩個半區(semi-space),一個包含現有資料,另一個包含已被廢棄的資料。節點複製式垃圾收集從切換(flip)兩個半區的角色開始,然後收集器在老的半區,也就是 Fromspace 中遍曆存活的資料結構,在第一次訪問某個單元時把它複製到新半區,也就是 Tospace 中去。在 Fromspace 中所有存活單元都被訪問過之後,收集器在 Tospace 中建立一個存活資料結構的副本,使用者程式可以重新開始運行了。
優點
- 所有存活的資料結構都縮並地排列在 Tospace 的底部,這樣就不會存在記憶體片段的問題。
- 擷取新記憶體可以簡單地通過遞增自由空間指標來實現。
缺點
- 記憶體得不到充分利用,總有一半的記憶體空間處於浪費狀態。
d. 分代收集
基於追蹤的記憶體回收演算法(標記-清掃、節點複製)一個主要問題是在生命週期較長的對象上浪費時間(長生命週期的對象是不需要頻繁掃描的)。
同時,記憶體配置存在這麼一個事實 “most object die young”。基於這兩點,分代記憶體回收演算法將對象按生命週期長短存放到堆上的兩個(或者更多)地區,這些地區就是分代(generation)。對於新生代的地區的記憶體回收頻率要明顯高於老年代地區。
指派至的時候從新生代裡面分配,如果後面發現對象的生命週期較長,則將其移到老年代,這個過程叫做 promote。隨著不斷 promote,最後新生代的大小在整個堆的佔用比例不會特別大。收集的時候集中主要精力在新生代就會相對來說效率更高,STW 時間也會更短。
優點
- 效能更優。
缺點
- 實現複雜
golang GC
Go語言提供了一個變數GOGC,用來對GC進行控制。該變數表示:最近一次GC過後,總的heap記憶體比所有可達節點所佔用heap記憶體 大的百分比。如果GOGC=100則表示最近一次GC過後,總的heap記憶體比所有可達節點所佔用heap記憶體大100%,即總heap記憶體是可達節點記憶體的2倍。
該值越大,則GC速度越快,但程式佔用的記憶體較大,GC效果相對不明顯。反之,則GC對記憶體的清理效果明顯,但往往需要更多的時間。
何時觸發 GC?
在堆上分配大於 32K byte 對象的時候進行檢測此時是否滿足記憶體回收條件,如果滿足則進行記憶體回收。
func mallocgc(size uintptr, typ *_type, needzero bool) unsafe.Pointer { ... shouldhelpgc := false // 分配的對象小於 32K byte if size <= maxSmallSize { ... } else { shouldhelpgc = true ... } ... // gcShouldStart() 函數進行觸發條件檢測 if shouldhelpgc && gcShouldStart(false) { // gcStart() 函數進行記憶體回收 gcStart(gcBackgroundMode, false) }}
上面是自動記憶體回收,還有一種是主動記憶體回收,通過調用 runtime.GC(),這是阻塞式的。
// GC runs a garbage collection and blocks the caller until the// garbage collection is complete. It may also block the entire// program.func GC() { gcStart(gcForceBlockMode, false)}
GC 觸發條件
觸發條件主要關注下面代碼中的中間部分:forceTrigger || memstats.heap_live >= memstats.gc_trigger
。forceTrigger 是 forceGC 的標誌;後面半句的意思是當前堆上的活躍對象大於我們初始化時候設定的 GC 觸發閾值。在 malloc 以及 free 的時候 heap_live 會一直進行更新,這裡就不再展開了。
// gcShouldStart returns true if the exit condition for the _GCoff// phase has been met. The exit condition should be tested when// allocating.//// If forceTrigger is true, it ignores the current heap size, but// checks all other conditions. In general this should be false.func gcShouldStart(forceTrigger bool) bool { return gcphase == _GCoff && (forceTrigger || memstats.heap_live >= memstats.gc_trigger) && memstats.enablegc && panicking == 0 && gcpercent >= 0}//初始化的時候設定 GC 的觸發閾值func gcinit() { _ = setGCPercent(readgogc()) memstats.gc_trigger = heapminimum ...}// 啟動的時候通過 GOGC 傳遞百分比 x// 觸發閾值等於 x * defaultHeapMinimum (defaultHeapMinimum 預設是 4M)func readgogc() int32 { p := gogetenv("GOGC") if p == "off" { return -1 } if n, ok := atoi32(p); ok { return n } return 100}
記憶體回收的主要流程
三色標記法,主要流程如下:
- 所有對象最開始都是白色。
- 從 root 開始找到所有可達對象,標記為灰色,放入待處理隊列。
- 遍曆灰色對象隊列,將其引用對象標記為灰色放入待處理隊列,自身標記為黑色。
處理完灰色對象隊列,執行清掃工作。
go_gc.png
這裡需要解釋下:
首先從 root 開始遍曆,root 包括全域指標和 goroutine 棧上的指標。
mark 有兩個過程。第一是從 root 開始遍曆,標記為灰色。遍曆灰色隊列。第二re-scan 全域指標和棧。因為 mark 和使用者程式是並行的,所以在過程 1 的時候可能會有新的對象分配,這個時候就需要通過寫屏障(write barrier)記錄下來。re-scan 再完成檢查一下。
Stop The World 有兩個過程。第一個是 GC 將要開始的時候,這個時候主要是一些準備工作,比如 enable write barrier。第二個過程就是上面提到的 re-scan 過程。如果這個時候沒有 stw,那麼 mark 將無休止。
另外針對各個階段對應 GCPhase 如下:
- Off: _GCoff
- Stack scan - Mark: _GCmark
- Mark termination: _GCmarktermination
寫屏障 (write barrier)
記憶體回收中的 write barrier 可以理解為編譯器在寫操作時特意插入的一段代碼,對應的還有 read barrier。
為什麼需要 write barrier,很簡單,對於和使用者程式並發啟動並執行記憶體回收演算法,使用者程式會一直修改記憶體,所以需要記錄下來。
Golang 1.7 之前的 write barrier 使用的經典的 Dijkstra-style insertion write barrier [Dijkstra ‘78], STW 的主要耗時就在 stack re-scan 的過程。自 1.8 之後採用一種混合的 write barrier 方式 (Yuasa-style deletion write barrier [Yuasa ‘90] 和 Dijkstra-style insertion write barrier [Dijkstra ‘78])來避免 re-scan。
標記
記憶體回收的代碼主要集中在函數 gcStart() 中。
// gcStart 是 GC 的入口函數,根據 gcMode 做處理。// 1. gcMode == gcBackgroundMode(後台運行,也就是並行), _GCoff -> _GCmark// 2. 否則 GCoff -> _GCmarktermination,這個時候就是主動 GC func gcStart(mode gcMode, forceTrigger bool) { ...}
* STW phase 1
在 GC 開始之前的準備工作。
func gcStart(mode gcMode, forceTrigger bool) { ... //在後台啟動 mark worker if mode == gcBackgroundMode { gcBgMarkStartWorkers() } ... // Stop The World systemstack(stopTheWorldWithSema) ... if mode == gcBackgroundMode { // GC 開始前的準備工作 //處理設定 GCPhase,setGCPhase 還會 enable write barrier setGCPhase(_GCmark) gcBgMarkPrepare() // Must happen before assist enable. gcMarkRootPrepare() // Mark all active tinyalloc blocks. Since we're // allocating from these, they need to be black like // other allocations. The alternative is to blacken // the tiny block on every allocation from it, which // would slow down the tiny allocator. gcMarkTinyAllocs() // Start The World systemstack(startTheWorldWithSema) } else { ... }}
* Mark
Mark 階段是並行的運行,通過在後台一直運行 mark worker 來實現。
func gcStart(mode gcMode, forceTrigger bool) { ... //在後台啟動 mark worker if mode == gcBackgroundMode { gcBgMarkStartWorkers() }}func gcBgMarkStartWorkers() { // Background marking is performed by per-P G's. Ensure that // each P has a background GC G. for _, p := range &allp { if p == nil || p.status == _Pdead { break } if p.gcBgMarkWorker == 0 { go gcBgMarkWorker(p) notetsleepg(&work.bgMarkReady, -1) noteclear(&work.bgMarkReady) } }}// gcBgMarkWorker 是一直在後台啟動並執行,大部分時候是休眠狀態,通過 gcController 來調度func gcBgMarkWorker(_p_ *p) { for { // 將當前 goroutine 休眠,直到滿足某些條件 gopark(...) ... // mark 過程 systemstack(func() { // Mark our goroutine preemptible so its stack // can be scanned. This lets two mark workers // scan each other (otherwise, they would // deadlock). We must not modify anything on // the G stack. However, stack shrinking is // disabled for mark workers, so it is safe to // read from the G stack. casgstatus(gp, _Grunning, _Gwaiting) switch _p_.gcMarkWorkerMode { default: throw("gcBgMarkWorker: unexpected gcMarkWorkerMode") case gcMarkWorkerDedicatedMode: gcDrain(&_p_.gcw, gcDrainNoBlock|gcDrainFlushBgCredit) case gcMarkWorkerFractionalMode: gcDrain(&_p_.gcw, gcDrainUntilPreempt|gcDrainFlushBgCredit) case gcMarkWorkerIdleMode: gcDrain(&_p_.gcw, gcDrainIdle|gcDrainUntilPreempt|gcDrainFlushBgCredit) } casgstatus(gp, _Gwaiting, _Grunning) }) ... }}
Mark 階段的標記代碼主要在函數 gcDrain() 中實現。
// gcDrain scans roots and objects in work buffers, blackening grey// objects until all roots and work buffers have been drained.func gcDrain(gcw *gcWork, flags gcDrainFlags) { ... // Drain root marking jobs. if work.markrootNext < work.markrootJobs { for !(preemptible && gp.preempt) { job := atomic.Xadd(&work.markrootNext, +1) - 1 if job >= work.markrootJobs { break } markroot(gcw, job) if idle && pollWork() { goto done } } } // 處理 heap 標記 // Drain heap marking jobs. for !(preemptible && gp.preempt) { ... //從灰色列隊中取出對象 var b uintptr if blocking { b = gcw.get() } else { b = gcw.tryGetFast() if b == 0 { b = gcw.tryGet() } } if b == 0 { // work barrier reached or tryGet failed. break } //掃描灰色對象的引用對象,標記為灰色,入灰色隊列 scanobject(b, gcw) }}
* Mark termination (STW phase 2)
mark termination 階段會 stop the world。函數實現在 gcMarkTermination()。
func gcMarkTermination() { // World is stopped. // Run gc on the g0 stack. We do this so that the g stack // we're currently running on will no longer change. Cuts // the root set down a bit (g0 stacks are not scanned, and // we don't need to scan gc's internal state). We also // need to switch to g0 so we can shrink the stack. systemstack(func() { gcMark(startTime) // Must return immediately. // The outer function's stack may have moved // during gcMark (it shrinks stacks, including the // outer function's stack), so we must not refer // to any of its variables. Return back to the // non-system stack to pick up the new addresses // before continuing. }) ...}
清掃
func gcSweep(mode gcMode) { ... //阻塞式 if !_ConcurrentSweep || mode == gcForceBlockMode { // Special case synchronous sweep. ... // Sweep all spans eagerly. for sweepone() != ^uintptr(0) { sweep.npausesweep++ } // Do an additional mProf_GC, because all 'free' events are now real as well. mProf_GC() mProf_GC() return } // 並行式 // Background sweep. lock(&sweep.lock) if sweep.parked { sweep.parked = false ready(sweep.g, 0, true) } unlock(&sweep.lock)}
並行式清掃,在 GC 初始化的時候就會啟動 bgsweep(),然後在後台一直迴圈
func bgsweep(c chan int) { sweep.g = getg() lock(&sweep.lock) sweep.parked = true c <- 1 goparkunlock(&sweep.lock, "GC sweep wait", traceEvGoBlock, 1) for { for gosweepone() != ^uintptr(0) { sweep.nbgsweep++ Gosched() } lock(&sweep.lock) if !gosweepdone() { // This can happen if a GC runs between // gosweepone returning ^0 above // and the lock being acquired. unlock(&sweep.lock) continue } sweep.parked = true goparkunlock(&sweep.lock, "GC sweep wait", traceEvGoBlock, 1) }}func gosweepone() uintptr { var ret uintptr systemstack(func() { ret = sweepone() }) return ret}
不管是阻塞式還是並行式,都是通過 sweepone()函數來做清掃工作的.記憶體管理都是基於 span 的,mheap_ 是一個全域的變數,所有分配的對象都會記錄在 mheap_ 中。在標記的時候,我們只要找到對對象對應的 span 進行標記,清掃的時候掃描 span,沒有標記的 span 就可以回收了。
// sweeps one span// returns number of pages returned to heap, or ^uintptr(0) if there is nothing to sweepfunc sweepone() uintptr { ... for { s := mheap_.sweepSpans[1-sg/2%2].pop() ... if !s.sweep(false) { // Span is still in-use, so this returned no // pages to the heap and the span needs to // move to the swept in-use list. npages = 0 } }}// Sweep frees or collects finalizers for blocks not marked in the mark phase.// It clears the mark bits in preparation for the next GC round.// Returns true if the span was returned to heap.// If preserve=true, don't return it to heap nor relink in MCentral lists;// caller takes care of it.func (s *mspan) sweep(preserve bool) bool { ...}
其他
每個 P 上都有一個 gcw 用來管理灰色對象(get 和 put),gcw 的結構就是 gcWork。gcWork 中的核心是 wbuf1 和 wbuf2,裡面儲存就是灰色對象,或者說是 work(下面就全部統一叫做 work)。
type p struct { ... gcw gcWork}type gcWork struct { // wbuf1 and wbuf2 are the primary and secondary work buffers. wbuf1, wbuf2 wbufptr // Bytes marked (blackened) on this gcWork. This is aggregated // into work.bytesMarked by dispose. bytesMarked uint64 // Scan work performed on this gcWork. This is aggregated into // gcController by dispose and may also be flushed by callers. scanWork int64}
既然每個 P 上有一個 work buffer,那麼是不是還有一個全域的 work list 呢?是的。通過在每個 P 上綁定一個 work buffer 的好處和 cache 一樣,不需要加鎖。
var work struct { full uint64 // lock-free list of full blocks workbuf empty uint64 // lock-free list of empty blocks workbuf pad0 [sys.CacheLineSize]uint8 // prevents false-sharing between full/empty and nproc/nwait ...}
那麼為什麼使用兩個 work buffer (wbuf1 和 wbuf2)呢?例如我現在要 get 一個 work 出來,先從 wbuf1 中取,wbuf1 為空白的話則與 wbuf2 swap 再 get。在其他時間將 work buffer 中的 full 或者 empty buffer 移到 global 的 work 中。
這樣的好處在於,在 get 的時候去全域的 work 裡面取(多個 goroutine 去取會有競爭)。這裡有趣的是 global 的 work list 是 lock-free 的,通過原子操作 cas 等實現。下面列舉幾個函數看一下 gcWrok。
func (w *gcWork) init() { w.wbuf1 = wbufptrOf(getempty()) wbuf2 := trygetfull() if wbuf2 == nil { wbuf2 = getempty() } w.wbuf2 = wbufptrOf(wbuf2)}
// put enqueues a pointer for the garbage collector to trace.// obj must point to the beginning of a heap object or an oblet.func (w *gcWork) put(obj uintptr) { wbuf := w.wbuf1.ptr() if wbuf == nil { w.init() wbuf = w.wbuf1.ptr() // wbuf is empty at this point. } else if wbuf.nobj == len(wbuf.obj) { w.wbuf1, w.wbuf2 = w.wbuf2, w.wbuf1 wbuf = w.wbuf1.ptr() if wbuf.nobj == len(wbuf.obj) { putfull(wbuf) wbuf = getempty() w.wbuf1 = wbufptrOf(wbuf) flushed = true } } wbuf.obj[wbuf.nobj] = obj wbuf.nobj++}
// get dequeues a pointer for the garbage collector to trace, blocking// if necessary to ensure all pointers from all queues and caches have// been retrieved. get returns 0 if there are no pointers remaining.//go:nowritebarrierfunc (w *gcWork) get() uintptr { wbuf := w.wbuf1.ptr() if wbuf == nil { w.init() wbuf = w.wbuf1.ptr() // wbuf is empty at this point. } if wbuf.nobj == 0 { w.wbuf1, w.wbuf2 = w.wbuf2, w.wbuf1 wbuf = w.wbuf1.ptr() if wbuf.nobj == 0 { owbuf := wbuf wbuf = getfull() if wbuf == nil { return 0 } putempty(owbuf) w.wbuf1 = wbufptrOf(wbuf) } } // TODO: This might be a good place to add prefetch code wbuf.nobj-- return wbuf.obj[wbuf.nobj]}
- forcegc
除了上面的兩種GC觸發方式:自動檢測和使用者主動調用。除此之後 Golang 本身還會對運行狀態進行監控,如果超過兩分鐘沒有 GC,則觸發 GC。監控函數是 sysmon(),在主 goroutine 中啟動。
// The main goroutinefunc main() { ... systemstack(func() { newm(sysmon, nil) })}// Always runs without a P, so write barriers are not allowed.func sysmon() { ... for { now := nanotime() unixnow := unixnanotime() lastgc := int64(atomic.Load64(&memstats.last_gc)) if gcphase == _GCoff && lastgc != 0 && unixnow-lastgc > forcegcperiod && atomic.Load(&forcegc.idle) != 0 { lock(&forcegc.lock) forcegc.idle = 0 forcegc.g.schedlink = 0 injectglist(forcegc.g) // 將 forcegc goroutine 加入 runnable queue unlock(&forcegc.lock) } }}var forcegcperiod int64 = 2 * 60 *1e9 //兩分鐘