Golang垃圾回收器執(zhí)行鏈路詳細分析
眾所周知,我們現(xiàn)版本的 Go 默認是使用的三色標記法,八股文已經(jīng)聽膩了,來看點源碼理解一下 GC 流程。
何時會觸發(fā)垃圾回收?
系統(tǒng)監(jiān)控
懂行的都知道,gc 的入口是 gcStart,所以我們只需要順著他的調用鏈路向上找,可以知道會有一個后臺協(xié)程 forcegchelper 會重復檢測是否滿足 GC 的狀態(tài):
// init 函數(shù)在包初始化時運行,啟動一個強制 GC 的輔助 goroutine
func init() {
go forcegchelper() // 啟動一個獨立 goroutine,專門負責觸發(fā)強制 GC
}
// forcegchelper 是強制 GC 的后臺輔助 goroutine
func forcegchelper() {
forcegc.g = getg()
lockInit(&forcegc.lock, lockRankForcegc)
for {
lock(&forcegc.lock)
if forcegc.idle.Load() {
throw("forcegc: phase error")
}
forcegc.idle.Store(true)
// 將當前 goroutine 掛起,釋放鎖,等待 sysmon(系統(tǒng)監(jiān)控 goroutine)喚醒
goparkunlock(&forcegc.lock, waitReasonForceGCIdle, traceBlockSystemGoroutine, 1)
if debug.gctrace > 0 {
println("GC forced")
}
gcStart(gcTrigger{kind: gcTriggerTime, now: nanotime()})
}
}
雖然是使用的 for 循環(huán)不斷檢測是否滿足 gc 條件,但是這里有一個 gopark,稍微有了解 go 源碼的都知道, gopark 意味著將這個協(xié)程掛起,也就是將 M 線程資源讓出來,從而避免長時間阻塞在這里等待滿足 gc 條件。那么什么時候會喚醒這個 goroutine 呢?答案寫在注釋里面,當系統(tǒng)監(jiān)控覺得確實應該觸發(fā) GC 了,就會喚醒這個后臺強制 GC 的 goroutine。
他是如何被喚醒的?可以在 sysmon() 的末尾找到答案,通過 gcTrigger 去判斷是否應該觸發(fā)強制 GC,如果應該 gc 了,那么就會將這個 goroutine 喚醒,其實就是將他放回調度隊列里面:
if t := (gcTrigger{kind: gcTriggerTime, now: now}); t.test() && atomic.Load(&forcegc.idle) != 0 {
lock(&forcegc.lock)
forcegc.idle = 0
var list gList
// 放回隊列
list.push(forcegc.g)
injectglist(&list)
unlock(&forcegc.lock)
}
申請內存
還有 newUserArenaChunk ,什么時候會觸發(fā)這個所謂的 newUserArenaChunk 呢?簡單來說就是堆內存需要新申請的時候,此時就會去檢測是否應該觸發(fā) GC,除此之外,檢測是否應該觸發(fā) GC 的地方 mallocgc ,這是一個通用的分配內存的函數(shù),總之,當我們申請內存的時候,我們都會去檢查是否應該去觸發(fā) GC,就這么簡單;除此之外說一句題外話,我們可以在這些 malloc 函數(shù)中看見讀寫屏障的具體邏輯,當開啟了寫屏障時,此時就會幫助直接標記為灰色。
gcStart 干了啥?
大體流程
剔除一些無關緊要的代碼,如下所示:
// gcStart 啟動 Go 垃圾回收(GC)。
//
// trigger: 指示 GC 啟動條件的觸發(fā)器,例如堆大小超過閾值或手動觸發(fā)。
//
// 注意:
// - 如果當前在系統(tǒng)棧上或持有鎖,不會啟動 GC。
// - 根據(jù) debug.gcstoptheworld 的設置,可能執(zhí)行并發(fā) GC 或 Stop-The-World GC。
func gcStart(trigger gcTrigger) {
...
// 啟動后臺 mark 工作 goroutine
// 就是這里面做的標記工作
gcBgMarkStartWorkers()
// 初始化 STW(Stop-The-World)相關信息
work.stwprocs, work.maxprocs = gomaxprocs, gomaxprocs
if work.stwprocs > numCPUStartup {
work.stwprocs = numCPUStartup
}
work.heap0 = gcController.heapLive.Load()
work.pauseNS = 0
work.mode = mode
now := nanotime()
work.tSweepTerm = now
// 系統(tǒng)棧執(zhí)行 STW
var stw worldStop
systemstack(func() {
stw = stopTheWorldWithSema(stwGCSweepTerm)
})
// 累計暫停時間
work.cpuStats.accumulateGCPauseTime(stw.stoppingCPUTime, 1)
// 在系統(tǒng)棧完成 sweep
systemstack(func() {
finishsweep_m()
})
// 清理對象池
clearpools()
// GC 周期計數(shù)加一
work.cycles.Add(1)
// 啟用協(xié)助機制和工作線程
gcController.startCycle(now, int(gomaxprocs), trigger)
gcCPULimiter.startGCTransition(true, now)
if mode != gcBackgroundMode {
schedEnableUser(false) // STW 模式下禁止用戶 goroutine 調度
}
// 進入并發(fā) mark 階段,并啟用寫屏障
setGCPhase(_GCmark)
gcBgMarkPrepare()
// 這個函數(shù)挺重要的,會把所有的待掃描的對象空間分成多個 task。
gcPrepareMarkRoots()
gcMarkTinyAllocs()
atomic.Store(&gcBlackenEnabled, 1)
mp = acquirem()
// 更新 CPU 統(tǒng)計信息
work.cpuStats.accumulateGCPauseTime(nanotime()-stw.finishedStopping, work.maxprocs)
// 并發(fā) mark 開始,STW 停止
systemstack(func() {
now = startTheWorldWithSema(0, stw)
work.pauseNS += now - stw.startedStopping
work.tMark = now
gcCPULimiter.finishGCTransition(now)
})
...
}
其中最值得注意的函數(shù)就是 gcBgMarkStartWorkers 和 gcPrepareMarkRoots 這兩個函數(shù)在我們之后的分析里面算是最重要的。
首先我們看看 gcBgMarkStartWorkers 干了什么,一串下去的鏈路是
gcBgMarkStartWorkers -> gcBgMarkWorker -> gcDrainMarkWorkerIdle -> gcDrain
而這個 gcDrain 函數(shù)就是最后我們需要分析的地方,這里很復雜。
gcDrain 會掃描 root 對象,不斷將灰色對象標記為黑色,直到?jīng)]有更多任務可以標記,gcDrain 并不是在一個專門的 M 上執(zhí)行,所以我們需要考慮到其他業(yè)務任務的執(zhí)行,如果長期執(zhí)行 gcDrain 就會導致負責業(yè)務的 goroutine 餓死,所以 gcDrain 也提供了一些搶占點檢查是否應該讓出 M。
首先我們需要知道,這個搶占點的檢查是什么:
checkWork := int64(1<<63 - 1)
var check func() bool
if flags&(gcDrainIdle|gcDrainFractional) != 0 {
checkWork = initScanWork + drainCheckThreshold
if idle {
check = pollWork
} else if flags&gcDrainFractional != 0 {
check = pollFractionalWorkerExit
}
}
這個 pollWork 是什么?其實就是看當前程序中是否有網(wǎng)絡 IO 就緒非阻塞調用一下 netpoll,查看是否有事件已經(jīng)準備好了,防止 gc 阻塞了重要任務的執(zhí)行。
第二個 pollFractionalWorkerExit 則是一個檢測自己有沒有執(zhí)行過長時間,如果執(zhí)行時間太長了,那么就會讓出當前的 M 線程,讓其他 goroutine 執(zhí)行;在后續(xù),我們每次循環(huán)標記的過程中都會去調用這個 check() 來防止任務被 GC 任務阻塞了,因為相對來說,GC 后臺標記這個任務優(yōu)先級是比較低的。
下面是第一個標記循環(huán),目的很清晰,就是從之前提到的 gcPrepareMarkRoots 中的 Tasks 中通過原子操作去獲取一個 Task 來標記,同時,在每次任務標記完之后,就會檢查一遍是否應該讓出 M 線程。這里的原子操作保證了 go 中的 GC 可以并發(fā)安全的進行標記,而 markroot 就是對所有的 root 對象進行掃描標記,root 是可達活對象的起點,包括但不限于全局變量,棧。
for !(gp.preempt && (preemptible || sched.gcwaiting.Load() || pp.runSafePointFn != 0)) {
job := atomic.Xadd(&work.markrootNext, +1) - 1
if job >= work.markrootJobs {
break
}
markroot(gcw, job, flushBgCredit)
if check != nil && check() {
goto done
}
...
}
想要知道 root 包含那些內存數(shù)據(jù),我們可以在之前提過的 gcPrepareMarkRoots 里面找到:
// gcPrepareMarkRoots 準備 GC 根對象掃描任務
func gcPrepareMarkRoots() {
// 確認此時世界已停止(STW),防止在掃描過程中有 goroutine 修改 root
assertWorldStopped()
// 用于計算需要多少個 root block(數(shù)據(jù)塊)來存儲給定字節(jié)數(shù)的 root
nBlocks := func(bytes uintptr) int {
return int(divRoundUp(bytes, rootBlockBytes))
}
// 初始化 data 和 BSS root 的數(shù)量
work.nDataRoots = 0
work.nBSSRoots = 0
// 掃描全局變量段(data / BSS 段)
for _, datap := range activeModules() {
nDataRoots := nBlocks(datap.edata - datap.data) // data 段需要多少 root block
if nDataRoots > work.nDataRoots {
work.nDataRoots = nDataRoots
}
nBSSRoots := nBlocks(datap.ebss - datap.bss) // BSS 段需要多少 root block
if nBSSRoots > work.nBSSRoots {
work.nBSSRoots = nBSSRoots
}
}
// 掃描 span roots(用于 finalizer 特殊對象)
// GC 會掃描在 mark 階段開始時可用的 heapArenas(即 markArenas)
mheap_.markArenas = mheap_.heapArenas[:len(mheap_.heapArenas):len(mheap_.heapArenas)]
work.nSpanRoots = len(mheap_.markArenas) * (pagesPerArena / pagesPerSpanRoot)
// 掃描 goroutine 棧
// 注意,之后新創(chuàng)建的 goroutine 不會被掃描,但它們的 root 會被寫屏障捕獲
work.stackRoots = allGsSnapshot()
work.nStackRoots = len(work.stackRoots)
// 初始化 root 掃描任務索引
work.markrootNext = 0
// 總共需要掃描的 root 數(shù)量
work.markrootJobs = uint32(fixedRootCount + work.nDataRoots + work.nBSSRoots + work.nSpanRoots + work.nStackRoots)
// 計算每類 root 的起始索引,用于 markroot 調度
work.baseData = uint32(fixedRootCount) // data root 起始索引
work.baseBSS = work.baseData + uint32(work.nDataRoots) // BSS root 起始索引
work.baseSpans = work.baseBSS + uint32(work.nBSSRoots) // span root 起始索引
work.baseStacks = work.baseSpans + uint32(work.nSpanRoots) // stack root 起始索引
work.baseEnd = work.baseStacks + uint32(work.nStackRoots) // 所有 root 的結束索引
}
這里其實就是做了一些計算工作,將當前的程序中的一些內存數(shù)據(jù)保存下來,并分塊成多個 task,方便之后并發(fā)的進行標記處理,我們不需要太過于在意這些數(shù)據(jù)是怎么得出來的,只需要知道是這么回事即可。
那么我們標記 root 之后,我們便需要去標記 heap 對象了,此時我們當然需要依賴之前從 root 中標記的對象去標記 heap 內存中的對象:
// 這是 GC 的 heap 標記循環(huán),用于從灰色對象隊列中繼續(xù)標記對象,直到隊列為空或需要暫停。
// 此循環(huán)在 GC 的標記階段執(zhí)行(_GCmark)。
//
// 循環(huán)條件:如果當前 G 被標記為可搶占,并且滿足以下任意條件則停止循環(huán):
// - preemptible 為 true
// - sched.gcwaiting 表示有人想觸發(fā) STW(Stop The World)
// - pp.runSafePointFn != 0 表示有 P 正在執(zhí)行安全點函數(shù)
for !(gp.preempt && (preemptible || sched.gcwaiting.Load() || pp.runSafePointFn != 0)) {
// 嘗試保證全局隊列中有可用工作。
// 如果 work.full == 0,說明本地隊列空了,需要從全局隊列平衡一些工作。
if work.full == 0 {
gcw.balance()
}
// 從工作隊列中獲取下一個待掃描對象或 span
var b uintptr // 單個對象指針
var s objptr // span 指針
// 嘗試按優(yōu)先級獲取灰色對象
if b = gcw.tryGetObjFast(); b == 0 { // 優(yōu)先嘗試快速隊列
if s = gcw.tryGetSpan(false); s == 0 { // 沒有對象,嘗試獲取 span
if b = gcw.tryGetObj(); b == 0 { // 再嘗試普通隊列
wbBufFlush() // 寫屏障緩沖區(qū) flush,可能產生新的灰色對象
if b = gcw.tryGetObj(); b == 0 { // 再次嘗試獲取對象
s = gcw.tryGetSpan(true) // 最后嘗試獲取 span
}
}
}
}
// 如果拿到對象或 span,就掃描它們
if b != 0 {
scanobject(b, gcw) // 掃描對象,將其引用的對象加入灰色隊列
} else if s != 0 {
scanSpan(s, gcw) // 掃描 span,處理里面的對象
} else {
// 隊列空,無法獲取更多工作,循環(huán)結束
break
}
// 如果實驗性 GreenTea GC 需要新 worker,則啟動
if goexperiment.GreenTeaGC && gcw.mayNeedWorker {
gcw.mayNeedWorker = false
if gcphase == _GCmark {
gcController.enlistWorker()
}
}
// 將本地累積的掃描工作量計入全局,供 mutator assist 使用
if gcw.heapScanWork >= gcCreditSlack {
gcController.heapScanWork.Add(gcw.heapScanWork) // 增加全局 heapScanWork
if flushBgCredit {
gcFlushBgCredit(gcw.heapScanWork - initScanWork) // flush 背景掃描信用
initScanWork = 0
}
checkWork -= gcw.heapScanWork
gcw.heapScanWork = 0
// 檢查,之前提到的 check
if checkWork <= 0 {
checkWork += drainCheckThreshold
if check != nil && check() {
break
}
}
}
}
到這里其實已經(jīng)把 GC 的邏輯梳理的差不多了,其他諸如 STW,StartTheWorld 都沒有講述。但是其實我們還可以更細粒度的去看看 tryget 還有 markroot 都干了些什么,這里有點不太想貼源碼,就直接口述了。
迭代標記
markroot的函數(shù)簽名是 markroot(gcw *gcWork, i uint32, flushBgCredit bool) int64,這個 i 就是所謂的 taskId,我們可以根據(jù)這個 id 找到當前需要進行掃描標記的區(qū)域,大多數(shù)都是調用 scanblock 去掃描的,而在這個函數(shù)中,經(jīng)歷一系列復雜的變換和掃描,由于我不懂 GC 的掃描邏輯,所以就不亂講,最終我們會把掃描到的可達對象通過 greyobject 將這個對象標記為灰色,如果對象不可掃描,則標記為黑色,在將他標記為灰色之后,我們還會將他通過 gcw.putObj 放入到本地的標記處理隊列里面,這一步的意義其實就是迭代處理,在第二階段標記的時候,我們也是最終會調用 greyobject 將這個函數(shù)染灰,并推送到本地標記處理隊列里面,用于迭代處理,思想上有點類似廣度優(yōu)先搜索。
// greyobject 將一個堆對象標記為灰色(可掃描),并將其加入到 P 的本地工作隊列 gcw 中,以便后續(xù)掃描其內部指針。
// 如果對象不可掃描(noscan),則直接標記為黑色。
//
// 參數(shù)說明:
// obj : 要標記的對象起始地址
// base, off : 調試信息,用于記錄對象是通過哪個 root 掃描到的
// span : 對象所在的內存 span
// gcw : 當前 P 的本地 GC 工作隊列
// objIndex : 對象在 span 中的索引
//
// go:nowritebarrierrec 表示此函數(shù)不會觸發(fā)寫屏障,且可遞歸調用
func greyobject(obj, base, off uintptr, span *mspan, gcw *gcWork, objIndex uintptr) {
...
// 將對象加入 P 的本地工作隊列,以便后續(xù) scanobject 掃描其指針
if !gcw.putObjFast(obj) { // 快速入隊
gcw.putObj(obj) // 慢速入隊(如果快速失敗)
}
}你可能會注意到,第二階段的掃描只調用了 scanobject ,實際上,它內部也是調用的 greyobject,他會將這個對象引用的指針通過 greyobject 變?yōu)榛疑?,并放入本地工作隊列,以便于下一次的迭代?/p>
// scanobject 掃描以 b 開頭的堆對象,將對象內部的指針加入 gcw 隊列。
// b 必須指向一個堆對象或 oblet(大對象的分塊)。
// scanobject 會根據(jù) GC 的位圖獲取指針掩碼,并通過 span 獲取對象大小。
//
//go:nowritebarrier 表示該函數(shù)不會觸發(fā)寫屏障。
func scanobject(b uintptr, gcw *gcWork) {
...
var scanSize uintptr
for {
var addr uintptr
// 嘗試快速獲取下一個指針
if tp, addr = tp.nextFast(); addr == 0 {
// 如果沒有快速指針,再走慢路徑
if tp, addr = tp.next(b + n); addr == 0 {
break
}
}
// 更新掃描范圍,用于統(tǒng)計 heapScanWork
scanSize = addr - b + goarch.PtrSize
// 讀取對象中的潛在指針
obj := *(*uintptr)(unsafe.Pointer(addr))
// 過濾掉 nil 和指向當前對象內部的指針
if obj != 0 && obj-b >= n {
// 判斷 obj 是否指向 Go 堆中的對象,如果是則標記
// 注意可能存在與分配同時發(fā)生的競爭,findObject 可能失敗
if !tryDeferToSpanScan(obj, gcw) {
if obj, span, objIndex := findObject(obj, b, addr-b); obj != 0 {
// 將指針對象標記為灰色,并入隊等待掃描
greyobject(obj, b, addr-b, span, gcw, objIndex)
}
}
}
}
// 更新本地 GC 工作隊列的統(tǒng)計信息
gcw.bytesMarked += uint64(n)
gcw.heapScanWork += int64(scanSize)
if debug.gctrace > 1 {
gcw.stats[s.spanclass.sizeclass()].sparseObjsScanned++
}
}綜上所述,三色標記的大致的流程如下:
markroot(掃描 root 對象:全局變量、棧、span specials)
↓
發(fā)現(xiàn)堆對象 → greyobject → 標灰 + 入本地隊列 gcw
↓
heap 掃描階段(drain heap marking jobs)
↓
從 gcw 隊列取灰對象(tryGetObj/tryGetSpan)
↓
scanobject 掃描對象內部指針
↓
掃描出的新對象 → greyobject → 入 gcw 隊列(迭代處理)
↓
重復直到隊列為空 → 所有可達對象都被標記
這下我們知道了,網(wǎng)上圖解的三色標記法其實就是一個在三色的基礎上進行廣度優(yōu)先搜索,圖還是很生動形象的,然而,有的東西也需要真正去看這部分邏輯才能學到,GC 不僅僅就是個垃圾回收,他的運行過程還和系統(tǒng)監(jiān)控,網(wǎng)絡輪詢器有著一定的關系,感覺看源碼有助于對整個 runtime 的認知,雖然我把 STW,讀寫屏障還有三色標記的具體算法沒有重點講解,但是本篇文章主要注重邏輯梳理。
一些優(yōu)化
除了上面所說的標記以外,我們的 mallocgc 其實也會在 gc 階段幫助我們進行部分標記工作,這就是我們常說的 Mutator Assist 優(yōu)化
// mallocgc 分配一個指定大小的對象。
// 小對象從 P(處理器本地)緩存的 free list 分配。
// 大對象(> 32 KB)直接從堆分配。
// mallocgc 是 runtime 內部接口,但一些第三方庫通過 //go:linkname 調用。
// 請勿修改函數(shù)簽名,否則可能破壞 runtime。
//
// 參數(shù):
// size - 需要分配的字節(jié)數(shù)
// typ - 對象類型信息 (_type),用于 GC 掃描指針;nil 表示 noscan
// needzero - 是否需要將分配的內存清零
//
// 返回值:
// unsafe.Pointer - 指向分配好的對象
//
// go:linkname 指令允許其他包直接調用 runtime.mallocgc
//
// mallocgc 核心功能:
// 1. 檢查 GC assist,決定 mutator 是否需要幫忙做標記。
// 2. 根據(jù)對象大小選擇 tiny allocator / small allocator / large allocator。
// 3. 調用 sanitizers(race、msan、asan、valgrind)。
// 4. 調整 GC assist 債務。
func mallocgc(size uintptr, typ *_type, needzero bool) unsafe.Pointer {
...
// 當前是否在 GC mark 階段且 write barrier 啟用
// 如果需要,mutator(分配者)需要幫忙標記一些對象
if gcBlackenEnabled != 0 {
deductAssistCredit(size) // 扣除助理信用,并可能觸發(fā) gcDrain
}
...
}
通過上面的分析,我們可以發(fā)現(xiàn),我們的 GC 是通過廣度優(yōu)先搜索的方式去從堆上掃描對象來進行回收,也就是說,如果堆上的內存小,但是對象多,就會給 GC 帶來很大的壓力,所以這就是我們需要進行逃逸分析,在一些情況下盡量避免內存逃逸到堆上,看完這部分我覺得《Go 語言設計與實現(xiàn)》講的是真的不錯,但是真的得自己再去看看源碼才能把整個鏈路搞明白。
總結
到此這篇關于Golang垃圾回收器執(zhí)行鏈路詳細分析的文章就介紹到這了,更多相關Golang垃圾回收器執(zhí)行鏈路內容請搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關文章希望大家以后多多支持腳本之家!
相關文章
gorm golang 并發(fā)連接數(shù)據(jù)庫報錯的解決方法
今天小編就為大家分享一篇gorm golang 并發(fā)連接數(shù)據(jù)庫報錯的解決方法,具有很好的參考價值,希望對大家有所幫助。一起跟隨小編過來看看吧2019-07-07

