首页 > 其他 > 详细

Golang GC详细剖析

时间:2020-12-26 17:59:29      阅读:33      评论:0      收藏:0      [点我收藏+]

Golang GC详细剖析

GC 简介

GC - 垃圾回收(Garbage Collection),是自动管理内存的机制。在 C/C++ 中,开发者需要手动处理自己的内存管理与申请与回收,由于开发者对操作系统和语言的理解问题会导致浪费内存空间悬挂指针的问题。因此许多新兴语言引入了语言层面的自动内存管理,开发者无需对内存进行手动释放,内存释放由 虚拟机(virtual machine)或者运行时(runtime) 来对不再使用的内存资源进行自动回收。其中 golang 选择使用 runtime 处理这件事。

Golang 内存结构简介

golang 的内存分配有 堆 (Heap) 栈 (Stack) 的概念,不同于 C++ 的设计理念让开发者自行处理变量是应该开在栈上还是堆上,golang 处理了自动化策略,写了自己的一套 逃逸分析(Escape analysis)

其中有两点原则可能可以确定自己变量的位置:

  1. 指向栈对象的指针不能存在于堆中
  2. 指向栈对象的指针不能在栈对象回收后存活

利用 RLIMIT_STACK 环境变量可以自行指定线程栈大小

因为开发者无法自行处理内存的分配与释放,golang 的逃逸分析需要自动化处理 gc 问题。

需要注意的是用户 无法 自行将虚拟内存 RSS 归还给 system 而且在 golang 中 gc 和回收到系统是有 gap 的,作为两个操作在执行,可能会导致一些内存占用率不符合预期的问题。

这里不会探讨关于逃逸分析相关问题,如果想了解推荐阅读

内存分配相关的问题推荐阅读,一些小细节比如不同大小的对象内存分配的不同点还是挺有意思的

Golang GC简介

注意:golang 到目前的版本(1.15)仍然在优化自己的 gc 方式及效率,如果需要使用相应的版本希望还是了解对应的历史遗留问题,即使是当前也不建议自行处理内存相关问题,因为 golang 没有提供任何开发者与系统直接交互内存申请的方式

GC 调优历史

  • go1.1,提高效率和垃圾回收精确度。
  • go1.3,提高了垃圾回收的精确度。
  • go1.4,之前版本的runtime大部分是使用C写的,这个版本大量使用Go进行了重写,让GC有了扫描stack的能力,进一步提高了垃圾回收的精确度。
  • go1.5,目标是降低GC延迟,采用了并发标记并发清除三色标记write barrier,以及实现了更好的回收器调度,设计文档1文档2,以及2015 版的Go talk
  • go1.6,小优化,当程序使用大量内存时,GC暂停时间有所降低。
  • go1.7,小优化,当程序有大量空闲goroutine,stack大小波动比较大时,GC暂停时间有显著降低。
  • go1.8write barrier切换到hybrid write barrier,以消除STW中的re-scan,把STW的最差情况降低到50us设计文档
  • go1.9,提升指标比较多,(1)过去 runtime.GC, debug.SetGCPercent, 和 debug.FreeOSMemory都不能触发并发GC,他们触发的GC都是阻塞的,go1.9可以了,变成了在垃圾回收之前只阻塞调用GC的goroutine。(2)debug.SetGCPercent只在有必要的情况下才会触发GC。
  • go.1.10,小优化,加速了GC,程序应当运行更快一点点。
  • go1.12,显著提高了堆内存存在大碎片情况下的sweeping性能,能够降低GC后立即分配内存的延迟。
  • go1.13,着手解决向操作系统归还内存的,提出了新的 Scavenger
  • go1.14,替代了仅存活了一个版本的 Scavenger,全新的页分配器,优化分配内存过程的速率与现有的扩展性问题,并引入了异步抢占,解决了由于密集循环导致的 STW 时间过长的问题

简单 gc 原理

如果不需要知道具体实现已经针对内存占用量问题进行调优,只是简单使用 golang 进行开发可以简单的对其了解。

