Go 記憶體回收

來源:互聯網
上載者:User
這是一個建立於 的文章,其中的資訊可能已經有所發展或是發生改變。

通常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 queuewhile(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.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狀態,分別對應這幾個階段。總結兩個問題:

  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初始化時進行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記憶體回收剖析

相關文章

聯繫我們

該頁面正文內容均來源於網絡整理,並不代表阿里雲官方的觀點,該頁面所提到的產品和服務也與阿里云無關,如果該頁面內容對您造成了困擾,歡迎寫郵件給我們,收到郵件我們將在5個工作日內處理。

如果您發現本社區中有涉嫌抄襲的內容,歡迎發送郵件至: info-contact@alibabacloud.com 進行舉報並提供相關證據,工作人員會在 5 個工作天內聯絡您,一經查實,本站將立刻刪除涉嫌侵權內容。

A Free Trial That Lets You Build Big!

Start building with 50+ products and up to 12 months usage for Elastic Compute Service

  • Sales Support

    1 on 1 presale consultation

  • After-Sales Support

    24/7 Technical Support 6 Free Tickets per Quarter Faster Response

  • Alibaba Cloud offers highly flexible support services tailored to meet your exact needs.