专栏首页养码场Golang垃圾回收 屏障技术

Golang垃圾回收 屏障技术

垃圾回收(Garbage Collection,简称GC)是编程语言中提供的自动的内存管理机制,自动释放不需要的对象,让出存储器资源,无需程序员手动执行。

Golang中的垃圾回收主要应用三色标记法,GC过程和其他用户goroutine可并发运行,但需要一定时间的STW(stop the world),STW的过程中,CPU不执行用户代码,全部用于垃圾回收,这个过程的影响很大,Golang进行了多次的迭代优化来解决这个问题。

  • 1.3以前的版本使用标记-清扫的方式,整个过程都需要STW。
  • 1.3版本分离了标记和清扫的操作,标记过程STW,清扫过程并发执行。
  • 1.5版本在标记过程中使用三色标记法。回收过程主要有四个阶段,其中,标记和清扫都并发执行的,但标记阶段的前后需要STW一定时间来做GC的准备工作和栈的re-scan。
  1. Sweep Termination: 收集根对象,清扫上一轮未清扫完的span,启用写屏障和辅助GC,辅助GC是将一定量的标记和清扫工作交给用户goroutine来执行,写屏障在后面会详细说明
  2. Mark: 扫描所有根对象和根对象可以到达的对象,并标记它们
  3. Mark Termination: 完成标记工作,重新扫描部分根对象(要求STW),关闭写屏障和辅助GC
  4. Sweep: 按标记结果清扫对象
  • 1.8版本引入了混合写屏障机制(hybrid write barrier),避免了对栈re-scan的过程,极大的减少了STW的时间。

标记-清扫算法

标记-清扫算法是一种追踪式的垃圾回收算法,并不会在对象死亡后立即将其清除掉,而是在一定条件下触发,统一校验系统中的存活对象,进行回收工作。在Golang中,有以下几种情况可触发:

  • gcTriggerAlways: 强制触发GC
  • gcTriggerHeap: 当前分配的内存达到一定阈值时触发,这个阈值在每次GC过后都会根据堆内存的增长情况和CPU占用率来调整
  • gcTriggerTime: 当一定时间没有执行过GC就触发GC(2分钟)
  • gcTriggerCycle: runtime.GC()调用

标记-清扫分为两个部分,标记和清扫,标记过程会遍历所有对象,查找出死亡对象。我们可以使用指针的可达性可确认对象的存活,也就是说,如果存在一条从根出发的指针链最终可指向某个对象,就认为这个对象是存活的。这样,未能证明存活的对象就可以标记为死亡了。

根:是一个有限的指针集合,程序可不经过其他对象直接访问这些指针,堆中的对象被加载时,需要先加载根中的指针。在Go中,一般为goroutine自己的栈空间和全局栈空间。

标记结束后,再次进行遍历,清除掉确认死亡的对象。

三色标记法

将标记-清扫法使用三色抽象的方式来描述,可以方便我们理解回收器的正确性。

回收器通过将对象图划分为三种状态来指示其扫描过程。黑色对象为该对象及其后代已处理且该对象确认存活的,灰色对象为已经扫描到但未处理完成或者还需要再次处理的,白色对象为尚未扫描到或已经死亡的。

如图所示,回收器从根出发,扫描到可达对象后标记其为灰色,放入灰色队列,在扫描灰色对象引用的对象,将他们标记为灰色,自身标记为黑色。扫描全部结束后,剩余未被扫描到的对象留在白色队列中,表示已死亡。

标记过程需要STW,因为对象引用关系如果在标记阶段做了修改,会影响标记结果的正确性。例如下图,灰色对象B中包含指向白色对象C的指针e,对象C尚未被扫描,此时,如有其他程序,将e指针从B对象中删除,并将指向对象C的新指针f插入到黑色对象A中,由于对象A早已完成扫描,对象C就会一直保持白色状态直到被回收。

可以看出,一个白色对象被黑色对象引用,是注定无法通过这个黑色对象来保证自身存活的,与此同时,如果所有能到达它的灰色对象与它之间的可达关系全部遭到破坏,那么这个白色对象必然会被视为垃圾清除掉。故当上述两个条件同时满足时,就会出现对象丢失的问题。

