1. 程式人生 > >Go 垃圾回收

Go 垃圾回收

https://studygolang.com/articles/11904

通常C++通過指標引用計數來回收物件,但是這不能處理迴圈引用。為了避免引用計數的缺陷,後來出現了標記清除,分代等垃圾回收演算法。Go的垃圾回收官方形容為 非分代 非緊縮 寫屏障 併發標記清理。標記清理演算法的字面解釋,就是將可達的記憶體塊進行標記mark,最後沒有標記的不可達記憶體塊將進行清理sweep

三色標記法

判斷一個物件是不是垃圾需不需要標記,就看是否能從當前棧或全域性資料區 直接或間接的引用到這個物件。這個初始的當前goroutine的棧和全域性資料區稱為GC的root區。掃描從這裡開始,通過markroot

將所有root區域的指標標記為可達,然後沿著這些指標掃描,遞迴地標記遇到的所有可達物件。因此引出幾個問題:

  1. 標記清理能不能與使用者程式碼併發
  2. 如何獲得物件的型別而找到所有可達區域 標記位記錄在哪裡
  3. 何時觸發標記清理

如何併發標記

標記清掃演算法在標記和清理時需要停止所有的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 // 子節點入隊
}

總結一下併發標記的過程:

  1. gcstart啟動階段準備了N個goMarkWorkers。每個worker都處理以下相同流程。
  2. 如果是第一次mark則首先markroot將所有root區的指標入隊。
  3. 從gcw中取節點出對開始掃描處理scanobject,節點出佇列就是黑色了。
  4. 掃描時獲取該節點所有子節點的型別資訊判斷是不是指標,若是指標且並沒有被標記則greyobject入隊。
  5. 每個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狀態,分別對應這幾個階段。總結兩個問題:

  1. 為什麼markTermination需要rescan全域性指標和棧。因為mark階段是跟使用者程式碼併發的,所以有可能棧上都分了新的物件,這些物件通過write barrier記錄下來,在rescan的時候再檢查一遍。
  2. 為什麼還需要兩個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建議交還給核心。