Golang 垃圾回收器执行链路分析

众所周知,我们现版本的 Go 默认是使用的三色标记法,八股文已经听腻了,来看点源码理解一下 GC 流程。

何时会触发垃圾回收?

系统监控

懂行的都知道,gc 的入口是 gcStart,所以我们只需要顺着他的调用链路向上找,可以知道会有一个后台协程 forcegchelper 会重复检测是否满足 GC 的状态:

go 复制代码
// init 函数在包初始化时运行,启动一个强制 GC 的辅助 goroutine
func init() {
	go forcegchelper()  // 启动一个独立 goroutine,专门负责触发强制 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(系统监控 goroutine)唤醒
		goparkunlock(&forcegc.lock, waitReasonForceGCIdle, traceBlockSystemGoroutine, 1)

		if debug.gctrace > 0 {
			println("GC forced")
		}

		gcStart(gcTrigger{kind: gcTriggerTime, now: nanotime()})
	}
}

虽然是使用的 for 循环不断检测是否满足 gc 条件,但是这里有一个 gopark,稍微有了解 go 源码的都知道, gopark 意味着将这个协程挂起,也就是将 M 线程资源让出来,从而避免长时间阻塞在这里等待满足 gc 条件。那么什么时候会唤醒这个 goroutine 呢?答案写在注释里面,当系统监控觉得确实应该触发 GC 了,就会唤醒这个后台强制 GC 的 goroutine。

他是如何被唤醒的?可以在 sysmon() 的末尾找到答案,通过 gcTrigger 去判断是否应该触发强制 GC,如果应该 gc 了,那么就会将这个 goroutine 唤醒,其实就是将他放回调度队列里面:

go 复制代码
		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 ,什么时候会触发这个所谓的 newUserArenaChunk 呢?简单来说就是堆内存需要新申请的时候,此时就会去检测是否应该触发 GC,除此之外,检测是否应该触发 GC 的地方 mallocgc ,这是一个通用的分配内存的函数,总之,当我们申请内存的时候,我们都会去检查是否应该去触发 GC,就这么简单;除此之外说一句题外话,我们可以在这些 malloc 函数中看见读写屏障的具体逻辑,当开启了写屏障时,此时就会帮助直接标记为灰色。

gcStart 干了啥?

大体流程

剔除一些无关紧要的代码,如下所示:

go 复制代码
// gcStart 启动 Go 垃圾回收(GC)。
//
// trigger: 指示 GC 启动条件的触发器,例如堆大小超过阈值或手动触发。
//
// 注意:
// - 如果当前在系统栈上或持有锁,不会启动 GC。
// - 根据 debug.gcstoptheworld 的设置,可能执行并发 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

    // 系统栈执行 STW
    var stw worldStop
    systemstack(func() {
        stw = stopTheWorldWithSema(stwGCSweepTerm)
    })

    // 累计暂停时间
    work.cpuStats.accumulateGCPauseTime(stw.stoppingCPUTime, 1)

    // 在系统栈完成 sweep
    systemstack(func() {
        finishsweep_m()
    })

    // 清理对象池
    clearpools()

    // GC 周期计数加一
    work.cycles.Add(1)

    // 启用协助机制和工作线程
    gcController.startCycle(now, int(gomaxprocs), trigger)
    gcCPULimiter.startGCTransition(true, now)

    if mode != gcBackgroundMode {
        schedEnableUser(false) // STW 模式下禁止用户 goroutine 调度
    }

    // 进入并发 mark 阶段,并启用写屏障
    setGCPhase(_GCmark)
    gcBgMarkPrepare()
    // 这个函数挺重要的,会把所有的待扫描的对象空间分成多个 task。
    gcPrepareMarkRoots()
    gcMarkTinyAllocs()
    atomic.Store(&gcBlackenEnabled, 1)

    mp = acquirem()

    // 更新 CPU 统计信息
    work.cpuStats.accumulateGCPauseTime(nanotime()-stw.finishedStopping, work.maxprocs)

    // 并发 mark 开始,STW 停止
    systemstack(func() {
        now = startTheWorldWithSema(0, stw)
        work.pauseNS += now - stw.startedStopping
        work.tMark = now

        gcCPULimiter.finishGCTransition(now)
    })

	...
}

