聊聊两个Go即将过时的GC优化策略 您所在的位置:网站首页 golang性能对比 聊聊两个Go即将过时的GC优化策略

聊聊两个Go即将过时的GC优化策略

2023-01-03 04:26| 来源: 网络整理| 查看: 265

概述

首先我们来简单的看一下 Go GC中做了什么事,以及它里面比较耗时的地方是什么,我们才能对它进行优化。

首先对于 GC 来说有这么几个阶段:

sweep termination(清理终止):会触发 STW ,所有的 P(处理器) 都会进入 safe-point(安全点); the mark phase(标记阶段):恢复程序执行,GC 执行根节点的标记,这包括扫描所有的栈、全局对象以及不在堆中的运行时数据结构; mark termination(标记终止):触发 STW,扭转 GC 状态,关闭 GC 工作线程等; the sweep phase(清理阶段):恢复程序执行,后台并发清理所有的内存管理单元;

在这几个阶段中,由于标记阶段是要从根节点对堆进行遍历,对存活的对象进行着色标记,因此标记的时间和目前存活的对象有关,而不是与堆的大小有关,也就是堆上的垃圾对象并不会增加 GC 的标记时间。

并且对于现代操作系统来说释放内存是一个非常快的操作,所以 Go 的 GC 时间很大程度上是由标记阶段决定的,而不是清理阶段。

在什么时候会触发 GC ?

我在这篇文章 https://www.luozhiyun.com/archives/475 做源码分析的时候有详细的讲到过,我这里就简单的说下。

在 Go 中主要会在三个地方触发 GC:

1、监控线程 runtime.sysmon 定时调用;

2、手动调用 runtime.GC 函数进行垃圾收集;

3、申请内存时 runtime.mallocgc 会根据堆大小判断是否调用;

runtime.sysmon

Go 程序在启动的时候会后台运行一个线程定时执行 runtime.sysmon 函数,这个函数主要用来检查死锁、运行计时器、调度抢占、以及 GC 等。

它会执行 runtime.gcTrigger中的 test 函数来判断是否应该进行 GC。由于 GC 可能需要执行时间比较长,所以运行时会在应用程序启动时在后台开启一个用于强制触发垃圾收集的 Goroutine 执行 forcegchelper 函数。

不过 forcegchelper 函数在一般情况下会一直被 goparkunlock 函数一直挂起,直到 sysmon 触发GC 校验通过,才会将该被挂起的 Goroutine 放转身到全局调度队列中等待被调度执行 GC。

runtime.GC

这个比较简单,会获取当前的 GC 循环次数,然后设值为 gcTriggerCycle 模式调用 gcStart 进行循环。

runtime.mallocgc

我在内存分配 https://www.luozhiyun.com/archives/434 这一节讲过,对象在进行内存分配的时候会按大小分成微对象、小对象和大对象三类分别执行 tiny malloc、small alloc、large alloc。

Go 的内存分配采用了池化的技术,类似 CPU 这样的设计,分为了三级缓存,分别是:每个线程单独的缓存池mcache、中心缓存 mcentral 、堆页 mheap 。

tiny malloc、small alloc 都会先去 mcache 中找空闲内存块进行内存分配,如果 mcache 中分配不到内存,就要到 mcentral 或 mheap 中去申请内存,这个时候就会尝试触发 GC;而对于 large alloc 一定会尝试触发 GC 因为它直接在堆页上分配内存。

如何控制 GC 是否应该被执行?

上面这三个触发 GC 的地方最终都会调用 gcStart 执行 GC,但是在执行 GC 之前一定会先判断这次调用是否应该被执行,并不是每次调用都一定会执行 GC, 这个时候就要说一下 runtime.gcTrigger中的 test 函数,这个函数负责校验本次 GC 是否应该被执行。

runtime.gcTrigger中的 test 函数最终会根据自己的三个策略,判断是否应该执行GC:

gctest

gcTriggerHeap:按堆大小触发,堆大小和上次 GC 时相比达到一定阈值则触发; gcTriggerTime:按时间触发,如果超过 forcegcperiod(默认2分钟) 时间没有被 GC,那么会执行GC; gcTriggerCycle:没有开启垃圾收集,则触发新的循环;

如果是 gcTriggerHeap 策略,那么会根据 runtime.gcSetTriggerRatio 函数中计算的值来判断是否要进行 GC,主要是由环境变量 GOGC(默认值为100 ) 决定阈值是多少。

我们可以大致认为,触发 GC 的时机是由上次 GC 时的堆内存大小,和当前堆内存大小值对比的增长率来决定的,这个增长率就是环境变量 GOGC,默认是 100 ,计算公式可以大体理解为:

hard_target = live_dataset + live_dataset * (GOGC / 100).

假设目前是 100M 内存占用,那么根据上面公式,会到 200M 的时候才会触发 GC。

触发 GC 的时机其实并不只是 GOGC 单一变量决定的,在代码 runtime.gcSetTriggerRatio 里面我们可以看到它控制的是一个范围:

