likes
comments
collection
share

Golang垃圾回收概览

作者站长头像
站长
· 阅读数 5

程序在运行的时候,总是伴随着内存的申请与释放。在C、C++这些语言中,我们需要显示(主动)去释放我们申请的内存。而java、Go这些语言自身都有内存管理机制,也就是说语言本身负责将不再使用的内存释放。

本文主要对go的内存回收机制进行探讨,并分析当前为什么这么实现,是解决了什么问题。

名称解释

解释
Object、对象指代程序内部申请的内促块,称作对象或者object
alive、存活知道目前还在使用的内存,与之对应的是垃圾
垃圾指代不再使用的内存

栈和堆

首先,我们需要理解下堆栈的概念。在go中,每个gorutine都有自己的栈空间,其上会保存当前函数的调用信息、局部变量以及控制信息等。但是在某些条件下(object过大、object在函数外可见等),一个对象会被分配到堆上。

随着程序的运行,堆上的内存可能会不再被引用,这些内存被称为垃圾内存,也就算我们回收的目标。

标记清扫

实现垃圾回收,第一步需要知晓哪些内存是垃圾。最直观的办法是尝试找到这些那些内存是垃圾,利用引用计数就可以实现。另外还有一个办法就是标记出那些内存还在使用,那么剩下的内存就是垃圾了。go的实现就是第二种方法。

标记

标记可以简单理解为,从go的跟对象出发,沿着指针进行bfs/dfs遍历,找到所有可以触达到的对象,然后将这些对象标记为alive(存活)。当标记结束时,剩余的object即可以认为是垃圾。

  • 跟对象。栈内的局部变量以及全局遍历。

  • 为什么从跟对象出发。我们可以这么理解,在程序内,我们使用的任何一个object,都需要有一个路径可以引用到,而这个路径的起始需要再栈内或者是全局变量。否则这个变量我们没有途径去引用。

三色标记

在具体的实现中,go使用一种叫做三色标记的方法来进行标记alive object。

所谓三色,即go将object分成了 黑、灰、白 三类。

  • 在标记初始的时候,go将跟对象标记为灰色,其余的所有对象都是白色。

  • 然后依次从灰色对象集合中取出扫描

  • 最终,所有未被标记的对象仍为白色,被当做垃圾进行清理。

换个角度, 这个过程和我们用bfs 遍历图很相似

  • 首先,我们队列里放进一些节点。(灰色)

  • 然后依次从队列里取值,然后将这些节点的下游节点放进队列。这个节点被处理完成(黑色)

延伸:为什么是三色,双色是否可以

之所以使用三色,是因为生产环境中的GC并不是STW的。gc随时都会被暂停,那么当gc恢复的时候,我们需要知道当前的状态。即我们需要知道还有哪些对象需要处理,哪些是已经处理了的。这就是引入灰色的意义。

下面引用其他博客的详细回答 malcolmyu.github.io/2019/07/07/…

而双色标记实际上仅仅是对扫描结果的描述:非黑即白,但忽略了对扫描进行状态的描述:这个点的子节点扫完了没有?假如我上次停在这样一个图上,重新启动的时候我就不仅要问:到底 A、B 点要不要扫子节点?

Golang垃圾回收概览

为了处理这种情况,Dijkstra 引入了另外一种颜色:灰色,它表示这个节点被 Root 引用到,但子节点我还没处理;而黑色的意思就变为:这个节点被 Root 引用到,而且子节点都已经标记完成。这样在恢复扫描时,只需要处理灰色节点即可。

Golang垃圾回收概览

引入灰色标记还有一个好处,就是当图中没有灰色节点时,便是整个图标记完成之时,就可以进行清理工作了。

写屏障

现实的生产环境下,gc是和其他gorutinue一起共存运行的。即对 object的扫描和修改是同步进行的。因此在gc期间需要引入写屏障来保证gc的正确性。

在go v1.7之前,都是使用的dijkstra写屏障来保证gc的正确性。

dijkstra写屏障(插入写屏障)

考虑下面一个场景

在这种情况下,扫描B时候因为,B->C3的引用已经不存在。所以C3一直会是白色,但是C3却真真实实有被引用,因此,在这种情况下,C3会被错误回收。

那么dijkstra写屏障就是来解决这个问题的,写屏障的伪代码如下

// 在将*slot 赋值为ptr的时候,会同时将ptr标记为灰色
writePointer(slot, ptr):
    shade(ptr) 
    *slot = ptr

简单理解,就是如果有新的指向赋值,那么被指向的object会被染成灰色。基于这个写屏障,再看下上面的例子

目前为止,一切看起来都很正常。但是写屏障是增加了cpu负担的。针对栈上的指针的话,如果也开启写屏障,那么代价(cpu消耗)就会变的非常的大。go在实现的时候并没有采取这种方式,而是在进行三色扫描的时候,栈上的object会一直保持灰色。这样在扫描结束后,进行STW,然后重新对栈上进行扫描,来保证程序的正确性。在go1.7之前都是采用的这个方式。

