Go垃圾回收[7]-标记终止与调步算法

324次阅读  |  发布于2年以前

前情回顾

标记终止阶段

当完成并发标记阶段所有灰色对象的扫描和标记,则进入到标记终止阶段。标记终止阶段会再次进入STW,标记终止阶段主要完成一些指标例如用时的统计、统计强制开始GC的次数、更新下一次触发gc需要达到的目标、关闭写屏障、并唤醒后台清扫的协程,开始下一阶段的清扫工作。

在标记终止阶段重要的任务是计算下一次触发垃圾回收时需要达到的堆目标,这叫做垃圾回收的调步算法。

调步算法是Go1.5提出的算法,由于Go1.5开始使用并发的三色标记,因此,当开始进行GC到GC结束的过程中,用户协程也可能在分配大量的内存。所以在GC的过程中,内存的大小实际上超过了我们设定的触发GC的目标。为了解决这样的问题,需要对程序进行估计,从而在目标内存之前就启动GC、并预计在GC结束之后,程序的内存大小刚好在目标内存附近。

因此,调步算法最重要的任务是估计出最佳的下一次触发GC的时机,而这依赖于本次GC阶段差额最终GC完成后的内存与目标内存之前的差距。当GC完成后的内存远小于目标内存,意味着我们触发GC的时间过早。如果GC完成后的内存远大于目标内存,意味着触发GC的时间太迟。

因此调度算法 的第一个目标是min(|目前内存-本次GC完成标记后的内存|),除此之外,调步算法还有第二个目标,即预计执行标记的CPU暂用率接近25%。结合之前提到的25%的后台标记协程,这个目标是满足的,正常情况下,只会有25%的CPU去执行后台标记任务。但是当用户工作协程执行了“辅助标记”

(下一篇文章介绍),这一目标将不再成立。当用户协程执行了过多的辅助标记,这将导致GC标记完成后的内存偏小,因为用户协程本来应该分配内存的时间用来了执行辅助标记。

算法将首先计算目标内存与实际内存的偏差,这是通过计算

偏差率 =  (目标增长率 - 触发增率) - (实际增长率 - 触发率)

来实现的。这其实是偏差=(目标内存 - 触发GC时的内存) - (GC标记完成后的内存- 触发GC时的内存) 的变形。

为了修复辅助标记带来的偏差,计算了辅助标记所用的时间,从而调整了(GC标记完成后的内存- 触发GC时的内存) 的大小。因此最终的偏差会调整为:

偏差率   =  (目标增长率 - 触发率) -  调整率  *(实际增长率 - 触发率)

实际代码如下:

func (c *gcControllerState) endCycle() float64 {
    utilization := gcBackgroundUtilization
    if assistDuration > 0 {
        utilization += float64(c.assistTime) / float64(assistDuration*int64(gomaxprocs))
    }
    triggerError := goalGrowthRatio - memstats.triggerRatio - utilization/gcGoalUtilization*(actualGrowthRatio-memstats.triggerRatio)
    triggerRatio := memstats.triggerRatio + triggerGain*triggerError
}

从公式中可以看出,实际增长率 和 辅助标记的时间都会影响最终的偏差。当调整后估计的实际内存越偏离于实际内存时,偏差率越大。这时,会调整下一次GC时的触发率,调整时,采取了渐进调整,每次只调整偏差的一半。下一次GC时的触发率的公式如下:

下次GC触发率 = 上次GC触发率  + 1/2 * 偏差率

计算完GC触发率之后,最终需要计算出实际的GC触发大小。这是在标记终止阶段gcSetTriggerRatio函数中完成的。目标内存的大小计算为:

goal := ^uint64(0)

if gcpercent >= 0 {
        goal = memstats.heap_marked + memstats.heap_marked*uint64(gcpercent)/100
}

goal为下次GC完成后期望的目标内存,其取决于本次GC中扫描后内存的大小以及gcpercent的大小。gcpercent是可以由用户动态设置的。调用debug标准库的SetGCPercent函数,可以修改gcpercent的大小。

func SetGCPercent(percent int) int

gcpercent默认为100,代表目标内存是前一此GC标记内存的一倍。当修改gcpercent小于0,将禁用Go的垃圾回收。另外,也可以在编译或运行时添加"GOGC"环境变量的方式修改gcpercent大小。核心逻辑是在程序初始化时调用readgogc()实现的。例如GOGC=off ./main 将关闭程序的GC回收。

func readgogc() int32 {
p := gogetenv("GOGC")
if p == "off" {
    return -1
}

if n, ok := atoi32(p); ok {

    return n

}
    return 100
}

当明确了目标内存大小,触发内存的大小可以简单定义为触发内存的大小 = 触发率 * 目标内存大小,但触发率不能超过0.95,也不能够小于0.6。因此最终触发内存大小在0.6*目标内存大小—0.95*目标内存大小之间。

Copyright© 2013-2020

All Rights Reserved 京ICP备2023019179号-8