其中最值得注意的函数就是 gcBgMarkStartWorkersgcPrepareMarkRoots 这两个函数在我们之后的分析里面算是最重要的。

首先我们看看 gcBgMarkStartWorkers 干了什么,一串下去的链路是

gcBgMarkStartWorkers -> gcBgMarkWorker -> gcDrainMarkWorkerIdle -> gcDrain

而这个 gcDrain 函数就是最后我们需要分析的地方,这里很复杂。

gcDrain 会扫描 root 对象,不断将灰色对象标记为黑色,直到没有更多任务可以标记,gcDrain 并不是在一个专门的 M 上执行,所以我们需要考虑到其他业务任务的执行,如果长期执行 gcDrain 就会导致负责业务的 goroutine 饿死,所以 gcDrain 也提供了一些抢占点检查是否应该让出 M。

首先我们需要知道,这个抢占点的检查是什么:

go 复制代码
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 是什么?其实就是看当前程序中是否有网络 IO 就绪非阻塞调用一下 netpoll,查看是否有事件已经准备好了,防止 gc 阻塞了重要任务的执行。

第二个 pollFractionalWorkerExit 则是一个检测自己有没有执行过长时间,如果执行时间太长了,那么就会让出当前的 M 线程,让其他 goroutine 执行;在后续,我们每次循环标记的过程中都会去调用这个 check() 来防止任务被 GC 任务阻塞了,因为相对来说,GC 后台标记这个任务优先级是比较低的。

下面是第一个标记循环,目的很清晰,就是从之前提到的 gcPrepareMarkRoots 中的 Tasks 中通过原子操作去获取一个 Task 来标记,同时,在每次任务标记完之后,就会检查一遍是否应该让出 M 线程。这里的原子操作保证了 go 中的 GC 可以并发安全的进行标记,而 markroot 就是对所有的 root 对象进行扫描标记,root 是可达活对象的起点,包括但不限于全局变量,栈。

go 复制代码
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 包含那些内存数据,我们可以在之前提过的 gcPrepareMarkRoots 里面找到:

go 复制代码
// gcPrepareMarkRoots 准备 GC 根对象扫描任务
func gcPrepareMarkRoots() {
    // 确认此时世界已停止(STW),防止在扫描过程中有 goroutine 修改 root
    assertWorldStopped()

    // 用于计算需要多少个 root block(数据块)来存储给定字节数的 root
    nBlocks := func(bytes uintptr) int {
        return int(divRoundUp(bytes, rootBlockBytes))
    }

    // 初始化 data 和 BSS root 的数量
    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 栈
    // 注意,之后新创建的 goroutine 不会被扫描,但它们的 root 会被写屏障捕获
    work.stackRoots = allGsSnapshot()
    work.nStackRoots = len(work.stackRoots)

    // 初始化 root 扫描任务索引
    work.markrootNext = 0
    // 总共需要扫描的 root 数量
    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 的结束索引
}

这里其实就是做了一些计算工作,将当前的程序中的一些内存数据保存下来,并分块成多个 task,方便之后并发的进行标记处理,我们不需要太过于在意这些数据是怎么得出来的,只需要知道是这么回事即可。

那么我们标记 root 之后,我们便需要去标记 heap 对象了,此时我们当然需要依赖之前从 root 中标记的对象去标记 heap 内存中的对象:

