1. 程式人生 > >Go垃圾回收機制剖析

Go垃圾回收機制剖析

Golang 從第一個版本以來,GC 一直是大家詬病最多的。但是每一個版本的釋出基本都伴隨著 GC 的改進。下面列出一些比較重要的改動。
v1.1 STW
v1.3 Mark STW, Sweep 並行
v1.5 三色標記法
v1.8 hybrid write barrier

GC 演算法簡介

這一小節介紹三種經典的 GC 演算法:引用計數(reference counting)、標記-清掃(mark & sweep)、節點複製(Copying Garbage Collection),分代收集(Generational Garbage Collection)。

引用計數

引用計數的思想非常簡單:每個單元維護一個域,儲存其它單元指向它的引用數量(類似有向圖的入度)。當引用數量為 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,降低程式執行效率。

標記-清掃

標記-清掃演算法是第一種自動記憶體管理,基於追蹤的垃圾收集演算法。演算法思想在 70 年代就提出了,是一種非常古老的演算法。記憶體單元並不會在變成垃圾立刻回收,而是保持不可達狀態,直到到達某個閾值或者固定時間長度。這個時候系統會掛起使用者程式,也就是 STW,轉而執行垃圾回收程式。垃圾回收程式對所有的存活單元進行一次全域性遍歷確定哪些單元可以回收。演算法分兩個部分:標記(mark)清掃(sweep)。標記階段表明所有的存活單元,清掃階段將垃圾單元回收。視覺化可以參考下圖。

標記清掃演算法過程

標記清掃演算法過程


標記-清掃演算法的優點也就是基於追蹤的垃圾回收演算法具有的優點:避免了引用計數演算法的缺點(不能處理迴圈引用,需要維護指標)。缺點

也很明顯,需要 STW。

三色標記演算法

三色標記演算法是對標記階段的改進,原理如下:

  1. 起初所有物件都是白色。
  2. 從根出發掃描所有可達物件,標記為灰色,放入待處理佇列。
  3. 從佇列取出灰色物件,將其引用物件標記為灰色放入佇列,自身標記為黑色,並放入黑色集合中。
  4. 重複 3,直到灰色物件佇列為空。此時白色物件即為垃圾,進行回收。

視覺化如下:

三色標記法過程

三色標記法過程


三色標記的一個明顯好處是能夠讓使用者程式和 mark 併發的進行,具體可以參考論文:《On-the-fly garbage collection: an exercise in cooperation.》。Golang 的 GC 實現也是基於這篇論文,後面再具體說明。

節點複製

節點複製也是基於追蹤的演算法。其將整個堆等分為兩個半區(semi-space),一個包含現有資料,另一個包含已被廢棄的資料。節點複製式垃圾收集從切換(flip)兩個半區的角色開始,然後收集器在老的半區,也就是 Fromspace 中遍歷存活的資料結構,在第一次訪問某個單元時把它複製到新半區,也就是 Tospace 中去。在 Fromspace 中所有存活單元都被訪問過之後,收集器在 Tospace 中建立一個存活資料結構的副本,使用者程式可以重新開始運行了。
優點

  • 所有存活的資料結構都縮並地排列在 Tospace 的底部,這樣就不會存在記憶體碎片的問題。
  • 獲取新記憶體可以簡單地通過遞增自由空間指標來實現。

缺點

  • 記憶體得不到充分利用,總有一半的記憶體空間處於浪費狀態。

分代收集

基於追蹤的垃圾回收演算法(標記-清掃節點複製)一個主要問題是在生命週期較長的物件上浪費時間(長生命週期的物件是不需要頻繁掃描的)。同時,記憶體分配存在這麼一個事實 “most object die young”。基於這兩點,分代垃圾回收演算法將物件按生命週期長短存放到堆上的兩個(或者更多)區域,這些區域就是分代(generation)。對於新生代的區域的垃圾回收頻率要明顯高於老年代區域。
分配物件的時候從新生代裡面分配,如果後面發現物件的生命週期較長,則將其移到老年代,這個過程叫做 promote。隨著不斷 promote,最後新生代的大小在整個堆的佔用比例不會特別大。收集的時候集中主要精力在新生代就會相對來說效率更高,STW 時間也會更短。
優點:效能更優

缺點:實現複雜

Golang GC

Overview

