理解 Go GC :从原理到实践

219 阅读31分钟

什么是GC

由于计算机的内存是有限的存储,所以需要将程序中申请的内存进行合理的管理,即申请和释放,在需要的时候去申请合适大小的内存,在不需要的时候将内存回收以便进行内存的复用。GC (garbage collector) 就一种自动内存管理的机制,除此之外,常见的编程语言内存管理方式还有:

  1. 手动内存管理。典型代表是C/C++,需要开发者手动的去malloc/free效率最高,但是开发者心智负担高,易出现内存泄漏(memory leak)问题。
  2. 自动内存管理。典型代表是java、go,也是我们下文要介绍的内容,开发者只需要在自己需要的时候进行申请内存,释放内存由语言层面进行支持,开发者心智负担小,性能较无GC语言差。
  3. 所有权机制。典型代表是rust,也是rust的特色,内存安全且无GC,利用所有权、借用和生命周期机制,将“gc”放在编译层面,这样rust就知道在合适的地方插入“free”去释放内存,学习成本较高,性能好。

上图是内存管理的三个参与者,Mutator用户程序、Allocator内存分配器和Collector垃圾回收。用户程序Mutator会通过内存分配器Allocator去申请内存Memory,而垃圾回收器Collector则会回收无用的堆内存。

gc的范围

栈内存

哪些值不需要由gc去管理?比如局部的非指针变量就不需要gc管理,这部分内存分配在函数栈上,栈内存由函数负责申请和释放,每个goroutine都有自己的栈内存,这种申请方式叫做stack allocation,这比gc管理更加高效。

堆内存

而对于不能确定生命周期的变量,也就不能分配到栈上了,需要分配到堆上,我们一般会把这个叫做:逃逸到堆(escape to the heap)。 因为编译器和runtime都需要做一点额外的工作来保证内存是如何使用以及何时释放的,所以这种在堆上申请内存的方式,也叫做动态内存分配(dynamic memory allocation)。这也是GC要做的事情:识别和清理动态分配的内存。

逃逸分析

上文中我们提到了escape to the heap, 这也是go非常重要的一个特性,开发者不需要关心他的变量是在堆上还是在栈上,编译器会做好一切,逃逸分析有两个好处。

  1. 使程序更安全,自动将变量分配到堆上,防止栈上变量因函数结束被回收掉,导致段错误(segment fault),例如经典的C程序。
int * Numfunc() {
    int num = 1234;
    /* ... */
    return #
}

//再次引用这个函数的返回值,会触发segment fault

2. 降低开发者的心智负担,无须自主选择变量的分配逻辑,再结合GC,在应用层屏蔽掉了堆和栈的概念。

在传统的编程语言中(比如c++)使用new关键字分配的变量会分配到堆上,直接赋值的变量会分配到栈上,go虽然内置了new关键字,但是也不保证new后的变量一定分配到堆上,而是根据逃逸分析决定所有的变量分配到栈上还是堆上,分配到栈上的变量,在函数栈销毁时即可被清理;分配到堆上的变量则会有GC防止泄露问题。

Student *student = new Student;//堆分配
int id=0; //栈分配

有多种情况会导致变量逃逸到堆上,这由go编译器的逃逸分析算法决定。

  1. 动态内存块大小,比如使用变量分配slice空间而不是常量。
  2. 指针逃逸,比如函数返回一个指针变量,会导致指针指向的空间逃逸到堆上。
  3. 过大的内存分配,由于栈的大小限制,过大的变量也会逃逸到堆上。
  4. 可传递的,如果一个变量被已经逃逸到堆上的变量写入,那么这个变量也逃逸到堆上。

等等,逃逸分析正在随着go的发布而逐步的去建设,更多的可以参考:Eliminating heap allocations