C++ 中智能指针的设计采用 引用计数 的方式进行:

  • 优点是易于理解,结果会非常符合预期,当一个 shared_ptr 引用计数清空的时候它就会递归调用析构函数并将内存交还给系统 RSS
  • 缺点是效率下降(只有系统态允许进行内存的申请与归还),引入了循环引用等使用问题,开发者仍然有一定的学习成本和 coding 成本
  • 缺点在开发者有经验的情况下也是可以缓解的,可以用内存池自行管理内存的申请与释放,在用户态进行操作

golang 中的 gc 则做到了开发者无感知:

func sz(x string) *int {
  return &len(x)
}

其中的“临时变量” len(x) 在正确的使用后仍然会存在与内存中,且在不用到它之后的某个时间点会自动释放掉交还给系统

如果使用 pprof 或者 GODEBUG=gctrace=1 go run main.go 可以看到它的释放周期和与系统交互的相关内容

简单来说分为几个步骤:

  • 启动程序的时候会自动产生一个协程处理 gc 问题
  • 某些时刻这个协程会启动,使用 三色标级算法 对某些协程的变量按照 拓扑结构 进行变量的清理标记
    • 标记启动时,准备根对象的扫描,会打开 写屏障(Write Barrier)辅助GC(mutator assist),而回收器和应用程序是并发运行的,因此会暂停当前正在运行的所有 Goroutine
    • 标记运行中,主要目的是标记堆内存中仍在使用的值,对它的拓扑结构进行分析,并不会 STW
    • 标记结束时,将重新扫描部分根对象,这时候会禁用 写屏障(Write Barrier)辅助GC(mutator assist),而标记阶段和应用程序是并发运行的,所以在标记阶段可能会有新的对象产生,因此在重新扫描时需要进行 STW
  • 内存清扫阶段,将需要回收的内存归还到堆中(仍然是 golang 在托管),写屏障关闭
  • 内存归还阶段,将过多的内存归还给系统,写屏障关闭
阶段 说明 赋值器状态
SweepTermination 清扫终止(标记开始)阶段 STW
Mark 三色标记阶段 并发
MarkTermination 标记终止阶段 STW
GCoff 内存清扫阶段 并发
GCoff 内存归还阶段 并发

所谓STW

STW 可以是Stop The World的缩写,也可以是Start The World的缩写。通常意义上指的是从Stop The World到Start The World这一段时间间隔。垃圾回收过程中为了保证准确性、防止无止境的内存增长等问题而不可避免的需要停止赋值器进一步操作对象图以完成垃圾回收。STW时间越长,对用户代码造成的影响越大。

下面具体介绍每个部分的过程与细节

在 golang 源码中可以找到 src/runtime/mem_linux.gosrc/runtime/mgc.gosrc/runtime/mgcsavenge.go 可以方便理解和阅读。

三色标级算法

所谓三色是指:

  • 白色对象(可能死亡):未被回收器访问到的对象。在回收开始阶段,所有对象均为白色,当回收结束后,白色对象均不可达。
  • 灰色对象(波面):已被回收器访问到的对象,但回收器需要对其中的一个或多个指针进行扫描,因为他们可能还指向白色对象。
  • 黑色对象(确定存活):已被回收器访问到的对象,其中所有字段都已被扫描,黑色对象中任何一个指针都不可能直接指向白色对象。

标记过程如下:

  1. 起初所有的对象都是白色的;

  2. 从根对象出发扫描所有可达对象,标记为灰色,放入待处理队列;

  3. 从待处理队列中取出灰色对象,将其引用的对象标记为灰色并放入待处理队列中,自身标记为黑色;

  4. 重复步骤 3,直到待处理队列为空,此时白色对象即为不可达的“垃圾”,回收白色对象;

技术分享图片

根对象

根对象在垃圾回收的术语中又叫做根集合,它是垃圾回收器在标记过程时最先检查的对象,包括以下内容:

  1. 全局变量:程序在编译期就能确定的那些存在于程序整个生命周期的变量
  2. 执行栈:每个 goroutine 都包含自己的执行栈,这些执行栈上包含栈上的变量及指向分配的堆内存区块的指针
  3. 寄存器:寄存器的值可能表示一个指针,参与计算的这些指针可能指向某些赋值器分配的堆内存区块

屏障机制

如果 No STW 会发生什么

