go如何触发垃圾回收的

Posted 2022-11-20 20:39 +0800 by ZhangJie ‐ 1 min read

分享:  

前言

go触发GC有这么几个时机,内存分配时触发mallogc,定时触发sysmon,手动触发runtime.GC、debug.FreeOSMemory,其中内存分配时触发是go是重中之重,go runtime以此来平衡好内存分配、内存回收的节奏以让内存占用维持在一个合适的水准。本文对内存分配过程中触发GC的一些设计考量进行了总结梳理。

应该听过“mark assist”

gc过程中mutator分配内存时可能会被搞去做assistg,去辅助扫描标记一些对象是否该回收的工作,当我的辅助标记内存数量减去要申请的内存数,如果为负数时,相当于我申请的比辅助标记的多,相当于欠债了,这个时候我就得去做些辅助标记的工作 gcAssistBytes:

  • 然后根据当前内存使用情况\扫描情况\下次GC的heapgoal,计算出我应该辅助标记多少,才能保证达到堆大小时GC标记工作恰好能完成,让我去干活
  • 这个时候干活之前会先检查下bgMarkWorker因为扫描工作贡献的信用分,然后我可以借用这个信用分来偿还债务,以减少扫描工作,或者完全避免扫描工作
  • 如果依旧欠债,那就干活呗,后面会执行到gcDrainN,去执行一些标记类的工作
    • 这些标记类的工作从何而来呢,比如写屏障记录下来的一些需要去扫描的对象
    • 执行完了这个扫描之后,这个assistG.gcAssistBytes就会加上扫描的字节数,相当于攒的一点信用分
  • 干完这些之后,才允许你申请内存\分配对象,哈哈哈!

goroutine可以去做些mark assist之类的工作的前提是,GC已经进入了GCMark阶段,那内存分配期间GC是什么如何被触发的呢?

GC什么情况下被触发的

关于什么时候触发GC,严谨一点,内存分配期间何时触发的GC,这里不考虑sysmon触发、手动runtime.GC()触发,ok。

GOGC\GOMEMLIMIT\heapGoal

我们应该都这样的认识阶段,通过GOGC、GOMEMLIMIT可以计算出下次GC时的heapGoal,等堆内存占用达到这个heapGoal时会触发GC。

但是严格来讲,理解成接下来内存占用达到heapGoal才触发GC,是不正确的。

引入GC Trigger

  • 为了触发GC,还有一个概念,叫GC trigger,它的值heapGoal要小些,在GCOff阶段,内存分配过程中会检查当前heapLive大小是否超过了这个trigger,是则启动gc(gcStart)
  • 那个协程来负责检查是否启动gc,可以理解成所有的协程,协程如果是申请大内存(>32K)则一定会做上述检查,小内存为了效率则不一定每次检查,当如果申请小内存(tiny or small)如果过程中span不足发生了refill也会做上述检查(shouldhelpgc)
  • 当启动了GC之后,接下来goroutines如果涉及到内存分配,就会转入markAssist阶段,要分配多少,先要干一定量的标记扫描的活才行(内存debt/assist设计)
  • 那么heapGoal干嘛用的呢,前面提到的内存debt/assist设计,就是为了在当前堆大小达到heapGoal时尽量完成内存的标记扫描,将markbits改成allocbits,未使用的就可以复用或者等下个GC cycle阶段回收

所以从GC trigger到heapGoal,这中间是有一些考量的,如果只认为GC heapGoal控制GC的触发,其实是认识不到位的。ps:可能在这这个提案 GC pacer redesign 实现之前确实是根据heapGoal来触发的,但是这会导致内存的不受限制的增长。

GC Trigger计算

那么这个GC trigger是如何计算的呢?

  • 首先它不能比heapGoal小很多,那可能会导致GC启动过早,写屏障打开后程序latency会上升,而且如果内存分配比较快GC一直触发运行,期间分配的对象会一直标记为black,Rss会上升
  • 也不能过晚触发,可能导致标记扫描阶段assistG的工作量过大,latency会比较明显,而且会堆大小增长会超出预期。