我们可以通过go build -gcflags '-m -l'` main.go来查看程序中的变量堆逃逸。

package main

import "fmt"

func main() {
    a := 666
    fmt.Println(a)
}

go build -gcflags '-m -l' main.go
# command-line-arguments
./main.go:7:13: ... argument does not escape
./main.go:7:13: a escapes to heap

可以通过再增加一个-m参数,来查看更多逃逸细节,这里可以看到a逃逸到堆上是由于传参给fmt.Println

$ go build -gcflags '-m -m -l' main.go
# command-line-arguments
./main.go:7:13: a escapes to heap:
./main.go:7:13:   flow: {storage for ... argument} = &{storage for a}:
./main.go:7:13:     from a (spill) at ./main.go:7:13
./main.go:7:13:     from ... argument (slice-literal-element) at ./main.go:7:13
./main.go:7:13:   flow: {heap} = {storage for ... argument}:
./main.go:7:13:     from ... argument (spill) at ./main.go:7:13
./main.go:7:13:     from fmt.Println(... argument...) (call parameter) at ./main.go:7:13
./main.go:7:13: ... argument does not escape

GC的几种实现

  1. 引用计数(reference counting)。

在每个对象中保存该对象的使用计数,引用一次则+1,不被引用则-1,当引用计数减为0时,则删除该对象。优点是实现简单,缺点容易出现循环引用,如下图。

image.png

  1. 标记追踪(tracing)。

也是go主要采用的,使用传递的指针来标记正在使用的Go值或者说存活的Go值。

更严谨的说法有两类实体

  • 对象(object):对象是一个动态分配的内存块,包括一个或多个Go值
  • 指针(pointer):指向对象内任何值的内存地址,当然也包括*T,同时也包含Go内置的一些Go值,例如string、slice、channel、map和interface。

而标记追踪又有几种经典的实现。

2.1 标记清除(Mark and Sweep)。

每次GC从根对象开始,逐步遍历所引用到的对象,最后清除标记不到的对象。

这也是go gc采用的gc策略,再结合go的采用类似tcmalloc的动态大小内存分配器,极大地缓解了内存碎片的问题。

  • 优点:简单快速。
  • 缺点:有内存碎片。

2.2 标记复制(Mark and Sweep)。

仅使用一半的空间,每次GC从跟对象开始,逐步遍历所引用到的对象,将使用到的对象复制到另一半空间,然后清楚原空间的全部内容。

  • 优点:无内存碎片。
  • 缺点:内存利用率低,仅可使用一半内存,且复制耗时。

2.3 标记整理(Mark and Compact)。

结合了标记清楚和标记复制实现,在标记清楚的基础上,每次GC将存活的对象整理到紧凑的空间内。

  • 优点:无内存碎片,且可以使用完整内存。
  • 缺点:复制耗时。

2.4 分代回收(Generation Collect )。

源于分代假说,有两种分代假说:

  • 弱分代假说 (Weak Generational Hypothesis):绝大多数对象都是朝生夕灭的。
  • 强分代假说 (Strong Generational Hypothesis):熬过越多次垃圾收集过程的对象就越难以消亡。

根据对象的存活时间将内存分成多块,不同块采用不同的策略,Java的堆分配算法就是基于分代假说的。

下面我们介绍下Go GC的实现,以下的内容基于go 1.19。

演进历史

  • Go 1:串行三色标记清扫
  • Go 1.3:并行清扫,标记过程需要 STW,停顿时间在约几百毫秒
  • Go 1.5:并发标记清扫,STW在一百毫秒以内
  • Go 1.6:使用 bitmap 来记录回收内存的位置,大幅优化垃圾回收器自身消耗的内存,STW在十毫秒以内
  • Go 1.7:停顿时间控制在两毫秒以内
  • Go 1.8:混合写屏障,STW在0.5毫秒左右
  • Go 1.9:彻底移除了栈的重扫描过程
  • Go 1.12:整合了两个阶段的 Mark Termination
  • Go 1.13:着手解决向操作系统归还内存的,提出了新的 Scavenger
  • Go 1.14:替代了仅存活了一个版本的 scavenger,全新的页分配器,优化分配内存过程的速率与现有的扩展性问题,并引入了异步抢占,解决了由于密集循环导致的 STW 无法抢占,进而导致 STW时间过长的问题
  • GO 1.19:引入GOMEMLIMIT,以实现软内存限制

STW

image.png

在介绍GC实现之前,我们先介绍下 STW,go 的 gc 也是带有 stw 的,stw(Stop The World),顾名思义就是暂停程序的运行,以防止因并发操作导致GC 状态设置的有问题。

go 在每一轮 GC 中有两次短暂STW,分别在

  1. Sweep Termination 切换到 Mark,也就是我们上文介绍的gcStart函数。

    1. 清理未清扫的 span
    2. runtime 内存清理(比如 sync.pool )
    3. 打开写屏障
    4. 计算markRoot工作量
  2. Mark Termination 切换到 Sweep。

    1. 关闭写屏障
    2. gc统计信息计算及 trace 记录
    3. 唤醒清扫协程

可以看到 STW 期间做的事情还是比较少的,STW 的延迟是评价一个 GC 系统非常好的指标,go 官方宣称目前STW 的延迟是亚毫秒级。

GO GC 实现

由于Go的GC采用的是标记-清扫,所以大概分成了两个阶段,标记阶段清扫阶段。听起来好像是废话.....。不过这其实包含了一个很重要的概念,在标记阶段结束前,是不会进行内存释放操作,因为仍然可能有未扫描到的指针指向了我们认为的非活对象。所以清扫阶段和标记阶段必须完全区分开。此外当GC没有相关的工作时,GC也可能根本不活动(关闭阶段)。GC在清扫结束、标记、标记结束和清扫这四个状态间不断循环。这就是 gc cycle

image.png

上述的四个状态,对应到源码里 GC 分为三个阶段,状态和阶段映射关系在上图中也有表示。

源码中由一个全局变量gcphase标识当前 GC 的运行阶段,在runtime/mgc.go。

// Garbage collector phase.
// Indicates to write barrier and synchronization task to perform.
var gcphase uint32

const (
    _GCoff             = iota // GC not running; sweeping in background, write barrier disabled
    _GCmark                   // GC marking roots and workbufs: allocate black, write barrier ENABLED
    _GCmarktermination        // GC mark termination: allocate black, P's help GC, write barrier ENABLED
)

通过 setGCPhase函数来变更 gcphase 变量,实现也很简单,就是atomic操作,运行阶段的变化,也会影响写屏障的开启与否。

//go:nosplit
func setGCPhase(x uint32) {
    atomic.Store(&gcphase, x)
    
    //判断写屏障是否开启
    writeBarrier.needed = gcphase == _GCmark || gcphase == _GCmarktermination
    writeBarrier.enabled = writeBarrier.needed || writeBarrier.cgo
}

和 phase相关的,还有一个全局变量,gcBlackenEnabled,若为 1,则mutator和 mark worker可以将相关对象染色为黑色。

// gcBlackenEnabled is 1 if mutator assists and background mark
// workers are allowed to blacken objects. This must only be set when
// gcphase == _GCmark.
var gcBlackenEnabled uint32

还有一个非常关键的全局变量work,包含了 GC 执行的上下文信息,在 GC 的全流程中都会用到,在后文我们用到它的字段,再做相关解释

//标识 GC 的上下文
var work workType

type workType struct {
    full  lfstack          // lock-free list of full blocks workbuf
    empty lfstack          // lock-free list of empty blocks workbuf
    pad0  cpu.CacheLinePad // prevents false-sharing between full/empty and nproc/nwait
    
    wbufSpans struct {
       lock mutex
       // free is a list of spans dedicated to workbufs, but
       // that don't currently contain any workbufs.
       free mSpanList
       // busy is a list of all spans containing workbufs on
       // one of the workbuf lists.
       busy mSpanList
    }
    
    // Restore 64-bit alignment on 32-bit.
    _ uint32
    
    // bytesMarked is the number of bytes marked this cycle. This
    // includes bytes blackened in scanned objects, noscan objects
    // that go straight to black, and permagrey objects scanned by
    // markroot during the concurrent scan phase. This is updated
    // atomically during the cycle. Updates may be batched
    // arbitrarily, since the value is only read at the end of the
    // cycle.
    //
    // Because of benign races during marking, this number may not
    // be the exact number of marked bytes, but it should be very
    // close.
    //
    // Put this field here because it needs 64-bit atomic access
    // (and thus 8-byte alignment even on 32-bit architectures).
    bytesMarked uint64
    
    markrootNext uint32 // next markroot job
    markrootJobs uint32 // number of markroot jobs
    
    nproc  uint32
    tstart int64
    nwait  uint32
    
    // Number of roots of various root types. Set by gcMarkRootPrepare.
    //
    // nStackRoots == len(stackRoots), but we have nStackRoots for
    // consistency.
    nDataRoots, nBSSRoots, nSpanRoots, nStackRoots int
    
    // Base indexes of each root type. Set by gcMarkRootPrepare.
    baseData, baseBSS, baseSpans, baseStacks, baseEnd uint32
    
    // stackRoots is a snapshot of all of the Gs that existed
    // before the beginning of concurrent marking. The backing
    // store of this must not be modified because it might be
    // shared with allgs.
    stackRoots []*g
    
    // Each type of GC state transition is protected by a lock.
    // Since multiple threads can simultaneously detect the state
    // transition condition, any thread that detects a transition
    // condition must acquire the appropriate transition lock,
    // re-check the transition condition and return if it no
    // longer holds or perform the transition if it does.
    // Likewise, any transition must invalidate the transition
    // condition before releasing the lock. This ensures that each
    // transition is performed by exactly one thread and threads
    // that need the transition to happen block until it has
    // happened.
    //
    // startSema protects the transition from "off" to mark or
    // mark termination.
    startSema uint32
    // markDoneSema protects transitions from mark to mark termination.
    markDoneSema uint32
    
    bgMarkReady note   // signal background mark worker has started
    bgMarkDone  uint32 // cas to 1 when at a background mark completion point
    // Background mark completion signaling
    
    // mode is the concurrency mode of the current GC cycle.
    mode gcMode
    
    // userForced indicates the current GC cycle was forced by an
    // explicit user call.
    userForced bool
    
    // initialHeapLive is the value of gcController.heapLive at the
    // beginning of this GC cycle.
    initialHeapLive uint64
    
    // assistQueue is a queue of assists that are blocked because
    // there was neither enough credit to steal or enough work to
    // do.
    assistQueue struct {
       lock mutex
       q    gQueue
    }
    
    // sweepWaiters is a list of blocked goroutines to wake when
    // we transition from mark termination to sweep.
    sweepWaiters struct {
       lock mutex
       list gList
    }
    
    // cycles is the number of completed GC cycles, where a GC
    // cycle is sweep termination, mark, mark termination, and
    // sweep. This differs from memstats.numgc, which is
    // incremented at mark termination.
    cycles atomic.Uint32
    
    // Timing/utilization stats for this cycle.
    stwprocs, maxprocs                 int32
    tSweepTerm, tMark, tMarkTerm, tEnd int64 // nanotime() of phase start
    
    pauseNS    int64 // total STW time this cycle
    pauseStart int64 // nanotime() of last STW
    
    // debug.gctrace heap sizes for this cycle.
    heap0, heap1, heap2 uint64
    
    // Cumulative estimated CPU usage.
    cpuStats
}

init

gc 的初始化过程包含在go进程的启动过程中,有两个核心的函数:

runtime/mgc.go

  • runtime.gcinit,在runtime.schedinit中被调用,主要是加载GOGC、GOMEMLIMIT环境变量做GC pacing(触发 GC 的时机)算法的初始化
  • runtime.gcenable,在 runtime.main 中被调用,启动后台回收(sweep)协程,sweep 的细节我们留到后边再去讲,并通知runtime:gc 已经就绪。

还有另外一个关键的全局变量runtime.gcMode,标识 GC 循环执行的模式,分别有以下取值,默认情况下会使用 gcBackgroundMode 运行 GC,可以通过启动命令进行配置。

  1. gcBackgroundMode,默认的运行模式,并发标记和并发清扫。
  2. gcForceMode,强制模式,STW 标记和并发清扫,启动时通过godebug=gcstoptheworld=2设置
  3. gcForceBlockMode,强制阻塞模式,STW标记和 STW 清扫,启动时通过godebug=gcstoptheworld=1设置
// gcMode indicates how concurrent a GC cycle should be.
type gcMode int

const (
    gcBackgroundMode gcMode = iota // concurrent GC and sweep
    gcForceMode                    // stop-the-world GC now, concurrent sweep
    gcForceBlockMode               // stop-the-world GC now and STW sweep (forced by user)
)

触发GC

gc 有多个触发的途径,多个触发途径最终都是调用runtime.gcStart函数,gcStart 的函数原型如下:

func gcStart(trigger gcTrigger)

// A gcTrigger is a predicate for starting a GC cycle. Specifically,
// it is an exit condition for the _GCoff phase.
type gcTrigger struct {
    kind gcTriggerKind
    now  int64  // gcTriggerTime: current time
    n    uint32 // gcTriggerCycle: cycle number to start
}

type gcTriggerKind int

const (
    // gcTriggerHeap indicates that a cycle should be started when
    // the heap size reaches the trigger heap size computed by the
    // controller.
    gcTriggerHeap gcTriggerKind = iota
    
    // gcTriggerTime indicates that a cycle should be started when
    // it's been more than forcegcperiod nanoseconds since the
    // previous GC cycle.
    gcTriggerTime
    
    // gcTriggerCycle indicates that a cycle should be started if
    // we have not yet started cycle number gcTrigger.n (relative
    // to work.cycles).
    gcTriggerCycle
)

其接收一个入参 gcTrigger,并且没有返回值,gcTrigger中有一个关键的字段 kind,其取值就代表了三种触发途径:

  1. gcTriggerHeap,到达堆内存限制前触发。是最常见的触发方式,我们通过 go 提供的环境变量可以进行调整,判断触发调用的协程是用户协程,申请堆内存时,在runtime.mallocgc中被调用,是否开启 GC 由func (t gcTrigger) test() bool 函数调用 pacing 算法 判断。
  2. gcTriggerTime,周期性的触发。如果在forcegcperiod时间内没有触发 gc,那么会由 sysmon线程启动一次gc,目前这个时间段在源码里写死的是 2 分钟。
  3. gcTriggerCycle,用户手动调用 runtime.GC函数触发。与其他的 GC 触发不同的是,这个函数会阻塞用户协程,直到 GC sweep 完成。

gcTrigger有一个实现的函数test,判断 gcTrigger 传入的 kind是否满足条件。

// test reports whether the trigger condition is satisfied, meaning
// that the exit condition for the _GCoff phase has been met. The exit
// condition should be tested when allocating.
func (t gcTrigger) test() bool {
//如果没有开启 GC、或者GC 已经在运行中了
    if !memstats.enablegc || panicking.Load() != 0 || gcphase != _GCoff {
       return false
    }
    switch t.kind {
    //堆内存触发方式
    case gcTriggerHeap:
       // Non-atomic access to gcController.heapLive for performance. If
       // we are going to trigger on this, this thread just
       // atomically wrote gcController.heapLive anyway and we'll see our
       // own write.
       //判断内存条件是否满足
       trigger, _ := gcController.trigger()
       return gcController.heapLive.Load() >= trigger
    // 定时触发方式,判断时间是否满足
    case gcTriggerTime:
       if gcController.gcPercent.Load() < 0 {
          return false
       }
       lastgc := int64(atomic.Load64(&memstats.last_gc_nanotime))
       return lastgc != 0 && t.now-lastgc > forcegcperiod
    // 手动触发方式,避免并发调用 runtime.GC
    case gcTriggerCycle:
       // t.n > work.cycles, but accounting for wraparound.
       return int32(t.n-work.cycles.Load()) > 0
    }
    return true
}

pacing 调步

因为 GO 的 垃圾回收器和内存分配器是并发执行的,所以如果我们当内存达到上限时再去触发 GC,那就有可能导致 GC 期间分配了更多的内存,进而导致进程内存不足,所以在达到上限前需要提前触发 GC,以保证进程不被 OOM kill,但是如果太早触发,那么 GC 的频率又会提高很多且内存利用率降低,相关示意见下图

image.png

GOGC 的内存触发公式 :Target heap memory(目标大小) = Live heap(存活大小) + (Live heap + GC roots) * GOGC / 100

所以如何减小图中最后这部分浪费的内存,就是 pacing(调步算法) ,优化并发式 GC 的步调,换句话说就是什么时候触发下一次 GC。

pacing算法会控制gc使用的cpu时间,目前限制是写死的常量0.25,也就是GC占用CPU时间不能超过25%。

const (
    // gcBackgroundUtilization is the fixed CPU utilization for background
    // marking. It must be <= gcGoalUtilization. The difference between
    // gcGoalUtilization and gcBackgroundUtilization will be made up by
    // mark assists. The scheduler will aim to use within 50% of this
    // goal.
    //
    // As a general rule, there's little reason to set gcBackgroundUtilization
    // < gcGoalUtilization. One reason might be in mostly idle applications,
    // where goroutines are unlikely to assist at all, so the actual
    // utilization will be lower than the goal. But this is moot point
    // because the idle mark workers already soak up idle CPU resources.
    // These two values are still kept separate however because they are
    // distinct conceptually, and in previous iterations of the pacer the
    // distinction was more important.
    gcBackgroundUtilization = 0.25
)

mark 标记

三色标记

alloc/markbits + gcw队列 + shade 函数 + 写屏障,就是三色标记的核心实现

当前版本的go(go1.19)采用三色标记法来进行垃圾数据的识别和标记,三色标记法中对指针有三个分类

  1. 黑色节点:已经扫描过,并且所有的子节点都扫描完了。
  2. 灰色节点:已经扫描完,但是子节点没有扫描完。
  3. 白色节点:未扫描。

三色标记法主要是为了让gc可以和应用程序并发的执行,这样就可以把标记阶段拆成多次来执行,而不用一直等待gc执行。

初始所有对象都是白色的,gc扫描会先确定根对象,根对象指的是全局数据和 协程 的指针,这两个区域的指针可以指向堆区的内容,标记过程首先会将根对象放入待扫描队列(灰色),再按广度优先 遍历的顺序扫描他们的子节点,将扫描到子节点加入到队列,并将当前节点移出队列,直到待扫描队列为空,这时所有的对象都是黑色和白色的,剩下的白色对象的就是垃圾,可以被 GC 回收掉。

执行扫描的协程是GC Mark Worker,每个P都会有一个标配的gc mark worker,而P的数量取决于*GOMAXPROCS,所以在程序初始化时就会启动GOMAXPROCS*个GC Mark Worker。

染色

image.png

源码中对三色标记法的实现并没有真正的三色都做标记,而是通过队列+bit位来进行标记

  1. 灰色对象,gcw队列,队列中的对象是待扫描对象
  2. 黑色对象,对象所在span的gcmarkBits(bitmap),若为1则是黑色对象(已标记)
  3. 白色对象,对象所在span的gcmarkBits(bitmap),若为0则是白色对象(未标记)
// Shade the object if it isn't already.
// The object is not nil and known to be in the heap.
// Preemption must be disabled.
//go:nowritebarrier
func shade(b uintptr) {
//shade会先用findobject从指定的地址中提取出对象和对象所在的span,及在span中的index
//接着继续调用greyobject做染色操作
    if obj, span, objIndex := findObject(b, 0, 0); obj != 0 {
       gcw := &getg().m.p.ptr().gcw
       greyobject(obj, 0, 0, span, gcw, objIndex)
    }
}

// obj is the start of an object with mark mbits.
// If it isn't already marked, mark it and enqueue into gcw.
// base and off are for debugging only and could be removed.
func greyobject(obj, base, off uintptr, span *mspan, gcw *gcWork, objIndex uintptr) {
    ...
    mbits := span.markBitsForIndex(objIndex) //定位当前对象所在的bit位,准备染黑色(置1)
    if useCheckmark {
       if setCheckmark(obj, base, off, mbits) {
          // Already marked.
          return
       }
    } else {
       if debug.gccheckmark > 0 && span.isFree(objIndex) {
          print("runtime: marking free object ", hex(obj), " found at *(", hex(base), "+", hex(off), ")\n")
          gcDumpObject("base", base, off)
          gcDumpObject("obj", obj, ^uintptr(0))
          getg().m.traceback = 2
          throw("marking free object")
       }

       // If marked we have nothing to do.
       if mbits.isMarked() {
          return
       }
       //执行染色操作
       mbits.setMarked()

       // Mark span.
       arena, pageIdx, pageMask := pageIndexOf(span.base())
       if arena.pageMarks[pageIdx]&pageMask == 0 {
          atomic.Or8(&arena.pageMarks[pageIdx], pageMask)
       }

       // If this is a noscan object, fast-track it to black
       // instead of greying it.
//如果span中无待扫描对象,那就直接置黑,不进队列继续扫描
       if span.spanclass.noscan() {
          gcw.bytesMarked += uint64(span.elemsize)
          return
       }
    }

    // We're adding obj to P's local workbuf, so it's likely
    // this object will be processed soon by the same P.
    // Even if the workbuf gets flushed, there will likely still be
    // some benefit on platforms with inclusive shared caches.
    sys.Prefetch(obj)
    // Queue the obj for scanning.
//对象置灰后,将对象放入待扫描队列执行扫描
    if !gcw.putFast(obj) {
       gcw.put(obj)
    }
}
}


type markBits struct {
    bytep *uint8 //bitmap中某个byte
    mask  uint8 //待置1的bit,通过or操作置1
    index uintptr
}
// setMarked sets the marked bit in the markbits, atomically.
func (m markBits) setMarked() {
    // Might be racing with other updates, so use atomic update always.
    // We used to be clever here and use a non-atomic update in certain
    // cases, but it's not worth the risk.
    //通过or操作对指定的bit置1
    atomic.Or8(m.bytep, m.mask)
}

剪枝

在标记的过程中不可避免的会出现重复引用的问题,为了减少重复引用带来的额外开销,也会进行剪枝的判断(空间换时间)。

多个节点引用一个节点

剪枝的源码判断流程,还是比较简单的,只需要判断标记位即可,不过由于 GC 的标记过程是并发的,所以判断标记位需要用atomic变量。

package runtime


    // If marked we have nothing to do.
    if mbits.isMarked() {
       return
    }
    mbits.setMarked()

// setMarked sets the marked bit in the markbits, atomically.
func (m markBits) setMarked() {
   // Might be racing with other updates, so use atomic update always.
   // We used to be clever here and use a non-atomic update in certain
   // cases, but it's not worth the risk.
   //使用原子操作,保证并发标记安全
   atomic.Or8(m.bytep, m.mask)
}

根对象扫描

根对象扫描的核心代码就是runtime.markroot,主要包含三部分

  1. 未初始化的全局变量bss段中包含指针的对象。
  2. 已初始化的全局变量data段中包含指针的对象。
  3. 所有goroutine栈上包含指针的对象。
func markroot(gcw *gcWork, i uint32, flushBgCredit bool) int64 {
    // Note: if you add a case here, please also update heapdump.go:dumproots.
    var workDone int64
    var workCounter *atomic.Int64
    switch {
    case work.baseData <= i && i < work.baseBSS:
       //扫描 bss段
       workCounter = &gcController.globalsScanWork
       for _, datap := range activeModules() {
          workDone += markrootBlock(datap.data, datap.edata-datap.data, datap.gcdatamask.bytedata, gcw, int(i-work.baseData))
       }

    case work.baseBSS <= i && i < work.baseSpans:
       //扫描 data段
       workCounter = &gcController.globalsScanWork
       for _, datap := range activeModules() {
          workDone += markrootBlock(datap.bss, datap.ebss-datap.bss, datap.gcbssmask.bytedata, gcw, int(i-work.baseBSS))
       }
       
  ......扫描span、finalizer 省略
  

    //扫描协程栈
    default:
       // the rest is scanning goroutine stacks
       workCounter = &gcController.stackScanWork
       if i < work.baseStacks || work.baseEnd <= i {
          printlock()
          print("runtime: markroot index ", i, " not in stack roots range [", work.baseStacks, ", ", work.baseEnd, ")\n")
          throw("markroot: bad index")
       }
       gp := work.stackRoots[i-work.baseStacks]

       // remember when we've first observed the G blocked
       // needed only to output in traceback
       status := readgstatus(gp) // We are not in a scan state
       if (status == _Gwaiting || status == _Gsyscall) && gp.waitsince == 0 {
          gp.waitsince = work.tstart
       }

       // scanstack must be done on the system stack in case
       // we're trying to scan our own stack.
       systemstack(func() {
          // If this is a self-scan, put the user G in
          // _Gwaiting to prevent self-deadlock. It may
          // already be in _Gwaiting if this is a mark
          // worker or we're in mark termination.
          userG := getg().m.curg
          selfScan := gp == userG && readgstatus(userG) == _Grunning
          if selfScan {
             casGToWaiting(userG, _Grunning, waitReasonGarbageCollectionScan)
          }

          // TODO: suspendG blocks (and spins) until gp
          // stops, which may take a while for
          // running goroutines. Consider doing this in
          // two phases where the first is non-blocking:
          // we scan the stacks we can and ask running
          // goroutines to scan themselves; and the
          // second blocks.
          stopped := suspendG(gp)
          if stopped.dead {
             gp.gcscandone = true
             return
          }
          if gp.gcscandone {
             throw("g already scanned")
          }
          workDone += scanstack(gp, gcw)
          gp.gcscandone = true
          resumeG(stopped)

          if selfScan {
             casgstatus(userG, _Gwaiting, _Grunning)
          }
       })
    }
    if workCounter != nil && workDone != 0 {
       workCounter.Add(workDone)
       if flushBgCredit {
          gcFlushBgCredit(workDone)
       }
    }
    return workDone
}

对于全局数据区和协程栈上的数据,我们并不需要扫描所有的对象,只需要扫描指针就可以,因为只有指针才有可能指向堆区的数据,为了快速判断一个对象是否是指针,会有指针位图的概念(空间换时间)。

全局数据会在程序编译时,就把指针 位图信息写入可执行文件中,而对于协程栈,也会进行扫描,扫描时会将协程挂起(注意这也是一个暂停操作,不过粒度要小,只 stop单个协程),将指针位图扫描标记出来,然后再将协程恢复,通过指针位图就可以快速的找到根对象。

package runtime


//全局数据 指针位图扫描
func modulesinit() {
    ...
    for md := &firstmoduledata; md != nil; md = md.next {
       if md.bad {
          continue
       }
       *modules = append(*modules, md)
       if md.gcdatamask == (bitvector{}) {
          scanDataSize := md.edata - md.data
          //全局变量data段的指针位图
          md.gcdatamask = progToPointerMask((*byte)(unsafe.Pointer(md.gcdata)), scanDataSize)
          scanBSSSize := md.ebss - md.bss
          //全局变量bss段的指针位图
          md.gcbssmask = progToPointerMask((*byte)(unsafe.Pointer(md.gcbss)), scanBSSSize)
          gcController.addGlobals(int64(scanDataSize + scanBSSSize))
       }
    }
    ...
}

// Information from the compiler about the layout of stack frames.
// Note: this type must agree with reflect.bitVector.
type bitvector struct {
    n        int32 // # of bits
    bytedata *uint8
}

// progToPointerMask returns the 1-bit pointer mask output by the GC program prog.
// size the size of the region described by prog, in bytes.
// The resulting bitvector will have no more than size/goarch.PtrSize bits.
//构建指针位图
func progToPointerMask(prog *byte, size uintptr) bitvector {
    n := (size/goarch.PtrSize + 7) / 8
    x := (*[1 << 30]byte)(persistentalloc(n+1, 1, &memstats.buckhash_sys))[:n+1]
    x[len(x)-1] = 0xa1 // overflow check sentinel
    n = runGCProg(prog, &x[0])
    if x[len(x)-1] != 0xa1 {
       throw("progToPointerMask: overflow")
    }
    return bitvector{int32(n), &x[0]}
}

//mgcmark.go 协程栈 指针位图扫描的关键代码
func scanstack(gp *g, gcw *gcWork) int64 {
    ...
    //下述的r.ptrdata就是指针位图
    if conservative {
       scanConservative(b, r.ptrdata(), gcdata, gcw, &state)
    } else {
       scanblock(b, r.ptrdata(), gcdata, gcw, &state)
    }
    ...
}

无论是全局对象的扫描,还是栈对象的扫描,最终都会 scanblock 函数,而 scanblock 就会使用指针位图加速扫描,每个指针位图占 1B(gcdata的大小)。

runtime/mgcmark.go

func scanblock(b0, n0 uintptr, ptrmask *uint8, gcw *gcWork, stk *stackScanState) {
    //这里入参的 ptrmask 就是指针位图
    ...

    for i := uintptr(0); i < n; {
       // Find bits for the next word.
       // 每次按 8B 进行偏移
       bits := uint32(*addb(ptrmask, i/(goarch.PtrSize*8)))
       //这里如果 bits ==0 代表整体 64 位都不包含指针
       if bits == 0 {
          i += goarch.PtrSize * 8
          continue
       }
       for j := 0; j < 8 && i < n; j++ {
       //8B共包含8个指针位图信息,如果最低位位1, 代表这个对象报站指针
            if bits&1 != 0 {
               // Same work as in scanobject; see comments there.
               p := *(*uintptr)(unsafe.Pointer(b + i))
               if p != 0 {
                  if obj, span, objIndex := findObject(p, b, i); obj != 0 {
                     greyobject(obj, b, i, span, gcw, objIndex)
                  } else if stk != nil && p >= stk.stack.lo && p < stk.stack.hi {
                     stk.putPtr(p, false)
                  }
               }
            }
            bits >>= 1
            i += goarch.PtrSize
        }
       ...
    }
}

如果应用程序的内存分配过快,可能会导致mark worker不能及时的标记内存,导致OOM,为了减轻这种情况,用户协程在申请内存时会进行辅助标记判断,如果需要辅助标记,则也执行一部分的标记工作,用来减轻mark worker的压力。

混合写屏障

先说结论,go 的写屏障技术有以下几个特点:

  1. 写屏障期间新申请的对象为黑色
  2. 采用混合写屏障技术
  3. 协程栈上不需要写屏障,且不需要重新扫描栈
基本原理

并发标记期间,用户程序对堆上对象的操作可能存在两种:

  1. 新申请对象。
  2. 改变已有对象的引用关系。

对于新申请的对象来说,最简单的实现就是将新申请的对象标记为黑色,这样就会被扫描器认为是可达对象,在sweep 阶段就不会被回收掉。

而对于改变已有对象的引用关系来说,就复杂了很多,比如将一个黑色对象指向了一个白色对象,这种情况下由于黑色对象已经扫描过了,所以会导致本次新指向的白色对象不会再被染色,导致 GC 结束时被误回收掉。为了使三色标记法可以和用户程序并发执行,GO 引入了强弱三色不变式进行约束:

  1. 强三色不变式:不允许黑色对象引用白色对象。
  2. 弱三色不变式:黑色对象可以引用白色对象,但是白色对象需要存在其他灰色对象对它直接或间接的引用。

只要满足强弱的任意一个,就可以保证对象不被误回收,就需要写屏障(write barrier) 技术,而写屏障又分为两种:

  1. Dijistra Insertion Barrier 插入写屏障,在 A 对象引用 B 对象时,将 B 对象标记为灰色,基本思路如下:

又是他,dijstra!太强了

func InsertWB(slot *uintptr,ptr uintptr) {
    shape(ptr)
    *slot = ptr
}

2. Yuasa deletion barrier 删除写屏障,在 A 对象引用 B 对象时,将A 原来指向的对象标记为灰色,基本思路如下:

func DeleteWB(slot *uintptr,ptr uintptr) {
    shape(*slot)
    *slot = ptr
}

插入写屏障技术保证了强三色不变式,而删除写屏障技术则保证了弱三色不变式,但是两种技术都会导致指针操作开销增大。

在 GO 1.5时通过三色标记法可以使用户程序和标记程序并发执行,进而降低了 STW 的时间,并使用插入写屏障技术保证对象不被错误回收,同时为了减少对性能的影响,栈上的指针操作不开启屏障技术,但这就需要在 GC 并发标记完后STW 并重新扫描栈,尤其是协程的数量如果比较大的情况下,这部分 STW 的时间开销也是很大的。

为了避免栈的重新扫描,GO 1.8引入了混合写屏障技术,提案(github.com/golang/prop…

func HrbridWB(slot *uintptr,ptr uintptr) {
    shade(*slot)
    if current stack is grey:
        shade(ptr)
    *slot = ptr
}

不过由于实现的复杂度和时间要求,go 实际实现的是更简化的版本,不判断栈颜色:

func HrbridWB(slot *uintptr,ptr uintptr) {
    shade(*slot)
    shade(ptr)
    *slot = ptr
}

混合写屏障结合了删除写屏障会对源对象进行染色的能力,这样栈就不会直接引用白色对象,就可以在不在栈上加写屏障,并且不重新扫描协程栈的,这样最后 STW 时就不需要再次扫描协程栈。

源码实现

上述是混合写屏障的基本原理,我们再看下源码的实现:

  1. 实际上写屏障的加入是通过编译器内嵌到编译器里的,然后写屏障插入是编译器实现的,我们可以看下生成的汇编代码。
go如下:

package main

func main() {
    var a []int
    var b = new([]int)
    
    go func() {
       a = make([]int, 10000000)
    }()
    
    go func() {
       b = &a
    }()
    
    println(b)
}

我们这里新开了两个goroutine以让相关的变量逃逸到堆上,进而编译器插入相关的屏障代码,执行下述命令生成对应的汇编代码。

GOOS=linux GOARCH=amd64 go tool compile -S -l -N  gc.go > 1.s

生成的汇编代码有397行(通过这里也可以看到由于堆内存写屏障需要在代码中进行hook操作,会导致可执行文件变大),我们截取屏障相关最关键的一部分:

0x002f 00047 (gc.go:4)  CMPL   runtime.writeBarrier(SB), $0
0x0036 00054 (gc.go:4)  JEQ    58
0x0038 00056 (gc.go:4)  JMP    67
0x003a 00058 (gc.go:4)  MOVQ   $0, (AX)
0x0041 00065 (gc.go:4)  JMP    79
0x0043 00067 (gc.go:4)  MOVQ   AX, DI
0x0046 00070 (gc.go:4)  XORL   CX, CX
0x0048 00072 (gc.go:4)  CALL   runtime.gcWriteBarrierCX(SB)

这里引用了runtime的一个变量runtime.writeBarrier和一个函数runtime.gcWriteBarrierCX

  1. writeBarrier,是全局变量,标识是否开启写屏障,汇编代码会读取前4字节(CMPL指令)判断是否开启写屏障。
// The compiler knows about this variable.
// If you change it, you must change builtin/runtime.go, too.
// If you change the first four bytes, you must also change the write
// barrier insertion code.

var writeBarrier struct {
    enabled bool    // compiler emits a check of this before calling write barrier
    pad     [3]byte // compiler uses 32-bit load for "enabled" field
    needed  bool    // whether we need a write barrier for current GC phase
    cgo     bool    // whether we need a write barrier for a cgo check
    alignme uint64  // guarantee alignment so that compiler can use a 32 or 64-bit load
}

汇编代码中0x002f 00047 (gc.go:4) CMPL runtime.writeBarrier(SB), $0 就是对比了前4字节和0的判断,如果writeBarrier.enabled为true,就会继续调用runtime.gcWriteBarrierCX(SB)进行混合写屏障的染色操作。

  1. runtime.gcWriteBarrierCX,写屏障的实现,内部继续调用了runtime.gcWriteBarrier,不会直接进行染色,会将待染色的数据放到wbbuf中,统一刷新,空间换时间的经典操作,提升指针操作的性能,批量的写操作。
runtime/asm_amd64.s

// gcWriteBarrierCX is gcWriteBarrier, but with args in DI and CX.
// Defined as ABIInternal since it does not use the stable Go ABI.
TEXT runtime·gcWriteBarrierCX<ABIInternal>(SB),NOSPLIT,$0
    XCHGQ CX, AX
    CALL runtime·gcWriteBarrier<ABIInternal>(SB)
    XCHGQ CX, AX
    RET

// gcWriteBarrier performs a heap pointer write and informs the GC.
//
// gcWriteBarrier does NOT follow the Go ABI. 
// - DI is the destination of the write  ptr
// - AX is the value being written at DI  slot
TEXT runtime·gcWriteBarrier<ABIInternal>(SB),NOSPLIT,$112
    // Save the registers clobbered by the fast path. This is slightly
    // faster than having the caller spill these.
    MOVQ   R12, 96(SP)
    MOVQ   R13, 104(SP)
    // TODO: Consider passing g.m.p in as an argument so they can be shared
    // across a sequence of write barriers.
    MOVQ   g_m(R14), R13
    MOVQ   m_p(R13), R13
    MOVQ   (p_wbBuf+wbBuf_next)(R13), R12
    // Increment wbBuf.next position.
    LEAQ   16(R12), R12
    MOVQ   R12, (p_wbBuf+wbBuf_next)(R13)
    CMPQ   R12, (p_wbBuf+wbBuf_end)(R13)
    // Record the write.
    MOVQ   AX, -16(R12)   // Record value,将ptr写入到buf中
 
    MOVQ   (DI), R13
    MOVQ   R13, -8(R12)   // Record *slot,将*slot写入到buf中
    // Is the buffer full? (flags set in CMPQ above)
    JEQ    flush  //如果buf满了,那就进行刷新统一处理
ret:
    MOVQ   96(SP), R12
    MOVQ   104(SP), R13
    // Do the write.
    MOVQ   AX, (DI) //等价于 *slot=ptr
    RET

flush:
    ...
    // This takes arguments DI and AX
    CALL   runtime·wbBufFlush(SB) //刷新buf
    ...
    JMP    ret

这里实现上不同于其他的函数传参(Go1.17之前函数调用都是通过栈传参,在Go1.17之后,为了读写内存的开销,普通的函数调用中9个以内的参数也会通过寄存器传递),runtime·gcWriteBarrier会固定将参数传到AX寄存器(放slot)和DI寄存器(放ptr)。

  1. wbbuf可以通过批量写存来优化写屏障的开销,在源码中每个P都有自己的wbbuf,相同P上调度的G会将buf写入同一个区域。
type p struct {
...
    // wbBuf is this P's GC write barrier buffer.
    //
    wbBuf wbBuf
...
}

wbbuf的结构很简单,next指示下一个可用的buf,end指向了buf数组的末尾,而buf就是指针缓存。

// wbBuf is a per-P buffer of pointers queued by the write barrier.
// This buffer is flushed to the GC workbufs when it fills up and on
// various GC transitions.
//
// This is closely related to a "sequential store buffer" (SSB),
// except that SSBs are usually used for maintaining remembered sets,
// while this is used for marking.
type wbBuf struct {
    // next points to the next slot in buf. It must not be a
    // pointer type because it can point past the end of buf and
    // must be updated without write barriers.
    //
    // This is a pointer rather than an index to optimize the
    // write barrier assembly.
    next uintptr

    // end points to just past the end of buf. It must not be a
    // pointer type because it points past the end of buf and must
    // be updated without write barriers.
    end uintptr

    // buf stores a series of pointers to execute write barriers
    // on. This must be a multiple of wbBufEntryPointers because
    // the write barrier only checks for overflow once per entry.
    buf [wbBufEntryPointers * wbBufEntries]uintptr
}

buf的长度由wbBufEntryPointerswbBufEntries这两个常量,目前固定为2(因为每次混合写屏障需要染色的指针数为2) * 256

const (
    // wbBufEntries is the number of write barriers between
    // flushes of the write barrier buffer.
    //
    // This trades latency for throughput amortization. Higher
    // values amortize flushing overhead more, but increase the
    // latency of flushing. Higher values also increase the cache
    // footprint of the buffer.
    //
    // TODO: What is the latency cost of this? Tune this value.
    wbBufEntries = 256

    // wbBufEntryPointers is the number of pointers added to the
    // buffer by each write barrier.
    wbBufEntryPointers = 2
)

在buf写满或者切换gcphase时需要进行buf的刷新操作。

  1. runtime.wbBufFlush将wbBuf中的指针进行染色,内部又继续调用了wbBufFlush1

// wbBufFlush1 flushes p's write barrier buffer to the GC work queue.
//
// This must not have write barriers because it is part of the write
// barrier implementation, so this may lead to infinite loops or
// buffer corruption.
//
// This must be non-preemptible because it uses the P's workbuf.
//
//go:nowritebarrierrec
//go:systemstack
func wbBufFlush1(pp *p) {
    // Get the buffered pointers.
    start := uintptr(unsafe.Pointer(&pp.wbBuf.buf[0]))
    n := (pp.wbBuf.next - start) / unsafe.Sizeof(pp.wbBuf.buf[0])
    ptrs := pp.wbBuf.buf[:n]

    // Poison the buffer to make extra sure nothing is enqueued
    // while we're processing the buffer.
    pp.wbBuf.next = 0

//只有当设置debug=gccheckmark=1时才会启用,
//gccheckmark=1的含义是会在第二个STW时进行存活对象的检查,如果发现有存活对象,但是没被mark,就会panic
    if useCheckmark {
       // Slow path for checkmark mode.
       // slow_path,循环对buf中的指针进行染色
       for _, ptr := range ptrs {
          shade(ptr)
       }
       //buf清空
       pp.wbBuf.reset()
       return
    }

    // Mark all of the pointers in the buffer and record only the
    // pointers we greyed. We use the buffer itself to temporarily
    // record greyed pointers.
    //
    // TODO: Should scanobject/scanblock just stuff pointers into
    // the wbBuf? Then this would become the sole greying path.
    //
    // TODO: We could avoid shading any of the "new" pointers in
    // the buffer if the stack has been shaded, or even avoid
    // putting them in the buffer at all (which would double its
    // capacity). This is slightly complicated with the buffer; we
    // could track whether any un-shaded goroutine has used the
    // buffer, or just track globally whether there are any
    // un-shaded stacks and flush after each stack scan.
    gcw := &pp.gcw
    pos := 0
//循环扫描对象,将对象置黑,并将
    for _, ptr := range ptrs {
       if ptr < minLegalPointer {
          // nil pointers are very common, especially
          // for the "old" values. Filter out these and
          // other "obvious" non-heap pointers ASAP.
          //
          // TODO: Should we filter out nils in the fast
          // path to reduce the rate of flushes?
          continue
       }
       obj, span, objIndex := findObject(ptr, 0, 0)
       if obj == 0 {
          continue
       }
       // TODO: Consider making two passes where the first
       // just prefetches the mark bits.
       mbits := span.markBitsForIndex(objIndex)
       // 剪枝判断
       if mbits.isMarked() {
          continue
       }
       mbits.setMarked()

       // Mark span.
       arena, pageIdx, pageMask := pageIndexOf(span.base())
       if arena.pageMarks[pageIdx]&pageMask == 0 {
       // 染色
          atomic.Or8(&arena.pageMarks[pageIdx], pageMask)
       }

       if span.spanclass.noscan() {
          gcw.bytesMarked += uint64(span.elemsize)
          continue
       }
       ptrs[pos] = obj
       pos++
    }

    // Enqueue the greyed objects.
    // 将新扫描到的对象放入gcw(灰色对象,待扫描队列)
    gcw.putBatch(ptrs[:pos])

    pp.wbBuf.reset()
}
  1. wbbuf重置,runtime.wbbuf.reset,实现上是直接重置start和end指针,复用buf的空间。
// reset empties b by resetting its next and end pointers.
func (b *wbBuf) reset() {
//start 重置
    start := uintptr(unsafe.Pointer(&b.buf[0]))
    b.next = start
    if writeBarrier.cgo {
       // Effectively disable the buffer by forcing a flush
       // on every barrier.
       b.end = uintptr(unsafe.Pointer(&b.buf[wbBufEntryPointers]))
    } else if testSmallBuf {
       // For testing, allow two barriers in the buffer. If
       // we only did one, then barriers of non-heap pointers
       // would be no-ops. This lets us combine a buffered
       // barrier with a flush at a later time.
       b.end = uintptr(unsafe.Pointer(&b.buf[2*wbBufEntryPointers]))
    } else {
//end重置
       b.end = start + uintptr(len(b.buf))*unsafe.Sizeof(b.buf[0])
    }

    if (b.end-b.next)%(wbBufEntryPointers*unsafe.Sizeof(b.buf[0])) != 0 {
       throw("bad write barrier buffer bounds")
    }
}

标记协程

在gcStart中会开启gomaxprocs个mark worker进行标记工作,并且从mark阶段切换到markTermination阶段也是mark work进行切换的。

worker mode

在真正执行mark前,会先确定MarkWorkerMode,MarkWorkerMode是P的一个字段,指定这个P执行mark时的表现,目前有三种mode。

  • DedicatedMode,专属模式,P专门用于mark worker,不会被调度器抢占。
  • FractionalMode,部分模式,gc需要控制占用的CPU不超过25%,如果使用DedicatedMode但是gomaxprocs不是4的倍数,那就会导致偏差比较大,所以需要部分运行模式,以更细力度的去控制worker运行时间。
  • IdleMode,空闲模式,当前P只有mark worker待执行,没有其他待执行的G,所以mark过程会持续进行,直到被抢占或者根据gcController.idleMarkTime计算运行时间。
type p struct {
...
    // gcMarkWorkerMode is the mode for the next mark worker to run in.
    // That is, this is used to communicate with the worker goroutine
    // selected for immediate execution by
    // gcController.findRunnableGCWorker. When scheduling other goroutines,
    // this field must be set to gcMarkWorkerNotWorker.
    gcMarkWorkerMode gcMarkWorkerMode
...
}

// gcMarkWorkerMode represents the mode that a concurrent mark worker
// should operate in.
//
// Concurrent marking happens through four different mechanisms. One
// is mutator assists, which happen in response to allocations and are
// not scheduled. The other three are variations in the per-P mark
// workers and are distinguished by gcMarkWorkerMode.
type gcMarkWorkerMode int

const (
    // gcMarkWorkerNotWorker indicates that the next scheduled G is not
    // starting work and the mode should be ignored.
//未进入GC
    gcMarkWorkerNotWorker gcMarkWorkerMode = iota
    
    // gcMarkWorkerDedicatedMode indicates that the P of a mark
    // worker is dedicated to running that mark worker. The mark
    // worker should run without preemption.
//专属模式
    gcMarkWorkerDedicatedMode
    
    // gcMarkWorkerFractionalMode indicates that a P is currently
    // running the "fractional" mark worker. The fractional worker
    // is necessary when GOMAXPROCS*gcBackgroundUtilization is not
    // an integer and using only dedicated workers would result in
    // utilization too far from the target of gcBackgroundUtilization.
    // The fractional worker should run until it is preempted and
    // will be scheduled to pick up the fractional part of
    // GOMAXPROCS*gcBackgroundUtilization.
//部分模式
    gcMarkWorkerFractionalMode
    
    // gcMarkWorkerIdleMode indicates that a P is running the mark
    // worker because it has nothing else to do. The idle worker
    // should run until it is preempted and account its time
    // against gcController.idleMarkTime.
//空闲模式
    gcMarkWorkerIdleMode
)
一次扫描标记

真正执行标记的是runtime.gcDrain函数,会根据work mode的选择,有不同的行为体现,gcDrain会一直循环执行,有四种情况会终止执行,分别对应了4个gcDrainFlags,注意这四种flag并不是互斥的,是都可以触发的,通过bit位传入gcDrain。

  1. gcDrainUntilPreempt = 1,直到被抢占。
  2. gcDrainFlushBgCredit = 2,直到完成了自己的点数消费,协助标记worker会进入这种模式。
  3. gcDrainIdle = 3,直到有其他G待调度。
  4. gcDrainFractional = 4,直到部分模式完成了目标CPU占用。
// gcDrain scans roots and objects in work buffers, blackening grey
// objects until it is unable to get more work. It may return before
// GC is done; it's the caller's responsibility to balance work from
// other Ps.
//
// If flags&gcDrainUntilPreempt != 0, gcDrain returns when g.preempt
// is set.
//
// If flags&gcDrainIdle != 0, gcDrain returns when there is other work
// to do.
//
// If flags&gcDrainFractional != 0, gcDrain self-preempts when
// pollFractionalWorkerExit() returns true. This implies
// gcDrainNoBlock.
//
// If flags&gcDrainFlushBgCredit != 0, gcDrain flushes scan work
// credit to gcController.bgScanCredit every gcCreditSlack units of
// scan work.
//
// gcDrain will always return if there is a pending STW.
//
//go:nowritebarrier
func gcDrain(gcw *gcWork, flags gcDrainFlags) {
    if !writeBarrier.needed {
       throw("gcDrain phase incorrect")
    }

    gp := getg().m.curg
    preemptible := flags&gcDrainUntilPreempt != 0
    flushBgCredit := flags&gcDrainFlushBgCredit != 0
    idle := flags&gcDrainIdle != 0

    initScanWork := gcw.heapScanWork

    // checkWork is the scan work before performing the next
    // self-preempt check.
    checkWork := int64(1<<63 - 1)
 确定check函数
    var check func() bool
    if flags&(gcDrainIdle|gcDrainFractional) != 0 {
       checkWork = initScanWork + drainCheckThreshold
       if idle {
 pollWork检查的是是否有其他G待执行,包含全局runq和P本地runq,idle模式会使用
          check = pollWork
       } else if flags&gcDrainFractional != 0 {
判断该执行的CPU时间是否已经执行完成,Fractional模式会使用
          check = pollFractionalWorkerExit
       }
    }

    // Drain root marking jobs.
 执行mark root
    if work.markrootNext < work.markrootJobs {
       // Stop if we're preemptible or if someone wants to STW.
       循环,直到满足结束条件(抢占、idle、fractional)
       for !(gp.preempt && (preemptible || sched.gcwaiting.Load())) {
          job := atomic.Xadd(&work.markrootNext, +1) - 1
          if job >= work.markrootJobs {
             break
          }
          将root对象染色并加入gcw队列
          markroot(gcw, job, flushBgCredit)
          if check != nil && check() {
             goto done
          }
       }
    }

执行gcw mark
    // Drain heap marking jobs.
    // Stop if we're preemptible or if someone wants to STW.
       循环,直到满足结束条件(抢占、idle、fractional)
    for !(gp.preempt && (preemptible || sched.gcwaiting.Load())) {
       // Try to keep work available on the global queue. We used to
       // check if there were waiting workers, but it's better to
       // just keep work available than to make workers wait. In the
       // worst case, we'll do O(log(_WorkbufSize)) unnecessary
       // balances.
       如果全局work队列为空,将P本地的gcw平衡一部分到全局队列以供其他P消费
       if work.full == 0 {
          gcw.balance()
       }

       b := gcw.tryGetFast()
       if b == 0 {
          b = gcw.tryGet()
          if b == 0 {
          如果从gcw或全局work队列取不到指针,那就将写屏障缓冲刷入gcw
             // Flush the write barrier
             // buffer; this may create
             // more work.
             wbBufFlush(nil, 0)
             b = gcw.tryGet()
          }
       }
       取不到指针,结束标记工作
       if b == 0 {
          // Unable to get work.
          break
       }
       扫描对象,将扫描到的指针加入gcw
       scanobject(b, gcw)

       // Flush background scan work credit to the global
       // account if we've accumulated enough locally so
       // mutator assists can draw on it.
       如果gcw的工作点数大于点数限制(gcCreditSlack),会刷新到gc协调器里
       gcCreditSlack 是常量目前固定为2000,
        - 更低的值可以让协助标记的阈值判断更准确
        - 更高的值会减少竞态(因为更新gc协调器堆扫描大小需要原子操作/锁)
       if gcw.heapScanWork >= gcCreditSlack {
          gcController.heapScanWork.Add(gcw.heapScanWork)
          if flushBgCredit {
             gcFlushBgCredit(gcw.heapScanWork - initScanWork)
             initScanWork = 0
          }
          checkWork -= gcw.heapScanWork
          gcw.heapScanWork = 0

          if checkWork <= 0 {
             checkWork += drainCheckThreshold
             if check != nil && check() {
                break
             }
          }
       }
    }

//
done:
    // Flush remaining scan work credit.
    if gcw.heapScanWork > 0 {
       gcController.heapScanWork.Add(gcw.heapScanWork)
       if flushBgCredit {
          gcFlushBgCredit(gcw.heapScanWork - initScanWork)
       }
       gcw.heapScanWork = 0
    }
}
markroot

在标记函数gcDrain中,首先会执行root对象的标记工作,也就是markroot函数,会根据work.markrootNext执行本次需要markroot的操作,根据优先级,依次是data区->bss区->finalizer对象->死亡的G扫描->specials->goroutine栈。

func markroot(gcw *gcWork, i uint32, flushBgCredit bool) int64 {
    // Note: if you add a case here, please also update heapdump.go:dumproots.
    var workDone int64
    var workCounter *atomic.Int64
    switch {
//data扫描
    case work.baseData <= i && i < work.baseBSS:
       workCounter = &gcController.globalsScanWork
       for _, datap := range activeModules() {
          workDone += markrootBlock(datap.data, datap.edata-datap.data, datap.gcdatamask.bytedata, gcw, int(i-work.baseData))
       }

//bss扫描
    case work.baseBSS <= i && i < work.baseSpans:
       workCounter = &gcController.globalsScanWork
       for _, datap := range activeModules() {
          workDone += markrootBlock(datap.bss, datap.ebss-datap.bss, datap.gcbssmask.bytedata, gcw, int(i-work.baseBSS))
       }

//finalizer扫描
    case i == fixedRootFinalizers:
       for fb := allfin; fb != nil; fb = fb.alllink {
          cnt := uintptr(atomic.Load(&fb.cnt))
          scanblock(uintptr(unsafe.Pointer(&fb.fin[0])), cnt*unsafe.Sizeof(fb.fin[0]), &finptrmask[0], gcw, nil)
       }
//死亡G扫描
    case i == fixedRootFreeGStacks:
       // Switch to the system stack so we can call
       // stackfree.
       systemstack(markrootFreeGStacks)
//special扫描
    case work.baseSpans <= i && i < work.baseStacks:
       // mark mspan.specials
       markrootSpans(gcw, int(i-work.baseSpans))

    default:
      ... 协程栈扫描
    }
    if workCounter != nil && workDone != 0 {
       workCounter.Add(workDone)
       if flushBgCredit {
          gcFlushBgCredit(workDone)
       }
    }
    return workDone
}
bss和data扫描

bss和data的根对象扫描流程类似,都是循环扫描所有的module,执行每个module中第i个block,也就是说每次markroot,最多会扫描 module 个block。

//data扫描
    case work.baseData <= i && i < work.baseBSS:
       workCounter = &gcController.globalsScanWork
       for _, datap := range activeModules() {
          workDone += markrootBlock(datap.data, datap.edata-datap.data, datap.gcdatamask.bytedata, gcw, int(i-work.baseData))
       }
//bss扫描
    case work.baseBSS <= i && i < work.baseSpans:
       workCounter = &gcController.globalsScanWork
       for _, datap := range activeModules() {
          workDone += markrootBlock(datap.bss, datap.ebss-datap.bss, datap.gcbssmask.bytedata, gcw, int(i-work.baseBSS))
       }

每个block的大小在源码中是写死的:

// rootBlockBytes is the number of bytes to scan per data or
// BSS root.
const (
    rootBlockBytes = 256 << 10 //262144字节,2^18 = 256KB
)

markrootBlock,入参b0是基址,n0是偏移量,b0 ~ b0+n0 就是当前module的bss/data 内存,ptrmask是当前bss/data的指针位图,以及gcw工作队列和第shard个block,该函数计算好偏移量,将本次扫描执行的内存块继续执行scanblock,扫描一个block,而scanblock是一个非常核心的函数,所有的扫描标记底层依赖的都是scanblock,我们放到最后去分析。

// markrootBlock scans the shard'th shard of the block of memory [b0,
// b0+n0), with the given pointer mask.
//
// Returns the amount of work done.
//
//go:nowritebarrier
func markrootBlock(b0, n0 uintptr, ptrmask0 *uint8, gcw *gcWork, shard int) int64 {
    if rootBlockBytes%(8*goarch.PtrSize) != 0 {
       // This is necessary to pick byte offsets in ptrmask0.
       throw("rootBlockBytes must be a multiple of 8*ptrSize")
    }
计算指针、指针位图偏移量
    // Note that if b0 is toward the end of the address space,
    // then b0 + rootBlockBytes might wrap around.
    // These tests are written to avoid any possible overflow.
    off := uintptr(shard) * rootBlockBytes
    if off >= n0 {
       return 0
    }
    b := b0 + off
    ptrmask := (*uint8)(add(unsafe.Pointer(ptrmask0), uintptr(shard)*(rootBlockBytes/(8*goarch.PtrSize))))
    n := uintptr(rootBlockBytes)
    if off+n > n0 {
       n = n0 - off
    }

    // Scan this shard.
扫描一个block,非常核心的函数,各个区域对象的扫描最终调用的都是scanblock
    scanblock(b, n, ptrmask, gcw, nil)
    return int64(n)
}
freeg清理

freeg并不会产生新的标记对象,它做的只是清理掉死亡G的栈内存,归还给内存分配器。

之所以只释放栈内存,而不是销毁整个G,是因为freeG会被重用,同时gFree也是有P本地队列和全局队列

在这里也可以看到,goroutine的栈内存也是由GC清理的,所以go的GC并不是只处理堆内存


// markrootFreeGStacks frees stacks of dead Gs.
//
// This does not free stacks of dead Gs cached on Ps, but having a few
// cached stacks around isn't a problem.
func markrootFreeGStacks() {
    // Take list of dead Gs with stacks.
    lock(&sched.gFree.lock)
    list := sched.gFree.stack
    sched.gFree.stack = gList{}
    unlock(&sched.gFree.lock)
    if list.empty() {
       return
    }

    // Free stacks.
    q := gQueue{list.head, list.head}
    循环freeG链表,释放栈内存
    for gp := list.head.ptr(); gp != nil; gp = gp.schedlink.ptr() {
       stackfree(gp.stack)
       gp.stack.lo = 0
       gp.stack.hi = 0
       // Manipulate the queue directly since the Gs are
       // already all linked the right way.
       q.tail.set(gp)
    }

    // Put Gs back on the free list.
    将释放完栈内存的G加入noStack链表
    lock(&sched.gFree.lock)
    sched.gFree.noStack.pushAll(q)
    unlock(&sched.gFree.lock)
}
协程栈扫描

每个worker每次扫描一个G,在协程栈扫描时,需要将对应的协程挂起suspendG(如果这个协程是用户协程,那也会造成延迟),协程挂起和恢复的细节我们在这里不做研究,放到后边的调度器去讲解。

default:
    // the rest is scanning goroutine stacks
    workCounter = &gcController.stackScanWork
    
...

    gp := work.stackRoots[i-work.baseStacks]

...

    // scanstack must be done on the system stack in case
    // we're trying to scan our own stack.
扫描goroutine的栈需要使用系统栈执行
    systemstack(func() {
    
       如果g正在扫描自己,而且处于running状态,那先把自己停下来(切换到g),防止死锁
       辅助标记会存在这种情况,mark worker不会有这种case
       
       userG := getg().m.curg
       selfScan := gp == userG && readgstatus(userG) == _Grunning
       if selfScan {
          casGToWaiting(userG, _Grunning, waitReasonGarbageCollectionScan)
       }

       
       将G挂起
》 当前版本扫描G栈的实现,整个扫描过程都是阻塞的,另外一个优化思路是分两个阶段
1. 第一阶段先扫描可以扫描的栈并且让goroutine自己扫描自己
2. 第二阶段再stop,扫描剩余的部分。
       stopped := suspendG(gp)
       ...
       workDone += scanstack(gp, gcw)
       ...
       将G复位
       resumeG(stopped)

       ...
    })
}

将G挂起后继续调用了scanstack去做真正的栈扫描工作,将栈上的全部指针染色(放入gcw队列),在扫描完栈之后,将G复位resumeG


// scanstack scans gp's stack, greying all pointers found on the stack.
//
// Returns the amount of scan work performed, but doesn't update
// gcController.stackScanWork or flush any credit. Any background credit produced
// by this function should be flushed by its caller. scanstack itself can't
// safely flush because it may result in trying to wake up a goroutine that
// was just scanned, resulting in a self-deadlock.
//
// scanstack will also shrink the stack if it is safe to do so. If it
// is not, it schedules a stack shrink for the next synchronous safe
// point.
//
// scanstack is marked go:systemstack because it must not be preempted
// while using a workbuf.
//
//go:nowritebarrier
//go:systemstack
func scanstack(gp *g, gcw *gcWork) int64 {

...

    // scannedSize is the amount of work we'll be reporting.
    //
    // It is less than the allocated size (which is hi-lo).
    var sp uintptr
    if gp.syscallsp != 0 {
       sp = gp.syscallsp // If in a system call this is the stack pointer (gp.sched.sp can be 0 in this case on Windows).
    } else {
       sp = gp.sched.sp
    }
    计算扫描的栈帧大小,并不是扫描整体的栈帧(stack.hi - stack.lo),而是只扫描用到的栈(sp),
    从栈指针sp到栈帧上界stack.hi
    scannedSize := gp.stack.hi - sp

    ...
    // Scan the stack. Accumulate a list of stack objects.
    扫描栈,包括本地变量、以及调用函数的参数和返回值
    scanframe := func(frame *stkframe, unused unsafe.Pointer) bool {
       scanframeworker(frame, &state, gcw)
       return true
    }
...

    // Find additional pointers that point into the stack from the heap.
    // Currently this includes defers and panics. See also function copystack.

    // Find and trace other pointers in defer records.
    除了栈帧内存,gp也会包含两类特殊指针,deferpanic
    for d := gp._defer; d != nil; d = d.link {
       if d.fn != nil {
          // Scan the func value, which could be a stack allocated closure.
          // See issue 30453.
              defer的函数闭包扫描
          scanblock(uintptr(unsafe.Pointer(&d.fn)), goarch.PtrSize, &oneptrmask[0], gcw, &state)
       }
       if d.link != nil {
          // The link field of a stack-allocated defer record might point
          // to a heap-allocated defer record. Keep that heap record live.
          scanblock(uintptr(unsafe.Pointer(&d.link)), goarch.PtrSize, &oneptrmask[0], gcw, &state)
       }
       // Retain defers records themselves.
       // Defer records might not be reachable from the G through regular heap
       // tracing because the defer linked list might weave between the stack and the heap.
       堆分配的defer,需要扫描,这里多说一嘴,这个也是defer的优化,defer会有栈/堆分配这两种
       if d.heap {
          scanblock(uintptr(unsafe.Pointer(&d)), goarch.PtrSize, &oneptrmask[0], gcw, &state)
       }
    }
    if gp._panic != nil {
       // Panics are always stack allocated.
       state.putPtr(uintptr(unsafe.Pointer(gp._panic)), false)
    }

    // Find and scan all reachable stack objects.
    扫描栈上所有可达的对象
    //
    // The state's pointer queue prioritizes precise pointers over
    // conservative pointers so that we'll prefer scanning stack
    // objects precisely.
    state.buildIndex()
    for {
       p, conservative := state.getPtr()
       if p == 0 {
          break
       }
       obj := state.findObject(p)
       if obj == nil {
          continue
       }
       r := obj.r
       if r == nil {
          // We've already scanned this object.
          continue
       }
       obj.setRecord(nil) // Don't scan it again.
       if stackTraceDebug {
          printlock()
          print("  live stkobj at", hex(state.stack.lo+uintptr(obj.off)), "of size", obj.size)
          if conservative {
             print(" (conservative)")
          }
          println()
          printunlock()
       }
       gcdata := r.gcdata()
       var s *mspan
       if r.useGCProg() {
          // This path is pretty unlikely, an object large enough
          // to have a GC program allocated on the stack.
          // We need some space to unpack the program into a straight
          // bitmask, which we allocate/free here.
          // TODO: it would be nice if there were a way to run a GC
          // program without having to store all its bits. We'd have
          // to change from a Lempel-Ziv style program to something else.
          // Or we can forbid putting objects on stacks if they require
          // a gc program (see issue 27447).
          s = materializeGCProg(r.ptrdata(), gcdata)
          gcdata = (*byte)(unsafe.Pointer(s.startAddr))
       }

       b := state.stack.lo + uintptr(obj.off)
       if conservative {
          scanConservative(b, r.ptrdata(), gcdata, gcw, &state)
       } else {
          scanblock(b, r.ptrdata(), gcdata, gcw, &state)
       }

       if s != nil {
          dematerializeGCProg(s)
       }
    }

    // Deallocate object buffers.
    // (Pointer buffers were all deallocated in the loop above.)
    for state.head != nil {
       x := state.head
       state.head = x.next
       if stackTraceDebug {
          for i := 0; i < x.nobj; i++ {
             obj := &x.obj[i]
             if obj.r == nil { // reachable
                continue
             }
             println("  dead stkobj at", hex(gp.stack.lo+uintptr(obj.off)), "of size", obj.r.size)
             // Note: not necessarily really dead - only reachable-from-ptr dead.
          }
       }
       x.nobj = 0
       putempty((*workbuf)(unsafe.Pointer(x)))
    }
    if state.buf != nil || state.cbuf != nil || state.freeBuf != nil {
       throw("remaining pointer buffers")
    }
    return int64(scannedSize)
}

协助标记

当内存分配的速率过快时,GC mark worker标记可能不能及时标记内存,导致内存不能及时回收无限增长或者长时间的STW,协助标记机制就是为了缓解这种情况,让用户协程在分配内存时也领取一部分标记任务。(属于是不能光白吃,也得干点活了)。

具体包含以下三个机制:

  1. 信用(Credit)系统:

    1. 每个协程维护一个GC信用值,表示它需要协助完成的标记工作量。
    2. 分配内存时扣除信用:每次分配内存,信用值会按比例减少,分配的越多,减少的越多。
    3. 信用耗尽时触发协助:当信用值低于零,协程必须执行标记任务来“偿还”债务,直到信用值恢复为正。
  2. 标记工作量计算

    1. 协程需协助的标记工作量与其分配的内存成正比。债务越多,需要标记的工作就越多。
  3. 与后台GC协作

    1. 用户协程协助标记时,会从全局或本地的标记队列中获取对象进行扫描,标记存活对象,直到完成自身需承担的标记量。
    2. 后台GC协程(如gcBgMarkWorker)同时运行,处理全局标记任务。

sweep 清扫

内存清扫会有多个入口。

  1. runtime.bgscavenge,正常触发 GC的内存清理都会通过这个函数处理。
  2. runtime.GC,用户主动调用的GC。
  3. runtime.finishsweep_m,在开始新一轮 GC 的并发标记前,STW,禁止持续的内存分配,保证上一轮的内存回收完成。
  4. runtime.gcSweep,使用stw 的 sweep 而不是默认情况下并发的sweep 时(在启动程序时,通过 godebug=gcstoptheworld=2)才会使用这里的阻塞式 gc 进行处理。
  5. runtime.gcStart,这里的内存清扫在正常模式下不会执行,因为正常情况下后台的 GC 一定需要上一轮清扫结束才会开始。只有当 GC 模式是强制模式(比如手动调用 runtime.GC() )时才可能会执行,因为runtime.GC()在任何时间都有可能会被调用。

gcstoptheworld: setting gcstoptheworld=1 disables concurrent garbage collection, making every garbage collection a stop-the-world event. Setting gcstoptheworld=2 also disables concurrent sweeping after the garbage collection finishes.

而这多个内存清扫其实最终调用的都是runtime.sweepone,这是清扫里最核心的函数,它的逻辑就是清扫掉一个 span,如果没有待清扫的 span,那么返回0。

内存的回收相对标记来说简单很多,通过对程序初始化的学习(go程序的生前死后 ),我们了解到,在进程启动时,会进行 gc 的初始化工作,并且会启动两个特殊的 goroutine,之前我们只是一笔带过,在这里我们再介绍下这两个goroutine的实现:

runtime.main -- > runtime.gcenable -->

// gcenable is called after the bulk of the runtime initialization,
// just before we're about to start letting user code run.
// It kicks off the background sweeper goroutine, the background
// scavenger goroutine, and enables GC.
func gcenable() {
    // Kick off sweeping and scavenging.
    c := make(chan int, 2)
    go bgsweep(c)
    go bgscavenge(c)
    <-c
    <-c
    memstats.enablegc = true // now that runtime is initialized, GC is okay
}

这两个 goroutine 永远不会返回,分别是

  • bgsweep,在标记流程结束后,将标记出的垃圾,进行回收工作,由于 go 内存的多级分配机制,这里并不会直接把内存归还给操作系统,只会将内存归还给分配器。
  • bgscavenge,定期将内存块归还给操作系统。

GC的成本

GC的成本模型有四条公理:

  1. GC时需要STWstop the world)。
  2. GC只涉及两种资源:CPU时间和物理内存。
  3. GC的内存开销包含存活的堆内存(在上个GC周期后存活的队内)、标记阶段前申请的堆内存(本次GC周期申请的全部内存)和元数据(metadata)的空间开销,不过元数据的开销相对小很多。
  4. GC的CPU固定建模在每一个GC周期内,以及与活跃堆内存的大小成比例的边际成本(marginal cost),即活跃堆内存越大,CPU开销越高。

在当前版本的实现中,标记的边际成本比清扫大很多

GOGC 与 内存/CPU

我们先看下内存的计算方式,GC 的目标堆大小计算方式如下:

Target heap memory = Live heap + (Live heap + GC roots) * GOGC / 100

所有的堆内存大小计算如下:

Total heap memory = Live heap + New heap memory

简化下来, GC 周期里新的堆内存大小如下:

*New heap memory = (Live heap + *GC roots) * GOGC / 100

所以基于堆内存公式我们可以看到,GOGC 的值越大,本次 GC 周期的内存越多,且比例固定,以 GOGC=100 为基数,GOGC 值翻倍,新的堆内存翻倍

接下来,再来看下 CPU 的计算方式,总的 CPU 成本如下:

Total GC CPU cost = (GC CPU cost per cycle) * (GC frequency) * T

而每一次 GC 循环的成本如下:

GC CPU cost per cycle = (Live heap + GC roots) * (Cost per byte) + Fixed cost

在稳态的内存分配下,GC 的频率如下:

GC frequency = (Allocation rate) / (New heap memory)

使用堆内存计算公式化简一下:

GC frequency = (Allocation rate) / ((Live heap + GC roots) * GOGC / 100)

所以总得 CPU 成本如下:

Total GC CPU cost = (Allocation rate) / ((Live heap + GC roots) * GOGC / 100) * ((Live heap + GC roots) * (Cost per byte) + Fixed cost) * T

大多数情况下,GC 的固定成本(Fixed cost)是随着边际成本一起增长的,所以可以忽略这个因子进行化简:

Total GC CPU cost = (Allocation rate) / (GOGC / 100) * (Cost per byte) * T

所以基于 CPU 公式我们可以看到,GOGC 的值越大,总的CPU 开销越小,且比例固定,以 GOGC=100 为基数,GOGC 值翻倍,CPU 成本减半。

可能导致延迟的原因

  1. 每GC有两次STW;Brief stop-the-world pauses when the GC transitions between the mark and sweep phases,
  2. 在标记阶段,会使用25%的CPU进行标记工作;Scheduling delays because the GC takes 25% of CPU resources when in the mark phase,
  3. 高内存分配率下,用户协程会进行辅助标记;User goroutines assisting the GC in response to a high allocation rate,
  4. 在标记阶段,指针操作需要额外的写屏障操作;Pointer writes requiring additional work while the GC is in the mark phase, and
  5. 栈被扫描时,对应的协程会挂起;Running goroutines must be suspended for their roots to be scanned.

排查手段

GC相关的有以下几个常用的观测和排查手段:

  1. pprof,观测CPU、内存等常用的工具,不做过多介绍。
  2. runtime.ReadMemStats 进行查看,PauseNs 是每一轮的 GC 暂停时间(最新的在数组最前边),PauseTotalNs 是总 GC 暂停时间,单位都是纳秒。

  1. 通过 debug.ReadGCStats也可以读到Pause和PauseTotal。

  1. 通过启动时指定GODEBUG=gctrace=1,这样在 GC时就可以看到相关的日志打印,比如这条 trace log 就代表,在 Mark 阶段pause 了 0.12ms,在MarkTermination阶段 pause 了 0.17ms。
gc 7 @0.140s 1%: 0.031+2.0+0.042 ms clock, 0.12+0.43/1.8/0.049+0.17 ms cpu, 4->4->1 MB, 5 MB goal, 4 P

5. 通过 go tool trace,比如这个 trace 就可以看到在sweep temination 阶段有 0.13ms 的 STW,在mark termination 阶段有 0.12ms 的 STW。

GC 调优

GOGC

GO 在设计上是尽可能对语言使用者保持透明,也是simplicity hiding complexity最好体现,所以不同于 Java 等提供了很多 GC 调优的工具,GOGC目前仅提供了 2 个参数来控制,其中一个还是 go1.19加入的。

在 GO 1.19 之前,gc 暴露出的允许开发者自己调整的参数只有一个,GOGC 即gc的频率控制,有两种使用方式都可以调整

  1. 使用 GOGC环境变量
  2. 使用 debug.SetGCPercent(percent int)

GOGC的含义是系统触发下一次 GC 所需要增长的内存大小百分比,默认情况下GOGC 的取值是 100,也就是说需要增长到当前内存的两倍才会触发 GC。如果设置为负数或 0(对应的 GOGC环境变量=off),代表的是关闭 GC。

我们可以通过调整 GOGC 的取值来控制 GC 触发的频率,GOGC 值越大,gc 触发的频率越低。

比如如果有大量的临时堆对象频繁创建和销毁(在 web 服务里是比较常见的),这种情况下 GC 的cpu开销是比较大的,在内存充足的情况下,我们可以通过调大 GOGC 的取值来降低 GC 的频率从而减少 GC 对 cpu 的消耗。

GOMEMLIMIT

在 GO 1.19中新加了一个参数,GOMEMLIMIT,软限制内存的上限,在达到上限后触发一轮 GC,同样也有两种方式可以调整,单位是 bytes

  1. 使用GOMEMLIMIT环境变量
  2. 使用debug.SetMemoryLimit(limit int64)

在go 的内存达到 GOMEMLIMIT 后会触发一轮 GC,默认情况下,取值是math.MaxInt64也就意味着这个限制永远不会用到,GOMEMLIMIT取值是包含整个进程的go 内存占用,包括堆区、协程栈和全局变量。

有两种场景可能会用到GOMEMLIMIT,而这两个使用场景基本可以将下边要介绍的社区解决方案给替换掉了。

  1. OOM,可以通过 GOMEMLIMIT 来设置内存的上限,避免 OOM。
  2. GC 触发频率过高,将 GOGC 设置为 off,然后GOMEMLIMIT设置为内存上限。

注意GOMEMLIMIT和GOGC是分开计算的,达到两者的满足条件分别都会触发 GC。

GOMEMLIMIT是软限制内存的,所以依然可能会出现分配的内存,大于GOMEMLIMIT的配置,这是因为如果做硬限制,如果在一段时间内持续有内存分配并存活超出限制(可以参考这一章的直方图📊tip.golang.org/doc/gc-guid… GC 会频繁的触发导致占用大量的 CPU 资源,目前的软限制是写死的,目前设置为 GC 占用的 CPU 为50%(在一个时间窗口内,时间窗口为 2 * GOMAXPROCS 秒),这样即使错误的配置了内存的上限,在最差的情况下,也只会使程序的运行时间延长为原来的两倍。

社区解决方案

除了这两个参数,社区也有两个常见的 GC 优化方案(不过这两个方案随着 GOMEMLIMIT 的加入,基本也没用了)

  1. memory ballast,由 twitch 的工程师开源的一个方案,直接翻译过来的名字是 内存压舱石 通过在程序的最开始申请一个超大的空间,用于提高内存的下限,进而需要较高的内存占用才会达到 GOGC 的限制,进而起到抑制 GC 频率的目的,而且由于我们不会对这段空间进行实际的读写,并不会分配真实的物理内存,参考代码如下。
func main() {
    // Create a large heap allocation of 10 GiB
    ballast := make([]byte, 10<<30)
    // Application execution continues
    // ...
}

之所以这个方案的原因是如果想抑制 GC 的频率,通过官方的接口,只能调大 GOGC 的值,但是调大 GOGC 的值很容易造成程序的 OOM(out of memory)异常退出,所以可以在不改变 GOGC 的情况下,通过压舱石来降低GC频率。

不过在 go 1.19 之后,GOMEMLIMIT已经可以替换到 ballast 的方案了,上述的代码,我们可以替换一下,将 GOGC 值调大并且设置内存软上限,也就能降低 GC 频率的同时,避免 OOM

func main() {
    debug.SetGCPercent(200)
    debug.SetMemoryLimit(10<<30) //软限制 10GiB 的内存
    // Application execution continues
    // ...
}

2. gc tuner,gc调节器,uber的文章中介绍的,不过并没有开源,基本的思路就是通过动态的调整 GOGC 的取值,进而控制GC触发的频率。在每一次的 GC 中都去动态的调整 GOGC 的取值,来改变下一次触发 GC 的内存上限,在 go 里可以通过runtime.SetFinalizer来设置对象被GC 时执行的一个钩子函数,在钩子函数内部进行GOGC值的调整,最后再重新调用下 runtime.SetFinalizer以避免对象被真正回收掉并且在每一次 GC 中都触发我们设置的钩子函数,基本思路可以参考:

type finalizer struct {
        ref *finalizerRef
}

type finalizerRef struct {
        parent *finalizer
}

func finalizerHandler(f *finalizerRef) {
        // 为 GOGC 动态设值
        getCurrentPercentAndChangeGOGC()
  // 重新设置回去,否则会被真的清理
        runtime.SetFinalizer(f, finalizerHandler)
}

func NewTuner(options ...OptFunc) *finalizer {
  // 处理传入的参数
  ...
  
  f := &finalizer{}
        f.ref = &finalizerRef{parent: f}
        runtime.SetFinalizer(f.ref, finalizerHandler)
  // 设置为 nil,让 GC 认为原 f.ref 函数是垃圾,以便触发 finalizerHandler 调用
        f.ref = nil
  return f
}

Why not

  • 为什么不使用分代 GC

分代GC我们在文章开头已经介绍了,将内存分代可以减少 GC 需要扫描的内存数, 但是不同于大多数语言的实现(比如Java、Python 等),Go 官方也曾经尝试过分代 GC,不过最终也没有采用,最重要的原因就是

  1. 得益于逃逸分析技术,在 Go 中,大部分年轻代对象会分配到栈上,在函数栈释放时就会被回收,没必要再次进行年轻代对象扫描。
  • 为什么不使用压缩 GC

    •   go 使用了更现代的内存分配器,基于 tcmalloc 的内存分配方案基本上没有内存碎片问题,所以在 GC 后即使不压缩,也没有问题,反过来说,对内存压缩的开销需要更长的 STW 做指针的移动。

参考文档

  1. 官方 gc 文档:tip.golang.org/doc/gc-guid…
  2. gc ballast :blog.twitch.tv/en/2019/04/…
  3. gc tuner : www.uber.com/en-US/blog/…
  4. SetMemoryLimit 的使用场景:colobu.com/2022/06/20/…
  5. 混合写屏障提案,github.com/golang/prop…
  6. www.cnblogs.com/luozhiyun/p…
  7. xargin.com/the-new-api…
  8. go.dev/talks/2015/…
  9. gc pointer bitmap:www.sobyte.net/post/2022-0…
  10. tc malloc google.github.io/tcmalloc/de…