go 复制代码
// 这是 GC 的 heap 标记循环,用于从灰色对象队列中继续标记对象,直到队列为空或需要暂停。
// 此循环在 GC 的标记阶段执行(_GCmark)。
//
// 循环条件:如果当前 G 被标记为可抢占,并且满足以下任意条件则停止循环:
// - preemptible 为 true
// - sched.gcwaiting 表示有人想触发 STW(Stop The World)
// - pp.runSafePointFn != 0 表示有 P 正在执行安全点函数
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 指针

    // 尝试按优先级获取灰色对象
    if b = gcw.tryGetObjFast(); b == 0 {          // 优先尝试快速队列
        if s = gcw.tryGetSpan(false); s == 0 {   // 没有对象,尝试获取 span
            if b = gcw.tryGetObj(); b == 0 {     // 再尝试普通队列
                wbBufFlush()                     // 写屏障缓冲区 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 {
        // 队列空,无法获取更多工作,循环结束
        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
            }
        }
    }
}

到这里其实已经把 GC 的逻辑梳理的差不多了,其他诸如 STW,StartTheWorld 都没有讲述。但是其实我们还可以更细粒度的去看看 tryget 还有 markroot 都干了些什么,这里有点不太想贴源码,就直接口述了。

迭代标记

markroot的函数签名是 markroot(gcw *gcWork, i uint32, flushBgCredit bool) int64,这个 i 就是所谓的 taskId,我们可以根据这个 id 找到当前需要进行扫描标记的区域,大多数都是调用 scanblock 去扫描的,而在这个函数中,经历一系列复杂的变换和扫描,由于我不懂 GC 的扫描逻辑,所以就不乱讲,最终我们会把扫描到的可达对象通过 greyobject 将这个对象标记为灰色 ,如果对象不可扫描,则标记为黑色,在将他标记为灰色之后,我们还会将他通过 gcw.putObj 放入到本地的标记处理队列里面,这一步的意义其实就是迭代处理,在第二阶段标记的时候,我们也是最终会调用 greyobject 将这个函数染灰,并推送到本地标记处理队列里面,用于迭代处理,思想上有点类似广度优先搜索。

go 复制代码
// greyobject 将一个堆对象标记为灰色(可扫描),并将其加入到 P 的本地工作队列 gcw 中,以便后续扫描其内部指针。
// 如果对象不可扫描(noscan),则直接标记为黑色。
//
// 参数说明:
// obj       : 要标记的对象起始地址
// base, off : 调试信息,用于记录对象是通过哪个 root 扫描到的
// span      : 对象所在的内存 span
// gcw       : 当前 P 的本地 GC 工作队列
// objIndex  : 对象在 span 中的索引
//
// go:nowritebarrierrec 表示此函数不会触发写屏障,且可递归调用
func greyobject(obj, base, off uintptr, span *mspan, gcw *gcWork, objIndex uintptr) {
	...

	// 将对象加入 P 的本地工作队列,以便后续 scanobject 扫描其指针
	if !gcw.putObjFast(obj) { // 快速入队
		gcw.putObj(obj)       // 慢速入队(如果快速失败)
	}
}

你可能会注意到,第二阶段的扫描只调用了 scanobject ,实际上,它内部也是调用的 greyobject,他会将这个对象引用的指针通过 greyobject 变为灰色,并放入本地工作队列,以便于下一次的迭代。

go 复制代码
// scanobject 扫描以 b 开头的堆对象,将对象内部的指针加入 gcw 队列。
// b 必须指向一个堆对象或 oblet(大对象的分块)。
// scanobject 会根据 GC 的位图获取指针掩码,并通过 span 获取对象大小。
//
//go:nowritebarrier 表示该函数不会触发写屏障。
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
			}
		}

		// 更新扫描范围,用于统计 heapScanWork
		scanSize = addr - b + goarch.PtrSize

		// 读取对象中的潜在指针
		obj := *(*uintptr)(unsafe.Pointer(addr))

		// 过滤掉 nil 和指向当前对象内部的指针
		if obj != 0 && obj-b >= n {
			// 判断 obj 是否指向 Go 堆中的对象,如果是则标记
			// 注意可能存在与分配同时发生的竞争,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 工作队列的统计信息
	gcw.bytesMarked += uint64(n)
	gcw.heapScanWork += int64(scanSize)
	if debug.gctrace > 1 {
		gcw.stats[s.spanclass.sizeclass()].sparseObjsScanned++
	}
}