在說 Golang 的具體垃圾回收流程時,我們先來看一下幾個基本的問題。

何時觸發 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
}

垃圾回收的主要流程

三色標記法,主要流程如下:

  1. 所有物件最開始都是白色。
  2. 從 root 開始找到所有可達物件,標記為灰色,放入待處理佇列。
  3. 遍歷灰色物件佇列,將其引用物件標記為灰色放入待處理佇列,自身標記為黑色。
  4. 迴圈步驟3直到灰色佇列為空為止,此時所有引用物件都被標記為黑色,所有不可達的物件依然為白色,白色的就是需要進行回收的物件。

詳細過程可參見官方給出的圖示


go-gc官方圖示

go-gc官方圖示

關於上圖有幾點需要說明的是。

  1. 首先從 root 開始遍歷,root 包括全域性指標和 goroutine 棧上的指標。
  2. mark 有兩個過程。
    2.1. 從 root 開始遍歷,標記為灰色。遍歷灰色佇列。
    2.2. re-scan 全域性指標和棧。因為 mark 和使用者程式是並行的,所以在過程 1 的時候可能會有新的物件分配,這個時候就需要通過寫屏障(write barrier)記錄下來。re-scan 再完成檢查一下。
  3. STW(Stop The World)有兩個過程。
    3.1. 第一個是 GC 將要開始的時候,這個時候主要是一些準備工作,比如 enable write barrier。
    3.2. 第二個過程就是上面提到的 re-scan 過程。如果這個時候沒有 stw,那麼 mark 將無休止。

另外針對上圖各個階段對應 GCPhase 如下:

  • Off: _GCoff
  • Stack scan ~ Mark: _GCmark
  • Mark termination: _GCmarktermination

寫屏障 (write barrier)

關於 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。具體的可以參考 17503-eliminate-rescan 。

標記

下面的原始碼還是基於 go1.8rc3。這個版本的 GC 程式碼相比之前改動還是挺大的,我們下面儘量只關注主流程。垃圾回收的程式碼主要集中在函式 gcStart()中。

// gcStart 是 GC 的入口函式,根據 gcMode 做處理。
// 1. gcMode == gcBackgroundMode(後臺執行,也就是並行), _GCoff -> _GCmark
// 2. 否則 GCoff -> _GCmarktermination,這個時候就是主動 GC 
func gcStart(mode gcMode, forceTrigger bool) { 
    ...
}
  1. 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 {
        ...
    }
}
  1. 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)
    }
}
  1. Mark termination (STW phase 2)

mark termination 階段會 stop the world。函式實現在 gcMarkTermination()。1.8 版本已經不會再對 goroutine stack 進行 re-scan 了。細節有點多,這裡不細說了。

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()函式來做清掃工作的。如果對於上篇文章Golang 記憶體管理 熟悉的話,這個地方就很好理解。記憶體管理都是基於 span 的,mheap_ 是一個全域性的變數,所有分配的物件都會記錄在 mheap_ 中。在標記的時候,我們只要找到對物件對應的 span 進行標記,清掃的時候掃描 span,沒有標記的 span 就可以回收了。

// sweeps one span
// returns number of pages returned to heap, or ^uintptr(0) if there is nothing to sweep.
func 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 {
    ...
}

其他

  1. gcWork
    這裡介紹一下任務佇列,或者說灰色物件管理。每個 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

// 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

// 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:nowritebarrier
func (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]
}
  1. forcegc
    我們上面講了兩種 GC 觸發方式:自動檢測和使用者主動呼叫。除此之後 Golang 本身還會對執行狀態進行監控,如果超過兩分鐘沒有 GC,則觸發 GC。監控函式是 sysmon(),在主 goroutine 中啟動。
// The main goroutine
func 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   //兩分鐘

程式的優化

現在我們已經瞭解了golang中的垃圾回收機制
那麼如何從程式碼方面優化以減少gc導致的STW的時間?

  • 減少物件的分配

  • 使用sync.Pool

說明

  1. 對於golang gc的時候,過程是:掃描-標記-清除,這3個步驟中在程式中能做的就是減少物件的分配,直觀的結果就是減少了gc的掃描和標記時間,而我們已經知道mark階段是會導致stw的,最終結果直接導致stw的時間減少。
  2. sync.Pool有兩個特性
    2.1. 能有效分擔物件儲存壓力
    2.2. 對gc友好