假设下面的场景,已经被标记为灰色的对象2,未被标记的对象3对象2用指针p引用;此时已经被标记为黑色的对象4创建指针q 指向未被标记的对象3,同时对象2将指针p移除;对象4已经被标记为黑色,对象3未被引用,对象2删除与对象3的引用,导致最后对象3被误清除

技术分享图片

技术分享图片

技术分享图片

技术分享图片

技术分享图片

  • 垃圾回收的原则是不应出现对象的丢失,也不应错误的回收还不需要回收的对象。如果同时满足下面两个条件会破坏回收器的正确性:
    • 条件 1: 赋值器修改对象图,导致某一黑色对象引用白色对象;(通俗的说就是A突然持有了B的指针,而B在并发标记的过程中已经被判定为白色对象要被清理掉的)
    • 条件 2: 从灰色对象出发,到达白色对象且未经访问过的路径被赋值器破坏;(通俗的说就是A持有B的指针,这个持有关系被释放)
  • 只要能够避免其中任何一个条件,则不会出现对象丢失的情况,因为:
    • 如果**条件 1 **被避免,则所有白色对象均被灰色对象引用,没有白色对象会被遗漏;
    • 如果条件 2 被避免,即便白色对象的指针被写入到黑色对象中,但从灰色对象出发,总存在一条没有访问过的路径,从而找到到达白色对象的路径,白色对象最终不会被遗漏。

可能的解决方法: 整个过程STW,浪费资源,且对用户程序影响较大,由此引入了屏障机制

屏障机制

把回收器视为对象,把赋值器视为影响回收器这一对象的实际行为(即影响 GC 周期的长短),从而引入赋值器的颜色:

  • 黑色赋值器:已经由回收器扫描过,不会再次对其进行扫描。
  • 灰色赋值器:尚未被回收器扫描过或尽管已经扫描过,但仍需要重新扫描。

插入屏障(Dijkstra)- 灰色赋值器

写入前,对指针所要指向的对象进行着色

// 灰色赋值器 Dijkstra 插入屏障
func DijkstraWritePointer(slot *unsafe.Pointer, ptr unsafe.Pointer) {
    shade(ptr) //先将新下游对象 ptr 标记为灰色
    *slot = ptr
}

//说明:
添加下游对象(当前下游对象slot, 新下游对象ptr) {   
  //step 1
  标记灰色(新下游对象ptr)   
  
  //step 2
  当前下游对象slot = 新下游对象ptr                    
}

//场景:
A.添加下游对象(nil, B)   //A 之前没有下游, 新添加一个下游对象B, B被标记为灰色
A.添加下游对象(C, B)     //A 将下游对象C 更换为B,  B被标记为灰色

Dijkstra 插入屏障的好处在于可以立刻开始并发标记。但存在两个缺点:

  • 由于 Dijkstra 插入屏障的“保守”,在一次回收过程中可能会残留一部分对象没有回收成功,只有在下一个回收过程中才会被回收;
  • 在标记阶段中,每次进行指针赋值操作时,都需要引入写屏障,这无疑会增加大量性能开销;为了避免造成性能问题,Go 团队在最终实现时,没有为所有栈上的指针写操作,启用写屏障,而是当发生栈上的写操作时,将栈标记为灰色,但此举产生了灰色赋值器,将会需要标记终止阶段 STW 时对这些栈进行重新扫描

技术分享图片

技术分享图片

技术分享图片

技术分享图片

技术分享图片

技术分享图片

技术分享图片

技术分享图片

技术分享图片

技术分享图片

总结特点:在标记开始时无需STW,可直接开始,并发进行,但结束时需要STW来重新扫描栈

删除屏障 (Yuasa)- 黑色赋值器

写入前,对指针所在对象进行着色

// 黑色赋值器 Yuasa 屏障
func YuasaWritePointer(slot *unsafe.Pointer, ptr unsafe.Pointer) {
    shade(*slot) 先将*slot标记为灰色
    *slot = ptr
}

//说明:
添加下游对象(当前下游对象slot, 新下游对象ptr) {
  //step 1
  if (当前下游对象slot是灰色 || 当前下游对象slot是白色) {
          标记灰色(当前下游对象slot)     //slot为被删除对象, 标记为灰色
  }  
  //step 2
  当前下游对象slot = 新下游对象ptr
}