综上所述,三色标记的大致的流程如下:

复制代码
markroot(扫描 root 对象:全局变量、栈、span specials)
    ↓
发现堆对象 → greyobject → 标灰 + 入本地队列 gcw
    ↓
heap 扫描阶段(drain heap marking jobs)
    ↓
从 gcw 队列取灰对象(tryGetObj/tryGetSpan)
    ↓
scanobject 扫描对象内部指针
    ↓
扫描出的新对象 → greyobject → 入 gcw 队列(迭代处理)
    ↓
重复直到队列为空 → 所有可达对象都被标记

这下我们知道了,网上图解的三色标记法其实就是一个在三色的基础上进行广度优先搜索,图还是很生动形象的,然而,有的东西也需要真正去看这部分逻辑才能学到,GC 不仅仅就是个垃圾回收,他的运行过程还和系统监控,网络轮询器有着一定的关系,感觉看源码有助于对整个 runtime 的认知,虽然我把 STW,读写屏障还有三色标记的具体算法没有重点讲解,但是本篇文章主要注重逻辑梳理。

一些优化

除了上面所说的标记以外,我们的 mallocgc 其实也会在 gc 阶段帮助我们进行部分标记工作,这就是我们常说的 Mutator Assist 优化

go 复制代码
// mallocgc 分配一个指定大小的对象。
// 小对象从 P(处理器本地)缓存的 free list 分配。
// 大对象(> 32 KB)直接从堆分配。
// mallocgc 是 runtime 内部接口,但一些第三方库通过 //go:linkname 调用。
// 请勿修改函数签名,否则可能破坏 runtime。
//
// 参数:
//   size     - 需要分配的字节数
//   typ      - 对象类型信息 (_type),用于 GC 扫描指针;nil 表示 noscan
//   needzero - 是否需要将分配的内存清零
//
// 返回值:
//   unsafe.Pointer - 指向分配好的对象
//
// go:linkname 指令允许其他包直接调用 runtime.mallocgc
//
// mallocgc 核心功能:
// 1. 检查 GC assist,决定 mutator 是否需要帮忙做标记。
// 2. 根据对象大小选择 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) // 扣除助理信用,并可能触发 gcDrain
	}

	...
}

通过上面的分析,我们可以发现,我们的 GC 是通过广度优先搜索的方式去从堆上扫描对象来进行回收,也就是说,如果堆上的内存小,但是对象多,就会给 GC 带来很大的压力,所以这就是我们需要进行逃逸分析,在一些情况下尽量避免内存逃逸到堆上,看完这部分我觉得《Go 语言设计与实现》讲的是真的不错,但是真的得自己再去看看源码才能把整个链路搞明白。

相关推荐
代码不停1 小时前
Java字符串 和 队列 + 宽搜 题目练习
java·开发语言
ULTRA??1 小时前
JPS路径规划(python AI实现)
开发语言·人工智能·python
山峰哥1 小时前
从指针到智能体:我与C++的二十年技术进化与AI革命
大数据·开发语言·数据结构·c++·人工智能
古城小栈1 小时前
深入解析Go泛型中的~struct{}
开发语言·后端·golang
开心香辣派小星1 小时前
23种设计模式-18观察者(Observer)模式
java·开发语言·设计模式
Slow菜鸟1 小时前
Java项目基础架构(一)| 工程架构选型指南
java·开发语言·架构
CoderYanger1 小时前
动态规划算法-斐波那契数列模型:1.第N个泰波那契数
开发语言·算法·leetcode·动态规划·1024程序员节
zore_c1 小时前
【C语言】文件操作详解2(文件的顺序读写操作)
android·c语言·开发语言·数据结构·笔记·算法·缓存
weixin_421133411 小时前
PyInstaller& Nuitka & 项目 (如 django)
后端·python·django