前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >Go GC 二三事

Go GC 二三事

作者头像
aoho求索
发布2021-11-16 10:55:31
3450
发布2021-11-16 10:55:31
举报
文章被收录于专栏:aoho求索aoho求索

作者 | 鬼鸮

来源 | https://www.jianshu.com/p/b6ea3d3275ee

一、从内存说起

我们知道程序运行时使用的常量变量其实都是存储在内存中的,所谓垃圾回收也就是将程序占用了,但现在已经不再使用的内存空间进行回收。那内存中都存储了些什么东西呢?

程序内存

  • 代码区:存储给cpu运行时读取用的我们编译好的代码
  • 数据区:存储全局变量

我们的程序在运行的时候,与我们接触更多的是堆区和栈区,大家都知道我们在程序中声明的变量会分配到这两个区域中。 对于go程序来说,我们一般不需要关心一个变量到底被go分配到了堆区还是栈区,但这里姑且还是提一下go的分配原则

  • 是否有在其他地方(非局部)被引用。只要有可能被引用了,那么它一定分配到堆上。否则分配到栈上
  • 即使没有被外部引用,但对象过大,无法存放在栈区上。依然有可能分配到堆上
  • go基本上每个版本对逃逸分析都会有所优化,所以不必死记,领会思想即可,需要的话直接通过 go build -gcflags '-m -l' 就可以看到逃逸分析的过程和结果

通过上面的原则我们可以看到,其实在分配内存时,是优先分配到栈上的,满足一些条件的时候才会逃逸到堆上,那么这是为什么呢?

我们写代码时,除了全局变量的声明,我们实际编写的逻辑,全部都是包在一个个的函数中的,比如程序是从main()函数开始执行的,而函数之间的调用,总体上是:

  1. A函数调用B函数
  2. B函数调用D函数
  3. D函数执行完毕返回到B函数
  4. B函数执行完毕返回到A函数
  5. A函数调用C函数
  6. C函数执行完毕返回到A函数
  7. A函数执行完毕结束调用

这样的一个流程,每个函数都有它自己需要存储的变量,A函数在调用了B函数之后,CPU会将A函数挂起转而去执行B函数,在B函数返回之前,A函数实际上是阻塞等待的,而C函数和B函数执行完毕后,他们各自占用的内存空间会被释放掉,所以函数对栈空间的占用看起来就像下图这样:

函数调用栈

从上面的描述大家应该也能够理解,栈这种先进后出的特性天然的适用这种函数互相调用的场景,因此栈空间是函数优先使用的内存空间。 但同时,如果函数执行完毕,该函数的栈空间就会被标记为释放,如果我们在函数中创建了全局变量,或者我们的函数返回了某个对象给调用方,这些变量就不适合放在本函数的栈空间中,他们就会被放在堆空间,以便于其他函数访问。

那函数在栈上都放些啥呢

栈区详情

假设现在,函数A调用了函数B,则CPU执行的内容从函数A转移到了函数B,要实现这个转移,CPU需要知道

  • 函数B的第一条机器指令的地址
  • 函数A的机器指令执行到的位置,好在函数B执行结束后返回

在我们的代码执行函数调用时,机器指令会调用call指令,指向函数B的地址,同时将函数A的下一条机器指令的地址push进函数A的栈帧中【即图中的0x40037b】,这样CPU在执行完成函数B后,可以读取这个地址继续执行函数A。

一般函数调用时,我们会有一些参数需要传进被调用函数中,这些参数会存储在寄存器中,由被调用函数从寄存器中读取;但是寄存器是有数量限制的,如果参数多于寄存器数量的话,多出来的参数会被直接放进调用函数的栈帧中,这样被调用函数就可以从调用函数的栈帧中获取参数。

函数内部定义的局部变量,默认也是存储在寄存器中的,但如果寄存器放不下的话,也会存放在函数的栈帧中。

寄存器是共享资源可以被任意函数使用,既然函数A把局部变量写进了寄存器,当函数A调用函数B的时候,函数B也把自己的局部变量写进寄存器,那当函数B执行完毕回来执行函数A的时候,函数A的局部变量不就被函数B改过了嘛,这样会有问题吧?

的确会有问题,所以当我们要往寄存器中写入函数B的局部变量前,要先将函数A的寄存器中值保存下来,保存在哪里呢,还是保存在函数A的栈帧中,这样当函数B执行完毕,就会从函数A的栈帧中读取寄存器初始值,恢复到寄存器中,这样函数A才能正常地继续执行。

二、垃圾回收