//场景
A.添加下游对象(B, nil)   //A对象,删除B对象的引用。B被A删除,被标记为灰(如果B之前为白)
A.添加下游对象(B, C)     //A对象,更换下游B变成C。B被A删除,被标记为灰(如果B之前为白)

技术分享图片

技术分享图片

技术分享图片

技术分享图片

技术分享图片

技术分享图片

技术分享图片

总结特点:标记结束不需要STW,但是回收精度低,GC 开始时STW 扫描堆栈记录初始快照,保护开始时刻的所有存活对象;且容易产生“冗余”扫描

混合屏障

大大缩短了 STW 时间

  • GC 开始将栈上的对象全部扫描并标记为黑色
  • GC 期间,任何在栈上创建的新对象,均为黑色
  • 被删除的堆对象标记为灰色;
  • 被添加的堆对象标记为灰色;
// 混合写屏障
func HybridWritePointerSimple(slot *unsafe.Pointer, ptr unsafe.Pointer) {
    shade(*slot)
    shade(ptr)
    *slot = ptr
}

按照堆栈分为四种场景:

  1. 对象被一个堆对象删除引用,成为栈对象的下游
  2. 对象被一个栈对象删除引用,成为栈对象的下游
  3. 对象被一个堆对象删除引用,成为堆对象的下游
  4. 对象被一个栈对象删除引用,成为另一个堆对象的下游

Golang 中的混合屏障结合了删除写屏障和插入写屏障的优点,只需要在开始时并发扫描各goroutine的栈,使其变黑并一直保持,标记结束后,因为栈空间在扫描后始终是黑色的,无需进行re-scan,减少了STW 的时间

Marking setup

为了打开写屏障,必须停止每个goroutine,让垃圾收集器观察并等待每个goroutine进行函数调用, 等待函数调用是为了保证goroutine停止时处于安全点。

技术分享图片

// 如果goroutine4 处于如下循环中,运行时间取决于slice numbers的大小
func add(numbers []int) int {
    var v int
    for _, n := range numbers {
             v += n
     }
     return v
}

下面的代码中,由于for{} 循环所在的goroutine 永远不会中断,导致始终无法进入STW阶段,资源浪费;Go 1.14 之后,此类goroutine 能被异步抢占,使得进入STW的时间不会超过抢占信号触发的周期,程序也不会因为仅仅等待一个goroutine的停止而停顿在进入STW之前的操作上。

func main() {
    go func() {
        for {
        }
    }()
    time.Sleep(time.Milliecond)
    runtime.GC()
    println("done")
}

Marking

一旦写屏障打开,垃圾收集器就开始标记阶段,垃圾收集器所做的第一件事是占用25%CPU。

标记阶段需要标记在堆内存中仍然在使用中的值。首先检查所有现goroutine的堆栈,以找到堆内存的根指针。然后收集器必须从那些根指针遍历堆内存图,标记可以回收的内存。

当存在新的内存分配时,会暂停分配内存过快的那些 goroutine,并将其转去执行一些辅助标记(Mark Assist)的工作,从而达到放缓继续分配、辅助 GC 的标记工作的目的。

Mark 终止

关闭写屏障,执行各种清理任务(STW)

Sweep (清理)

清理阶段用于回收标记阶段中标记出来的可回收内存。当应用程序goroutine尝试在堆内存中分配新内存时,会触发该操作,清理导致的延迟和吞吐量降低被分散到每次内存分配时。

清除阶段出现新对象:

清除阶段是扫描整个堆内存,可以知道当前清除到什么位置,创建的新对象判定下,如果新对象的指针位置已经被扫描过了,那么就不用作任何操作,不会被误清除,如果在当前扫描的位置的后面,把该对象的颜色标记为黑色,这样就不会被误清除了

什么时候进行清理?

主动触发(runtime.GC()) 被动触发 (GC百分比、定时)

GC 百分比

运行时中有GC 百分比的配置选项,默认情况下为100。此值表示在下一次垃圾收集必须启动之前可以分配多少新内存的比率。将GC百分比设置为100意味着:基于在垃圾收集完成后标记为活动的堆内存量,下次垃圾收集前,堆内存使用可以增加100%。