延伸:gc性能数据

以下数据,摘抄于参考资料,并未实证

  • 在理想情况下,gc的耗时在10ms以内

  • 在goroutine特别多的情况下,stw重新扫描的需要占用10~100ms。(参考自draveness.me/golang/docs…

延伸:为什么栈上不能开写屏障

针对这个问题,参考了很多博客,都只说了栈上开启写屏障性能消耗无法接受,但是没有说具体原因,以下是个人的理解,如有不对,欢迎斧正。

  • 相比较堆,栈才是主程序经常修改的地方,即栈上的写操作比堆上频繁的多。当写屏障在栈上使用时,每次栈上数据的改动(例如函数调用、局部变量的更改)都需要通过写屏障进行同步,这就增加了大量的额外操作。

  • 堆和栈在内存中的管理方式也不同,这也导致了在堆上使用写屏障的性能开销比在栈上小。在堆上,对象的生命周期通常较长,而且分布更为稀疏,因此在堆上发生写操作的次数相对较少,同时也更不容易出现并发冲突。反观栈,函数调用和局部变量的存取频繁,写操作非常集中,这就需要更频繁地使用写屏障,从而带来更大的性能开销。

Yuasa写屏障(删除写屏障)

为了便于理解后面的混合写屏障,这里先介绍下Yuasa写屏障

需要注意的是,Yuasa写屏障在栈上也是不开启的。

和dijkstra写屏障不同,Yuasa要求在删除对某个对象引用的时候,将这个引用染为灰色,伪代码如下。

// befor A->slot=B
// next A->slot=ptr,这里是将B染为灰色
writePointer(slot, ptr):
    shade(*slot)
    *slot = ptr

我们看个具体的例子,和之前插入写屏障不同的时候,新建引用的时候,不会染色,而在删除引用的时候进行染色。

Yuasa要求在开始前,扫描完全部栈,构建三色的初始状态。扫描栈的时候也是需要STW这个在go这种众多gorountine的场景下也是无法接受的。

关于Yuasa讨论的一些摘抄, from github.com/golang/prop…

As originally proposed, the Yuasa barrier takes a complete snapshot of the stack before proceeding with marking. Yuasa argued that this was reasonable on hardware that could perform bulk memory copies very quickly. However, Yuasa's proposal was in the context of a single-threaded system with a comparatively small stack, while Go programs regularly have thousands of stacks that can total to a large amount of memory.

Q: 是否可以像dijsktra写屏障一样,所有栈都是灰色,最后rescan.

A: 没有找到对应的资料,但是根据理解和推理是可以的,不过这件事情同样没有避免STW,所以没有很大的意义。

延伸:为什么Yuasa写屏障又叫做快照写屏障

  • 补充Yuasa写屏障又叫做快照写屏障的资料

混合写屏障(Dijkstra + Yuasa)

在go1.7及以前都是使用的dijkstra写屏障,但是上面提到的需要重新扫描的弊端在一些极端场景下带来了很大的程序劣化。因此go的开发者在github.com/golang/prop…

writePointer(slot, ptr):
    shade(*slot)
    if current stack is grey:
        shade(ptr)
    *slot = ptr

在Dijkstra中我们提到,为了保证程序的正确性,需要在标记结束的时候。进行STW,然后重新扫描栈。而混合写屏障解决了这个问题。

  • 之前因为栈上没有开启写屏障,如果将一个object从heap引用移动到stack引用,那么这个对象不会被标灰,从而可能会被误回收。而在混合写屏障的时候,因为有shade(*slot)的存在,这个object同样会被置灰。

  • 同样Yuasa开始前需要扫全部stack的原因是,如果一个object从stack引用到heap引用,因为栈上没有写屏障,这个object不会置灰,而被错误回收。在混合写屏障中,因为有shade(ptr)的存在,这个object同样会被置灰。

混合写屏障结合了 dijkstra和yuasa写屏障,从而避免了rescan stack。

这个技术在go1.8版本上线,并成功将gc时间控制在了0.5ms内。(数据来自参考资料)

延伸:混合写屏障正确性证明

这部分知识go作者已经有了证明github.com/golang/prop…

三色不变性

强三色不变性:黑色对象不会指向白色对象,只会指向灰色对象或者黑色对象。No black object may contain a pointer to a white object.

弱三色不变性:黑色对象指向的白色对象必须包含一条从灰色对象经由多个白色对象的可达路径。即存在灰色对象进行保护(grey-protected)。Any white object pointed to by a black object is reachable from a grey object via a chain of white pointers (it is grey-protected)。

其中 dijkstra 满足强三色不变性,而后面的Yuasa写屏障以及混合写屏障满足弱三色不变性。

清扫

相比标记,清扫需要关注的问题就比较少,只要保证所有的垃圾都被回收即可。

需要注意的是,清扫和标记是不能重叠的2个状态。

标记清扫的状态流转

  1. 清理终止阶段

    1. 暂停程序,所有处理器进入安全点(safe point)

    2. 处理未被清理的内存单元(即上一轮回收中找到的垃圾内存)

  2. 标记阶段

    1. 开启写屏障

    2. 恢复程序执行,此时所有新创建的对象之间标记为黑色。

    3. 标记进程和协助协程开始扫描跟对象,进行三色标记

      1. 扫描goroutine栈的时候,会暂停当前处理器。

    4. 进行标记结束判定

  3. 标记终止

    1. STW,状态切换,关闭标记辅助
  4. 清理阶段

    1. 初始化清理状态,关闭写屏障

    2. 恢复程序执行,所有新创建的对象标记为白色。

触发时机

Golang垃圾回收概览

  1. 系统触发。

    1. 当所分配的堆大小达到阈值(由控制器计算的触发堆的大小)时,将会触发。

    2. 当距离上一个 GC 周期的时间超过一定时间时,将会触发。-时间周期以 runtime.forcegcperiod 变量为准,默认 2 分钟

  2. 手动触发。用户手动调用。

  3. 内存增长触发。下面GOGC会详细讨论。

    1. 内存耗尽触发。即使没有满足target,如果申请内存失败,也会触发。

安全点 (Safe Point)

简单来说,安全点是指在程序执行过程中的某些特定位置,此时所有的Goroutine都会被暂停,以供垃圾回收器进行工作。在这些点上,垃圾回收器能够获取到所有Goroutine的栈信息,以找出哪些是活跃对象,哪些是可以被回收的垃圾。

这些安全点位于比如函数调用、循环控制、IfElse控制等结构的起点和终点。当所有的goroutine都在这些安全点停下来后,即停止继续执行,那么就称之为进入了一次全局停止(Stop The World)。安全点的机制使得Go能够在运行时实现精确的垃圾回收。

需要注意的是,Go语言的垃圾回收器会尽可能地减少全局的停止时间,以提高程序执行的效率,这也是Go语言垃圾回收器的特色之一。

GOGC - 内存CPU间的平衡

上面我们从设计角度过完了怎么去做标记清扫,那么回到生产环境使用,我们究竟应该在什么时候进行回收操作。

内存回收的本质

这里我们抛掉具体的细节实现,思考下为什么需要内存回收。

如果我们在一个完全没有内存限制的理想空间内,那么内存回收是完全不必要的,我们可以任意的申请内存。但是现实并不是这样,我们的程序实际上有着各种的限制,有限的内存有限的CPU。内存回收就是让我们的内存趋于一个稳态,尽量保证程序不OOM(内存泄漏除外)。

既然这样,那我们可以频繁的进行内存回收吗,这样内存的占用情况可以保持到特别稳定的状态,显然也是不行的。gc操作并非无损,它占用了一定的cpu资源,抢夺了用户程序的时间,如果一直gc,那么用户的程序就无法运行。

GOGC

因此,我们需要设置一个参数,在 控制内存使用和cpu利用率的情况下找到平衡。go定义了一个叫做Target heap memory的概念,即当内存达到这个target,就会触发回收。它定义是

Target heap mrmory = Live heap + (Live heap + GC roots) * GOGC /100

其中 Live Heap为上一轮循环结束时,堆内的内存占用量。

可见 GOGC决定了内存回收的时机,如果GOGC越大,gc次数越少,cpu消耗越少,内存峰值越高。反之亦然。

GOGC影响示例

在go博客tip.golang.org/doc/gc-guid…

假设,程序cpu时间为10s,稳态内存占用为20MB

GOGC=100
Golang垃圾回收概览
GOGC=50
Golang垃圾回收概览
GOGC=150
Golang垃圾回收概览
GOGC=off
Golang垃圾回收概览
GOGC=1
Golang垃圾回收概览

Memory limit

在go1.19的时候,引入了内存限制这个参数。这个参数很好理解,之前是根据当前内存和上轮回收完的存活内存做比较,现在是限制了上限。如果超过这个上限就进行回收。

可能存在的问题

Memory limit是看当前内存占用来决定是否回收的,但是这个值设置的比较低,或者程序中突然申请了一块特别大的内存,那么此时可能会导致频繁的gc,但是无济于事,程序性能极致劣化。

因此具体实践中,我们需要保证memory limit设置的比较宽松。

GC数据

生产环境中,

go版本gc stw 耗时 p99协程数量GC次数内存分配速度
服务A1.180.45ms~2ms. Golang垃圾回收概览1.1k~1.2kGolang垃圾回收概览0.1~0.45 c/sGolang垃圾回收概览40~200MiB/sGolang垃圾回收概览
服务B1.180.13~0.6msGolang垃圾回收概览400~500Golang垃圾回收概览0.008~0.016 c/s , 可以看到内存申请的少,很多时候是时间触发的gcGolang垃圾回收概览<10MB/sGolang垃圾回收概览

参考

  1. malcolmyu.github.io

  2. proposal/design/17503-eliminate-rescan.md at master · golang/proposal

  3. Go 垃圾回收器指南

  4. golang 垃圾回收(五)混合写屏障

转载自:https://juejin.cn/post/7350602010905657344
评论
请登录