至于如何计算的,可以先看下上面这个提案中关于GC trigger的设计,然后翻下源码瞧瞧……额,还是简单总结下吧:

  • 明确下目标,GC trigger是用来确定何时触发GC的一个值,当内存分配导致堆大小变化时会检查当前heapLive>trigger来决定是否触发GC(申请大内存必检查,申请小内存为了效率一般不检查,但在span不足refill后检查)
  • GC trigger如何计算出来的:
    • 首先根据GOGC、GOMEMLIMIT算出下次GC的heapGoal,
    • 然后根据minTrigger=heapMarked+(heapGoal-heapMarked)*0.7,
    • 然后maxTrigger=heapMarked+(heapGoal-heapMarked)*.0.95,如果堆比较小就用这里算出的值意味着总有一个buffer来赶在内存占用达到heapGoal之前启动GC。如果堆比较大但是有没有多少扫描工作,就用heapGoal-defaultHeapMinimum(4MB)来作为maxTrigger,这也是一种优化。 ps: 这里的heapMarked表示上轮GC标记结束时堆大小。这两个值,相当于确定了一个候选的触发GC的heapLive范围,最终trigger值一定要大于等于minTrigger,一定要小于等于maxTrigger。
  • 确定trigger:
    • 确定runway,根据上轮GC过程记录的consMark(程序分配内存、扫描内存量的比值)、实际的扫描内存的量(heap+stack+global)以及并发标记执行阶段mutator:collector的CPU执行时间的比值3:1,可以大致算出下一轮GC期间内存使用量能涨到多少,这个源码中选了个词叫runway,意思是我们内存使用量能走多远。
    • 很明显如果这个值如果大于heapGoal说明我们很可能会让堆占用走高,此时需要更激进地触发GC,所以此时的trigger就选下界minTrigger。
    • 如果这个值比比heapGoal小,那就用goal-runway作为trigger,但是这个值表示的时啥?如果这个值比minTrigger小就用minTrigger。
    • 前面还算了个最大trigger,如果这里的trigger值比maxTrigger还大,那trigger要改成maxTrigger。

Put it together

OK,现在知道了trigger值是怎么详细计算的了,好,我们继续串一下:

  • 如果当前没有触发GC,当前goroutine正在执行内存分配
    • 根据当前内存分配的量来确定是否shouldhelpgc(>32K一定为true,反之则要根据是否有span不足refill)
      • 如果否就分配完内存该干嘛干嘛去就完了
      • 如果需要辅助gc,首先先计算下当前trigger(上面详细描述了如何计算的),然后比较下当前heapLive、trigger的大小
        • 如果heapLive < trigger,不用触发GC,该干嘛干嘛
        • 如果heapLive > trigger,发起GC,即调用gcStart(),这里其实是发起一轮完整的GC,等它完成后再返回来该干嘛干嘛
  • 当有其他goroutine发起了GC,进入GCMark阶段后gcBlackenEnabled=1表示其他mutator要把新分配对象标记为黑,可以理解成当前进入GC阶段了
    • 每一个goroutine都要维护一个账本,自己分配了多少内存,自己辅助标记了多少内存
    • 如果自己分配的内存没有超过辅助标记的内存,gcAssistBytes>0,没欠债该干嘛干嘛去
    • 如果自己分配的内存超过自己辅助标记的内存,表示自己欠债了,欠债了怎么办?就得还债,还债就得去辅助标记内存,这就是我们说的markAssist
      • 如果我要分配npages的内存,那么要辅助扫描多少内存呢,这个运行时有个计算规则,总的目标是在GC发起后、内存占用达到heapGoal之前我能把所有的内存扫描完
      • 确定了要扫描多少内存后,就可以去干活了?等等,当前goroutine欠的债,也可以先找大佬帮还一下,这就是bgMarkWorker攒的credit(bytes),如果一次还不完,欠多少就是多少,自己去扫描就完了 ps: 这样有个好处,当前goroutine可以不用引入扫描内存的开销就可以继续干自己该干的事情。
    • 这个阶段会更新当前goroutine的这个账本,如果当前GC Cycle内它还有动作,就可以继续拿来秋后算账

当一轮GC Cycle结束时,go runtime会将当前的gcMarkBits作为gcAllocBits,意思就是这些没有被标记的内存都可以在后续分配内存对象时复用了,实在没用的就可以还给操作系统了。

本文小结

实际上内存分配器和垃圾回收器,这两个之间并不是割裂的关系,而是互相协作的两个组件,这里就只是介绍了内存分配过程中的主要逻辑,忽略了垃圾回收中的部分,也忽略了内存多层级组织的内容(mheap->arena->pages, mheap->mcentral->mspan->p.mcache)。

这篇文章总结的就是mallogc期间go runtime的一些考量,有时间在总结分享下go垃圾回收的部分。