GC过程演示

演示一个GC过程,并输出相关信息,使用GODEBUG变量生成GC trace

func gcfinished() *int {
   p := 1
   runtime.SetFinalizer(&p, func(_ *int) {
      println("gc finished")
      atomic.StoreUint64(&stop, 1)
   })
   return &p
}
func allocate() {
   _ = make([]byte, int((1<<18)))
}
func main() {
   f, _ := os.Create("trace.out")
   defer f.Close()
   trace.Start(f)
   defer trace.Stop()
   gcfinished()
   // 当完成 GC 时停止分配
  for n := 1; n < 50; n++ {
      println("#allocate: ", n)
      allocate()
   }
   println("terminate")
}

技术分享图片

GC 调优

GC 性能指标

Go 的 GC 被设计为成比例触发、大部分工作与赋值器并发、不分代、无内存移动且会主动向操作系统归还申请的内存。因此最主要关注的、能够影响赋值器的性能指标有:

  • CPU 利用率:回收算法会在多大程度上拖慢程序?有时候,这个是通过回收占用的 CPU 时间与其它 CPU 时间的百分比来描述的。
  • GC 停顿时间:回收器会造成多长时间的停顿?目前的 GC 中需要考虑 STW 和 Mark Assist 两个部分可能造成的停顿。
  • GC 停顿频率:回收器造成的停顿频率是怎样的?目前的 GC 中需要考虑 STW 和 Mark Assist 两个部分可能造成的停顿。
  • GC 可扩展性:当堆内存变大时,垃圾回收器的性能如何?但大部分的程序可能并不一定关心这个问题。

合理化内存分配的速度、提高赋值器的 CPU 利用率

goroutine 的执行时间占其生命周期总时间非常短的一部分,但大部分时间都花费在调度器的等待上了,说明同时创建大量 goroutine 对调度器产生的压力确实不小,我们不妨将这一产生速率减慢,一批一批地创建 goroutine。

func concat() {
   for n := 0; n < 100; n++ {
      for i := 0; i < 8; i++ {
         go func() {
            s := "Go GC"
            s += " " + "Hello"
            s += " " + "World"
            _ = s
         }()
      }
   }
}

//改进
func concat() {
   wg := sync.WaitGroup{}
   for n := 0; n < 100; n++ {
      wg.Add(8)
      for i := 0; i < 8; i++ {
         go func() {
            s := "Go GC"
            s += " " + "Hello"
            s += " " + "World"
            _ = s
            wg.Done()
         }()
      }
      wg.Wait()
   }
}

降低并复用已经申请的内存

newBuf()产生的申请的内存过多, sync.Pool 是内存复用的一个最为显著的例子

func newBuf() []byte {
   return make([]byte, 10<<20)
}
b := newBuf()

//改进
var bufPool = sync.Pool{
    New: func() interface{} {
        return make([]byte, 10<<20)
    },
}
b := bufPool.Get().([]byte)

调整 GOGC

降低收集器的启动频率(提高GC百分比)无法帮助垃圾收集器更快完成收集工作。降低频率会导致垃圾收集器在收集期间完成更多的工作。 可以通过减少新分配对象数量来帮助垃圾收集器更快完成收集工作

GOGC 与 cgroup

linux 中容器化重要的组件 cgroup 中对 memory 和 cpu 的限制不是通过直接的虚拟化限制,而是超过分配限额时触发 OOM,而 GOGC 的调优实际上是对获取系统内存最大量进行操作的,所以 golang 目前对其的支持并不好,在容器化应用中需要对内存更小心的使用。

Enforcing memory limits in Go

参考文献

用 GODEBUG 看 GC

Golang三色标记、混合写屏障GC模式图文全分析

Go 如何做性能分析

Go 语言设计与实现

Golang GC详细剖析

原文:https://www.cnblogs.com/badcw/p/14192895.html

(0)
(0)
   
举报
评论 一句话评论(0
关于我们 - 联系我们 - 留言反馈 - 联系我们:wmxa8@hotmail.com
© 2014 bubuko.com 版权所有
打开技术之扣,分享程序人生!