上面讲了我们都往内存里存些什么,那存储空间我们只存不释放的话总有写满的一天,将已经不再使用的内存空间释放的行为称为垃圾回收,也就是GC。

在go语言的早期版本中,垃圾回收的性能是非常糟糕的,历经数年多次迭代,如今的goGC性能已经有了相当大的优化

Go 1.3之前的标记清除算法

启动STW,将程序暂停,遍历所有对象,将所有不可达对象标记出来,清除这些不可达对象,结束STW

1.3之前的GC

逻辑非常简单易懂,算法的问题也很大,那就是mark标记阶段需要扫描整个堆,而且STW的时间很长,带来的卡顿人可感知,这必然是不好的,当时主要是通过在代码上及时手动释放内存及减少大内存的频繁申请和释放来尽量规避这个问题。

Go 1.3的标记清除算法

go1.3版本时将sweep清除的行为改在停止STW之后,起一个协程来与其他逻辑并发执行sweep,如果程序运行在多核CPU上,go还会尽可能的将这个协程调度到一颗单独的CPU上去执行,以尽量减少对现有逻辑的影响。此次优化后按go团队的说法,减少了50%-70%的STW时间。

1.3的GC

Go 1.5的三色并发标记法

三色标记法的操作逻辑其实也不复杂

  1. GC开始时,所有对象标记为白色
  2. 从根节点开始遍历所有白色对象,将遍历到的对象从白色改为灰色
  3. 从灰色对象作为根节点开始遍历所有白色对象,将遍历到的对象从白色改为灰色,并将灰色的根节点改为黑色
  4. 重复第三步,直到所有灰色对象都变成黑色对象
  5. 清除白色对象

简单图例

GC开始

第一次置灰

第二次置灰 第一次置黑

第三次置灰 第二次置黑

第四次置灰 第三次置黑

第四次置黑 标记完成

将白色对象回收

上述操作很自然地给大家解释了为啥叫三色标记法,那并发又是怎么个并发呢 我们知道之前的GC都需要STW,那三色标记法如果不STW会怎么样呢?

对象6引用对象3

刚刚扫描完对象1和对象6,对象2和对象7为灰色,还未开始对对象2和对象7进行扫描。此时黑色对象6创建对白色对象3的引用。

对象2取消引用对象3

并且灰色对象2取消对白色对象3的引用

继续扫描

扫描结果如图所属,因为对象6已经是黑色对象,所以不会重复去扫描,从而导致对象3最终没有被扫描到,在回收阶段连带对象3引用的对象4都会被错误地回收掉。

可见,会出现这个错误,需要同时满足两个条件

  • 一个黑色对象引用一个白色对象
  • 灰色对象与此白色对象之间的可达关系被破坏

只要这两个条件同时满足,就会出现对象丢失的情况。

要解决这个问题,最简单的做法就是在扫描阶段STW,但是STW的性能损耗太大,怎么样能做到不STW地对对象的引用关系进行扫描呢?

答案是,我们想办法破坏掉上面的两个条件就可以了。

为了破坏掉上述两个条件,go团队提出了强/弱三色不变性两个补充规则,分别用于破坏其中一个规则

强弱三色不变性表

Go 1.8的混合写屏障机制

在go的1.8版本中,对写屏障这块的逻辑进行了大幅优化,在优化思想上是希望结合插入屏障和删除屏障各自的优点,综合提升GC性能。

  1. GC开始时,STW,对栈进行扫描,将所有可达对象置黑
  2. GC期间,任何栈上新创建的对象,皆置黑【1和2配合,可避免插入屏障对栈区的二次三色标记】
  3. 被删除的对象置灰
  4. 新挂载的对象置灰

与插入屏障对比,可以看到混合写屏障的思路中,将GC大致分为3个阶段【本质上还是 标记-清扫 两阶段】

  • 首先是开始时的STW,专门扫描栈区【标记】
  • 然后是对堆进行并发三色标记,通过将堆上所有新增挂载和删除挂载的对象全部置灰,栈上所有新增对象全部置黑来保证不出现对象丢失的问题【标记】
  • 最后是并发清理白色对象【清扫】

具体的GC实现细节随着go版本的迭代是不断在优化的,但总体的实现思路就是这样的三阶段。 要注意的是机制3和机制4在栈上是不生效的,栈由机制1和机制2控制

GC开始

先扫描栈区

GC开始先对栈区进行扫描,将所有可达对象置黑

对象7引用对象2

这时将堆区的对象7挂载到栈区的对象2下。因为写屏障在栈区是不生效的,所以这个挂载行为不会改变对象7的颜色。

对象6取消引用对象7

触发屏障 对象7置灰

