Junedayday Blog

六月天天的个人博客

0%

2020-03

2022-03-28 Go1.5的GC概览3 - Tri-color

在标记阶段,Go语言使用了 tri-color,也就是著名的三色标记法。在这篇文章里,详细地描述了这部分的实现。

原文链接 - https://go.dev/blog/go15gc

三色标记法是一种堆上对象的图算法。这里图的边Edge即指针,所以这里的关系是单向的。

In a tri-color collector, every object is either white, grey, or black and we view the heap as a graph of connected objects.

接下来,就是具体的三色标记法的工作了:

At the start of a GC cycle all objects are white. The GC visits all roots, which are objects directly accessible by the application such as globals and things on the stack, and colors these grey. The GC then chooses a grey object, blackens it, and then scans it for pointers to other objects. When this scan finds a pointer to a white object, it turns that object grey. This process repeats until there are no more grey objects. At this point, white objects are known to be unreachable and can be reused.

这一段内容很长,但描述得很直白,我简单概括下:

  • GC初始化
    • 将所有的对象设置为 白色
  • Mark的初始化
    • 将全局变量和栈上的对象,标记为 灰色
    • 这些灰色对象会被放入队列中
  • Mark的核心流程
    • 从队列中弹出一个灰色对象
    • 访问这个灰色对象的指针,将白色对象的转变为灰色对象,并加入到队列中
    • 将这个灰色对象标记为黑色,表示访问完毕
    • 重复上述过程,直到队列为空
  • 清理阶段
    • 将所有剩余的 白色对象 进行垃圾回收

我们重点看这里的 Mark的核心流程,里面有个关键问题:mutator(也就是运行中的程序)在不停地修改对象的指针,所以会出现各种异常情况,比如说让一个黑色对象指向白色对象(正常情况下,黑色对象指向的是黑色或者灰色)。

网上有很多关于三色标记的资料,不太清楚的朋友需要自行搜索,比如 https://segmentfault.com/a/1190000022030353

重点可以结合写屏障要解决的问题,进行理解。

这个时候,就引入了我们前面说的内容 - 写屏障write barrier

Go’s write barrier colors the now-reachable object grey if it is currently white, ensuring that the garbage collector will eventually scan it for pointers.

写屏障即会在每次发生指针变更时,加入一小段代码:比如检测到新的被指向的对象是白色,就将它修改为灰色,需要扫描。这只是一个简单例子,后续Go语言对写屏障进行了迭代,采用的写屏障技术是 混合写屏障,也就是 插入写屏障+删除写屏障

2022-04-02 Go1.5的GC概览4 - 深入细节

今天我们会开始抠细节,来加深大家对这块的理解。

原文链接 - https://go.dev/blog/go15gc

Of course the devil is in the details. 细节才是恶魔,但只有去抠这些细节,我们才能掌握GC的实现。作者在文中抛出了很多问题,我们挑出3个关键性的问题进行回答。

When do we start a GC cycle?

一个主动调用和两个被动调用

主动调用指的是代码调用runtime.GC()函数,被动调用包括 周期性强制执行(如2min)和GC Pacing算法。

其中GC Pacing主要和堆上空间的增长速度相关,增长越快,GC频率越快。

How do we know where the roots are?

标记阶段的根节点来自于哪里呢?从程序的维度来说,包括 全局变量Goroutine的栈上变量

全局变量,对应到进程结构中的bss段(未初始化的全局变量)和data段(已初始化的全局变量)。bss和data段的概念是通用的,也就是Go、C++等任意进程都是这样的数据结构。

而Goroutine栈上变量是Go的runtime自己维护的。

How do we know where in an object pointers are located?

在一个对象中,我们如何识别出对象内部的指针呢?

首先,我们要了解到,一个对象在内存中是一段连续0和1。由于Go语言的强类型特点,我们可以清楚地计算出这个结构体的总大小、以及内部各个成员变量的大小。

对象内部的变量分为两类:具体的数值和指针。具体的数值,如int64 a=1,那就在开辟一个可以存储int64大小空间段,存下1这个数值;而指针呢,如*int64这个指针,它在堆上先存储int64具体的值,记录它的起始地址如0x1111,结构体内部开辟一个指针大小的空间,记录地址的值0x1111。

如果抛开强类型,我们看到程序中的数值是无法区分的,比如上述例子中的1可以被理解地址0x0001,而0x1111也可以被理解为是具体的数字。强类型的语言是一种解决方案,它能在编译期就识别出具体的类型。