func gcSetTriggerRatio(triggerRatio float64) { // gcpercent 由环境变量 GOGC 决定 if gcpercent >= 0 { // 默认是 1 scalingFactor := float64(gcpercent) / 100 // 最大的 maxTriggerRatio 是 0.95 maxTriggerRatio := 0.95 * scalingFactor if triggerRatio > maxTriggerRatio { triggerRatio = maxTriggerRatio } // 最大的 minTriggerRatio 是 0.6 minTriggerRatio := 0.6 * scalingFactor if triggerRatio < minTriggerRatio { triggerRatio = minTriggerRatio } } else if triggerRatio < 0 { triggerRatio = 0 } memstats.triggerRatio = triggerRatio trigger := ^uint64(0) if gcpercent >= 0 { // 当前标记存活的大小乘以1+系数triggerRatio trigger = uint64(float64(memstats.heap_marked) * (1 + triggerRatio)) ... } memstats.gc_trigger = trigger ... }

具体阈值计算是比较复杂的,从 gcControllerState.endCycle 函数中可以看到执行 GC 的时机还要看以下几个因素:

当前 CPU 占用率,GC 标记阶段最高不能超过整个应用的 25%; 辅助 GC 标记对象 CPU 占用率; 目标增长率(预估),该值等于:(下次 GC 完后堆大小 - 堆存活大小)/ 堆存活大小; 堆实际增长率:堆总大小/上次标记完后存活大小-1; 上次GC时触发的堆增长率大小;

这些综合因素计算之后得到的一个值就是本次的触发 GC 堆增长率大小。这些都可以通过 GODEBUG=gctrace=1,gcpacertrace=1 打印出来。

下面我们看看一个具体的例子:

package main import ( "fmt" ) func allocate() { _ = make([]byte, 1 loop. ... gc 1409 @0.706s 14%: 0.009+0.22+0.076 ms clock, 0.15+0.060/0.053/0.033+1.2 ms cpu, 4->6->2 MB, 5 MB goal, 16 P gc 1410 @0.706s 14%: 0.007+0.26+0.092 ms clock, 0.12+0.050/0.070/0.030+1.4 ms cpu, 4->7->3 MB, 5 MB goal, 16 P gc 1411 @0.707s 14%: 0.007+0.36+0.059 ms clock, 0.12+0.047/0.092/0.017+0.94 ms cpu, 5->7->2 MB, 6 MB goal, 16 P ... < loop.

上面展示了 3 次 GC 的情况,下面我们看看:

gc 1410 @0.706s 14%: 0.007+0.26+0.092 ms clock, 0.12+0.050/0.070/0.030+1.4 ms cpu, 4->7->3 MB, 5 MB goal, 16 P 内存 4 MB:标记开始前堆占用大小 (in-use before the Marking started) 7 MB:标记结束后堆占用大小 (in-use after the Marking finished) 3 MB:标记完成后存活堆的大小 (marked as live after the Marking finished) 5 MB goal:标记完成后正在使用的堆内存的目标大小 (Collection goal)

可以看到这里标记结束后堆占用大小是7 MB,但是给出的目标预估值是 5 MB,你可以看到回收器超过了它设定的目标2 MB,所以它这个目标值也是不准确的。

在 1410 次 GC 中,最后标记完之后堆大小是 3 MB,所以我们可以大致根据 GOGC 推测下次 GC 时堆大小应该不超过 6MB,所以我们可以看看 1411 次GC:

gc 1411 @0.707s 14%: 0.007+0.36+0.059 ms clock, 0.12+0.047/0.092/0.017+0.94 ms cpu, 5->7->2 MB, 6 MB goal, 16 P 内存 5 MB:标记开始前堆占用大小 (in-use before the Marking started) 7 MB:标记结束后堆占用大小 (in-use after the Marking finished) 2 MB:标记完成后存活堆的大小 (marked as live after the Marking finished) 6 MB goal:标记完成后正在使用的堆内存的目标大小 (Collection goal)

可以看到在 1411 次GC启动时堆大小是 5 MB 是在控制范围之内。

说了这么多 GC 的机制,那么有没有可能 GC 的速度赶不上制造垃圾的速度呢?这就引出了 GC 中的另一种机制:Mark assist。

如果收集器确定它需要减慢分配速度,它将招募应用程序 Goroutines 来协助标记工作。这称为 Mark assist 标记辅助。这也就是为什么在分配内存的时候还需要判断要不要执行 mallocgc 进行 GC。

在进行 Mark assist 的时候 Goroutines 会暂停当前的工作,进行辅助标记工作,这会导致当前 Goroutines 工作的任务有一些延迟。

而我们的 GC 也会尽可能的消除 Mark assist ,所以会让下次的 GC 时间更早一些,也就会让 GC 更加频繁的触发。

我们可以通过 go tool trace 来观察到 Mark assist 的情况:

image-20220612175510974

Go Memory Ballast

上面我们熟悉了 Go GC 的策略之后,我们来看看 Go Memory Ballast 是怎么优化 GC 的。下面先看一个例子:

func allocate() { _ = make([]byte, 1


【本文地址】

公司简介

联系我们

今日新闻

    推荐新闻

    专题文章
      CopyRight 2018-2019 实验室设备网 版权所有