对象7删除对对象6的挂载。触发混合写屏障机制,对象7被删除挂载,故将对象7置灰。

继续标记

标记完成

继续对堆区进行三色标记,直到将所有可达对象置黑

Go 1.12的GC细节

本质还是【标记-清扫】,其中标记有三个阶段,其中两个阶段会STW导致程序暂停,标记完成才会进入并发清扫阶段

  1. 标记开始阶段【需要STW】
  2. 并发标记阶段【至少占用25%的CPU】
  3. 标记终止阶段【需要STW】
  4. 并发清扫阶段

标记开始阶段

标记开始阶段也就是GC的开始阶段,在此阶段需要先打开混合写屏障机制,而打开混合写屏障机制需要STW,需要将所有的goroutine停下来。 打开混合写屏障的速度很快,平均在10-30微秒。但是因为STW要求把所有的goroutine停下来,而为了保证goroutine停下时处于一个比较安全的状态,go的垃圾收集器会观察goroutine,一般是在goroutine进行函数调用时将其暂停。

这里有个问题,就是如果有什么逻辑导致一个goroutine一直在运行没有进入想函数调用这种可以安全暂停的状态【比如你写个流程极长的循环累加什么的】就会导致其他goroutine都被停下来等待这一个没能暂停的goroutine,但是这个goroutine又一直未能进入暂停状态,从而导致程序运行严重卡顿 此问题在go1.14版本被优化,加入了抢占机制。

并发标记阶段

混合写屏障打开之后,就要开始并发标记了。

垃圾收集器会占用25%的CPU资源,也就是说如果程序跑在4线程的机器上,此时垃圾收集器会直接占用掉一个线程的资源,一个P将会专用于垃圾收集。

先扫描所有goroutine的堆栈,找到堆内存的根指针,然后从跟指针开始遍历遍历整个堆,通过三色标记法对内存进行标记,此标记过程中混合写屏障一直生效。

这里也有个问题,就是如果我们的程序一直在持续不断的大量分配内存,可能会出现垃圾收集器扫描和标记的速度跟不上我们程序分配内存的速度,从而导致这个并发标记阶段永远不会结束【除非内存被占满】。

所以如果真的出现这样的情况,垃圾收集器会评估当前在运行的goroutine,将内存分配最多的那个goroutine暂停,转化为Mark Assist【协助标记】,转化的时间与此goroutine申请的内存大小成正比。通过这种方式可以实现【开源节流】的效果加快并发标记。

但很显然这种【借调】行为会增加垃圾收集对程序运行性能的影响,所以垃圾收集器会努力减少Mark Assist的使用,具体表现在,如果一次GC使用了大量的Mark Assist,则垃圾收集器会提前开始下一次GC周期,通过加快频率来减少对Mark Assist的使用。

标记终止阶段

对堆上的对象遍历标记完成以后,就会进入标记终止阶段,此阶段需要再一次进行STW以关闭混合写屏障机制,执行各项清理任务,并计算下一次GC的周期。此次STW平均花费60-90微秒。

和标记开始阶段一样,如果出现一直难以暂停的goroutine将会导致本阶段的STW延长。

并发清扫阶段

在并发清扫阶段,并不会一次性将标记出来的白色对象全部清理掉,而是在goroutine尝试在堆中分配内存时触发,这样可以将清扫带来的延迟分散到每一次内存分配的时候,避免程序出现过于明显的卡顿。

GC Percentage

运行时中有一个GC Percentage的配置项,默认为100,这个配置的意思是在下次GC必须启动前,可以分配多少新内存的比例。

比如说某次GC后,堆内存占用为2M,此设置为100则意思是,当堆内存占用达到4M时触发GC。 那如果我们把设置改为200,那就是堆内存占用达到6M时触发GC,以此类推 需要注意的是,将这个值调大并不一定是一个好的选择,确实我们允许程序占用的空间会变多,这能减少GC的频率,但也导致每次GC需要扫描和清理的内存变大,这会导致GC的耗时变长。

一般来说我们还是不建议去改这个配置——事实上大部分情况下我们程序的性能瓶颈都不会是他。

本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。
原始发表:2021-11-10,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 aoho求索 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 一、从内存说起
  • 二、垃圾回收
    • Go 1.3之前的标记清除算法
      • Go 1.3的标记清除算法
        • Go 1.5的三色并发标记法
          • Go 1.8的混合写屏障机制
            • Go 1.12的GC细节
              • 标记开始阶段
              • 并发标记阶段
              • 标记终止阶段
              • 并发清扫阶段
          • GC Percentage
          领券
          问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档