为了延伸思考,这边也提一下另一种方案:

扩展数据,将它拆分为 对象类型(如前x位)+数据 。比如,

  • 数值1 = int64数据 + 1
  • 指针1 = int64的指针 + 指针起始地址

这就有JAVA里“一切皆对象”的影子了。

小结

细节既是恶魔,也提供了我们梳理知识体系的过程。虽然大多数的时间我们没法掌握细节,但只要怀着一颗保持探索的心,总是能不断往前进的。

Github: https://github.com/Junedayday/code_reading

Blog: http://junes.tech/

Bilibili: https://space.bilibili.com/293775192

公众号: golangcoding

二维码

2020-03

2022-03-21 Go垃圾回收之旅6 - ROC与Write Barrier

今天,我们来看GC的一种设计 - ROC(Request Oriented Collector)。虽然ROC并没有被实际工程采用,但很值得我们学习,加深理解。

《Go垃圾回收之旅》原文链接 - https://go.dev/blog/ismmkeynote

ROC-面向请求的回收器

ROC提出了一种假设:

Objects associated with a completed request or a dormant goroutine die at a higher rate than other object.

与一个完整请求 或 休眠 goroutine 所关联的对象们`,比其它对象更容易死亡。

我们假设存在两个Goroutine - G1和G2,它们的对象分为如下三类:

  • G1私有
  • G2私有
  • G1和G2共有

当G1的生命周期结束时,即Goroutine退出,G1私有的的对象就应该被回收,这一点很容易理解。

但是,程序实际运行的过程中,对象一直在变化,也就是G1私有的对象变成了G1和G2共有的。这个时候,我们就必须引入一个新的概念 - write barrier。

Write Barrier-写屏障

我们通过一句话来了解的写屏障功能:

Whenever there was a write, we would have to see if it was writing a pointer to a private object into a public object.

也就是说,当有个写请求时,我们就必须检查它是否将一个指针从私有对象变成了公共对象。这里注意两个点:

  1. 对象的复杂性 - 如果一个对象从私有变成共有,那么它内部的子对象也需要变化
  2. 针对指针 - 不用考虑一些值拷贝的对象

由于第一点的存在,ROC需要始终开启写屏障,给整个程序带来了大量的成本,所以ROC最终没有被采用。

我们不妨延伸地思考一下,当一个共有对象变成私有时,该怎么操作?我这边提供2个思路:

  1. 每次删除指针引用时,看一下这个对象、是否只有一个Goroutine的引用,是的话转为私有
  2. 不处理。等这个对象没有任何引用时,用GC清理

小结

ROC的思想很朴素,非常符合我们的直觉,具有一定的参考价值。

而写屏障目前被广泛地应用在各类GC中,今天我们也借ROC对它有了初步印象。

2022-03-24 Go1.5的GC概览1 - 官方Talk

在上一个系列,我们通过阅读 Go垃圾回收之旅 的相关资料,对Go中GC的很多概念有了基本的认识,这就给我们接下来的学习铺好了路。

今天开始,我们将一起阅读下一篇内容,也就是官方博客对Go1.5版本GC的讲解。

原文链接 - https://go.dev/blog/go15gc

为什么我不选择最新版本进行讲解呢?

Go1.5的GC实现是具有一定里程碑意义的,实现了 并发标记清扫,与最新的GC实现差异并不大,作为入门学习资料更容易理解。

在这篇博客中,作者先引入了一个Talk,里面重点讲述了GC的实现与性能,而实现部分是我们今天的重点。

请跳转阅读 - https://go.dev/talks/2015/go-gc.pdf

GC相关的差异(Go与Java)

维度 GO java
运行线程 1000+Goroutine 10+线程
同步机制 channel
运行时实现 Go语言实现 C语言实现
内存分布 具备局部性 通过指针跳转

GC概览

  1. Scan Phase 扫描
  2. Mark Phase 标记
  3. Sweep Phase 清理

关于这三个阶段是怎么实现的,可以对照着ppt看,或者观看视频 - https://www.bilibili.com/video/BV18r4y1q7p3

关于更细节的 GC Algorithm Phases 实现,我们会在下一讲描述。

小结

本篇内容主要结合这个Talk,讲述了Go1.5版本的GC基本实现,希望大家能对GC背景和三阶段操作有基本了解。

2022-03-26 Go1.5的GC概览2 - GC Algorithm Phases

在上一篇,我们从这篇Talk - https://go.dev/talks/2015/go-gc.pdf 里了解标记清理算法。

今天,我们将对着下面这张Go1.5 GC算法的各个阶段,串讲一下GC这个过程。

Go 1.5 GC

Stack scan 栈扫描

栈扫描的启动阶段有一小段STW,这是因为GC要启动写屏障,所以必须先暂停所有Goroutine的运行。这个时间很短,大概耗时在几十微秒。

runtime中的写屏障的数据结构如下:

1
2
3
4
5
6
7
var writeBarrier struct {
enabled bool // compiler emits a check of this before calling write barrier
pad [3]byte // compiler uses 32-bit load for "enabled" field
needed bool // whether we need a write barrier for current GC phase
cgo bool // whether we need a write barrier for a cgo check
alignme uint64 // guarantee alignment so that compiler can use a 32 or 64-bit load
}

完成启动后,就进入这一步的工作:从全局变量和各个Goroutine的栈上收集指针信息。这一步,也就是初始化所有标记对象的集合。

Mark 标记

标记阶段即根据扫描出的初始指针对象,做BFS遍历,也就将所有可触达的对象加上标记。这里有一句话:

Write barrier tracks pointer changes by mutator. 也就是在标记阶段中,如果有程序变更了指针,就需要添加写屏障。

关于写屏障的实现细节我们先不细聊,先一起来看看GC中的三个概念:

  1. mutator:一般指应用程序,在运行过程中,会不停地修改堆对象里的指向关系
  2. collector:垃圾回收期,更多地是指GC线程
  3. allocator:内存分配器,也就是程序向操作系统申请内存、释放内存,这一点在GC里很重要,往往被我们忽视

Mark Termination 标记结束

完成标记后,主要分为三个工作:

  1. Rescan - 重新扫描其中变化的内容
  2. Clean Up Tasks - 这里的清理并不是清理对象,而是对整个Mark标记的收尾工作,比如收缩栈
  3. 关闭写屏障

注意,这一整个阶段都是STW的。

Sweep 清扫

Sweep就是将未标记的堆上对象进行清理,回收资源。这一阶段是并发的。

值得一提的是,我们之前谈论过的GC Paging算法就是在这一步启动的,用在估算下一次启动GC的最佳时间。

Github: https://github.com/Junedayday/code_reading

Blog: http://junes.tech/

Bilibili: https://space.bilibili.com/293775192

公众号: golangcoding

二维码

2020-03

2022-03-14 Go垃圾回收之旅3 - 静态编译

Go的源码会被编译成二进制文件,然后直接在对应的操作系统上运行。那么,这对学习GC有什么意义呢?让我们一起看看今天的内容。

《Go垃圾回收之旅》原文链接 - https://go.dev/blog/ismmkeynote

我们先和JAVA程序做个对比:

  • Go
    • Go编译的二进制文件
    • Linux
  • JAVA
    • Java打包的JAR文件
    • JVM
    • Linux

从这个架构不难猜到,上文谈到的 运行时,Go语言是直接编译到二进制文件里的;而JAVA是在JVM里实现的。

Go的这种实现方式,主要优劣点如下:

  • 优点: 程序的运行更具备 确定性,即开发人员可以根据代码,预测到程序的运行逻辑,更容易针对性地优化
  • 缺点:运行时没有JIT机制,无法针对具体的运行结果进行反馈优化

JIT的优化方向很多,我这里举一个热点函数优化的例子:

  1. 在代码中,函数f需要输入参数a和b
  2. 运行了一段时间后,JIT发现b的输入参数一直都是某个固定值b1
  3. 这时,JIT进行编译优化,将函数f编译成一个新函数f1
    1. f1只需要入参a
    2. b参数被替换为固定值b1
    3. 减少参数复杂度,能提升程序效率,尤其是热点函数
  4. 如果参数b突然变成了b2,那JIT就会从f1回退到f

简单来说:Go程序会怎么运行,往往在编码阶段就可以预期到了;而JAVA引入的JIT能力,可以在程序运行后,根据具体的运行情况,做针对性地优化,提升效率的同时也带了很多的不确定性。

两种实现方式各有利弊,团队可以根据实际情况自行选择。单从Go语言开发者来说,排查线上问题相对有JIT机制的JAVA程序简单很多。

这种确定性也让Go的GC相对简单不少,方便我们的学习。

2022-03-15 Go垃圾回收之旅4 - 性能压力下的Go程序

这篇演讲中,有这么一段很有意思的描述:

Out of memory, OOMs, are tough on Go;

temporary spikes in memory usage should be handled by increasing CPU costs, not by aborting.

Basically if the GC sees memory pressure it informs the application that it should shed load. Once things are back to normal the GC informs the application that it can go back to its regular load.

这段话包含了Go语言的GC,在面对CPU和内存压力下的决策:

  1. Go程序很少会OOM
    1. 这句话有一定前提,即内存设置是合理的,代码也没有明显的内存泄露问题
    2. 至于具体原因,我们看下文
  2. 业务高峰时内存使用率过高,应该通过提升CPU能力来解决,而不是中止程序
    1. 自动GC是需要CPU的计算资源做支持,来清理无用内存
    2. 要保证内存资源能支持程序的正常运行,有两个思路:
      1. 减少已有内存 - 通过GC来回收无用的内存
      2. 限制新增内存 - 即运行时尽可能地避免新内存的分配,最简单的方法就是不运行代码
    3. 显然,中止程序对业务的影响很大,我们更倾向于通过GC去回收内存,腾出新的空间
  3. GC压力高时,通知应用减少负载;而当恢复正常后,GC再通知应用可以恢复到正常模式了
    1. 我们可以将上述分为两类工作
      1. 业务逻辑的Goroutine
      2. GC的Goroutine
    2. 这两类Goroutine都会消耗CPU资源,区别在于:
      1. 运行业务逻辑往往会增加内存
      2. GC是回收内存
    3. 这里就能体现出Go运行时的策略
      1. 内存压力高时,GC线程更容易抢占到CPU资源,进行内存回收
      2. 代价是业务处理逻辑会有一定性能损耗,被分配的计算资源减少

GC最直观的影响就体现在延迟上。尤其是在STW - Stop The World情况下,程序会暂停所有非GC的工作,进行全量的垃圾回收。即便整个GC只花费了1s,所有涉及到这个程序的业务调用,都会增加1s延迟;在微服务场景下,这个问题会变得尤为复杂。

而GC的方案迭代,最直观的效果就体现在这个延迟优化上。

2022-03-17 Go垃圾回收之旅5 - GC Pacer

今天我们会重点讨论Go语言GC Pacer这个概念。

《Go垃圾回收之旅》原文链接 - https://go.dev/blog/ismmkeynote

要理解透彻GC Pacer的非常困难,底层实现细节必须深入到源码。这里,我们会通过分享中的关键性描述,来思考GC Pacer的设计理念。

It is basically based on a feedback loop that determines when to best start a GC cycle.

我们聚焦到两个词:

  • feedback loop 反馈循环,GC Pacer是会根据实际GC情况会不断迭代、反馈的
  • when to best start a GC cycle 强调了GC Pacer的目标 - 为了决定一个最佳启动GC的时机

GC Pacer的内部原理也和它的定义非常贴切,它是根据步长来决定GC的:

  • 对象:堆上的内存分配
  • 步长:设定值,如100%
  • 触发时机:当前堆上内存大小 >= 上次堆上内存大小 * (1 + 100%)

简单来说,就是一种 按比例增长 的触发机制。但这个机制没有那么简单,我们看下面这段:

If need be, the Pacer slows down allocation while speeding up marking.

At a high level the Pacer stops the Goroutine, which is doing a lot of the allocation, and puts it to work doing marking.

这两句描述和我们上一讲的内容对应上了 - 在一定的性能压力下,Pacer会减少内存的分配,而花更多的时间在对象的标记(marking)上,它是GC里的最耗性能的步骤。

对应到上面提到的反馈呢,也就是GC Pacer并不是单纯的一种 按比例增长 的触发机制,还有一些其余因素的影响:比如,当前这次的GC花费的CPU计算资源与标记的耗时超过了预期,表示当前整个GC存在一定压力,下次的GC的开始时间需要适当提前。

GC Pacer最近也重新做了一次大的改动,有兴趣的可以参考这篇文章:

https://go.googlesource.com/proposal/+/a216b56e743c5b6b300b3ef1673ee62684b5b63b/design/44167-gc-pacer-redesign.md

深入研究GC Pacer需要很多数学知识储备,留给有兴趣的同学自行探索了。

Github: https://github.com/Junedayday/code_reading

Blog: http://junes.tech/

Bilibili: https://space.bilibili.com/293775192

公众号: golangcoding

二维码