如果这个白色对象下游还引用了其他对象,并且这条路径是指向下游对象的唯一路径,那么他们也是必死无疑的。

为了防止这种现象的发生,最简单的方式就是STW,直接禁止掉其他用户程序对对象引用关系的干扰,但是STW的过程有明显的资源浪费,对所有的用户程序都有很大影响,如何能在保证对象不丢失的情况下合理的尽可能的提高GC效率,减少STW时间呢?

在Golang中使用并发的垃圾回收,也就是多个赋值器与回收器并发执行,与此同时,应用屏障技术来保证回收器的正确性。其原理主要就是破坏上述两个条件之一。

屏障技术

当回收器满足下面两种情况之一时,即可保证不会出现对象丢失问题。

弱三色不变式:所有被黑色对象引用的白色对象都处于灰色保护状态(直接或间接从灰色对象可达)。强三色不变式:不存在黑色对象到白色对象的指针。

强三色不变式很好理解,强制性的不允许黑色对象引用白色对象即可。而弱三色不变式中,黑色对象可以引用白色对象,但是这个白色对象仍然存在其他灰色对象对它的引用,或者可达它的链路上游存在灰色对象。

三色抽象除了可以用于描述对象的状态的,还可用来描述赋值器的状态,如果一个赋值器已经被回收器扫描完成,则认为它是黑色的赋值器,如果尚未扫描过或者还需要重新扫描,则认为它是灰色的赋值器。

在强三色不变式中,黑色赋值器只存在到黑色对象或灰色对象的指针,因为此时所有黑色对象到白色对象的引用都是被禁止的。在弱三色不变式中,黑色赋值器允许存在到白色对象的指针,但这个白色对象是被保护的。

上述这些可以通过屏障技术来保证。

插入屏障

插入屏障拦截将白色指针插入黑色对象的操作,标记其对应对象为灰色状态,这样就不存在黑色对象引用白色对象的情况了,满足强三色不变式,如上图例中,在插入指针f时将C对象标记为灰色。Go1.5版本使用的Dijkstra写屏障就是这个原理,伪代码如下:

writePointer(slot, ptr):

shade(ptr)

*slot = ptr

在Golang中,对栈上指针的写入添加写屏障的成本很高,所以Go选择仅对堆上的指针插入增加写屏障,这样就会出现在扫描结束后,栈上仍存在引用白色对象的情况,这时的栈是灰色的,不满足三色不变式,所以需要对栈进行重新扫描使其变黑,完成剩余对象的标记,这个过程需要STW。这期间会将所有goroutine挂起,当有大量应用程序时,时间可能会达到10~100ms。

删除屏障

删除屏障也是拦截写操作的,但是是通过保护灰色对象到白色对象的路径不会断来实现的。如上图例中,在删除指针e时将对象C标记为灰色,这样C下游的所有白色对象,即使会被黑色对象引用,最终也还是会被扫描标记的,满足了弱三色不变式。这种方式的回收精度低,一个对象即使被删除了最后一个指向它的指针也依旧可以活过这一轮,在下一轮GC中被清理掉。Yuasa屏障伪代码如下:

writePointer(slot, ptr):

if (isGery(slot) || isWhite(slot))

shade(*slot)

*slot = ptr

在这种实现方式中,回收器悲观的认为所有被删除的对象都可能会被黑色对象引用。

混合写屏障

插入屏障和删除屏障各有优缺点,Dijkstra的插入写屏障在标记开始时无需STW,可直接开始,并发进行,但结束时需要STW来重新扫描栈,标记栈上引用的白色对象的存活;Yuasa的删除写屏障则需要在GC开始时STW扫描堆栈来记录初始快照,这个过程会保护开始时刻的所有存活对象,但结束时无需STW。Go1.8版本引入的混合写屏障结合了Yuasa的删除写屏障和Dijkstra的插入写屏障,结合了两者的优点,伪代码如下:

writePointer(slot, ptr):

shade(*slot)

if current stack is grey:

shade(ptr)

*slot = ptr

这里使用了两个shade操作,shade(*slot)是删除写屏障的变形,例如,一个堆上的灰色对象B,引用白色对象C,在GC并发运行的过程中,如果栈已扫描置黑,而赋值器将指向C的唯一指针从B中删除,并让栈上其他对象引用它,这时,写屏障会在删除指向白色对象C的指针的时候就将C对象置灰,就可以保护下来了,且它下游的所有对象都处于被保护状态。如果对象B在栈上,引用堆上的白色对象C,将其引用关系删除,且新增一个黑色对象到对象C的引用,那么就需要通过shade(ptr)来保护了,在指针插入黑色对象时会触发对对象C的置灰操作。如果栈已经被扫描过了,那么栈上引用的对象都是灰色或受灰色保护的白色对象了,所以就没有必要再进行这步操作。

Golang中的混合写屏障满足的是变形的弱三色不变式,同样允许黑色对象引用白色对象,白色对象处于灰色保护状态,但是只由堆上的灰色对象保护。由于结合了Yuasa的删除写屏障和Dijkstra的插入写屏障的优点,只需要在开始时并发扫描各个goroutine的栈,使其变黑并一直保持,这个过程不需要STW,而标记结束后,因为栈在扫描后始终是黑色的,也无需再进行re-scan操作了,减少了STW的时间。

本文分享自微信公众号 - 养码场(yangmachang0)

原文出处及转载信息见文内详细说明,如有侵权,请联系 yunjia_community@tencent.com 删除。

原始发表时间:2019-09-06

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

我来说两句

0 条评论
登录 后参与评论

相关文章

  • go 学习笔记之值得特别关注的基础语法有哪些

    在上篇文章中,我们动手亲自编写了第一个 Go 语言版本的 Hello World,并且认识了 Go 语言中有意思的变量和不安分的常量.

    雪之梦技术驿站
  • golang条件编译

    golang中没有类似C语言中条件编译的写法,比如在C代码中可以使用如下语法做一些条件编译,结合宏定义来使用可以实现诸如按需编译release和debug版本代...

    左手java右手go
  • Go入门 | 我的Go语言入门总结

    听说Go语言的并发很牛逼,所以边摸鱼边把Go语言的入门语法学习了一遍,之后继续摸鱼学习进阶部分。

    咸鱼学Python
  • 修改golang源代码获取goroutine id实现ThreadLocal

    golang在http.Request中提供了一个Context用于存储kv对,我们可以通过这个来存储请求相关的数据。在请求入口,我们把唯一的requstID存...

    左手java右手go
  • go 学习笔记之数组还是切片都没什么不一样

    上篇文章中详细介绍了 Go 的基础语言,指出了 Go 和其他主流的编程语言的差异性,比较侧重于语法细节,相信只要稍加记忆就能轻松从已有的编程语言切换到 Go 语...

    雪之梦技术驿站
  • go语言数值类型及布尔类型

    常量声明常量是程序在编译时就确定的值,程序在执行时不能修改常量的值。声明常量使用关键字const。在声明常量时,需要对常量赋值。const 名称 类型 = 值 ...

    暮雨
  • 【深度知识】Rust语言入门、关键技术与实战经验

    编者按:高可用架构分享及传播在架构领域具有典型意义的文章,本文由唐刘在高可用架构群分享。转载请注明来自高可用架构公众号「 ArchNotes 」。

    辉哥
  • Gin框架 - 项目目录

    最近这段时间太忙了,每天都在 Coding ,在用 go 写 API 端,写 Service 端 ...

    新亮
  • go语言字符串类型储存解析

    在这篇文章中,将对字符串进行展开。字符串是一串固定长度的字符连接起来的字符集合。go语言中的字符串使用utf-8编码。

    暮雨
  • 修改golang源代码实现无竞争版ThreadLocal

    书接上文 修改golang源代码获取goroutine id实现ThreadLocal。上文实现的版本由于map是多个goroutine共享的,存在竞争,影响了...

    左手java右手go

扫码关注云+社区

领取腾讯云代金券