golang硬核技术(四)垃圾回收 原理+源码 一文搞懂

前言

早期的程序需要手动管理内存,比如c,c++这种,十分费劲。非常容易泄露。

后面出现的编程语言,基本都是自动回收内存,有go java这种追踪式的,还是有python这种引用计数方式的。

现在,甚至出现了rust这种,用所有权+栈式的内存管理。

不过今天来看一下golang的垃圾回收机制,有没有它吹得那么牛。

原理

垃圾回收原理已经有很多文章表述了,且写的都非常好,这里只做基本解释。

详细过程推荐这篇博文:community.apinto.com/d/34057-gol...

标记清除 < 1.5

go的早期版本(<1.5)采用的是标记清除方式。

gc开始时先stw,然后从根节点开始扫描资源,找到所有可达资源节点 并标记。

停止stw,开始清理未被标记的资源。

  • stw: Stop The World 暂停业务程序。

这种方式清理内存,问题显而易见,标记时stw,这会导致业务程序长时间不能运行,有极大的性能损耗。

三色标记算法

为了解决上面的问题,go从1.5改用了三色标记算法回收内存。

三色标记法:

  • 开始扫描前,将所有节点标记为白色
  • gc开始,将根节点置为灰色
  • 向根节点的下一个依赖节点进行扫描,将其标记为灰色,自身标记为黑色,类似下图
  • 循环上个操作,直到标记全部可达节点为黑色,然后回收白色节点。

因为扫描标记过程和业务程序运行时同步的,所以会产生这样一种情况:

黑色节点引用了一个白色节点,白色节点和前面的灰色节点的引用关系又被破坏,这就会导致这个白色节点存在引用,但依然会被回收。如下图

产生这种情况,需要满足两个条件

  • 条件一:一个黑色节点引用了一个白色节点
  • 条件二:白色节点前面到灰色节点的引用关系遭到破坏

强三色不变式

为避免引用内存被回收,破坏条件一的方式 称为强三色不变式。

规则:不允许黑色对象引用白色对象

弱三色不变式

根据破坏条件二的处理方式 称为弱三色不变式

规则:黑色对象可以引用白色对象,但是白色对象的上游必须存在灰色对象

插入写屏障

满足强三色不变式

规则:当一个对象引用另外一个对象时,将另外一个对象标记为灰色。

go在开始使用三色标记法的时候,就是采用的插入写屏障。但这种保护只对堆内存资源生效。

原因是go的逃逸分析更倾向将资源分配到栈上,在大量goroution调度时,栈上的资源被频繁调度,如果存在插入写屏障,会产生极大的性能消耗。

go的做法是,在一次正常的三色标记流程之后,stw,然后对栈上的资源进行rescan一次。

当然stw+rescan这种重新扫描的操作,依然有极大的成本。

删除写屏障

满足弱三色不变式

规则:在删除引用时,如果被删除引用的对象自身为灰色或者白色,那么被标记为灰色。

这种方式会导致回收精度下降,很多应该回收的资源,会存活到下一轮gc。

混合写屏障

go在1.18之后,不再使用插入写屏障,而是自研了一种混合写屏障,避免了rescan问题,也不会降低回收精度。

规则:

  • GC刚开始的时候,会将栈上的可达对象全部标记为黑色。
  • GC期间,任何在栈上新创建的对象,均为黑色。
  • 堆上被删除的对象标记为灰色
  • 堆上新添加的对象标记为灰色

源码解读

这里依然是mac+go版本1.18.4

gc触发方式

方式一: 定时触发,runtime/proc.go

golang 复制代码
func init() {
   go forcegchelper()
}

func forcegchelper() {
...
   for {
   ...
      // Time-triggered, fully concurrent.
      // 在gcStart 函数中会计算,如果上次gc在两分钟之前则会触发gc
      gcStart(gcTrigger{kind: gcTriggerTime, now: nanotime()})
   }
}

方式二: 内存分配阈值

在程序分配新的内存,函数runtime/malloc.go->mallocgc时,内存分配量超过一定的阈值时,垃圾回收器会被触发。

方式三: 主动触发,在业务代码中主动调起gc

scss 复制代码
runtime.GC()

gc步骤

从上面三色标记法里可以看出来 gc大概有这样三个阶段

  1. 标记(Mark): 在这个阶段,垃圾回收器会标记所有仍然被引用的对象,以区分出哪些对象是存活的。
  2. 清除(Sweep): 在这个阶段,垃圾回收器会遍历堆上的对象,将那些被标记为不再被引用的对象释放掉,从而回收内存。
  3. 回收(Reclaim): 在清除阶段完成后,垃圾回收器会将回收的内存重新添加到空闲列表,以备后续的内存分配使用。

开始标记过程 gcStart

gc的主要过程在runtime/mgc.go下的gcStart

go 复制代码
func gcStart(trigger gcTrigger) {
   //gc依赖malloc,malloc可能存在多个调用。如果它处于被抢占的情况下,则不开始gc
   mp := acquirem()
   if gp := getg(); gp == mp.g0 || mp.locks > 1 || mp.preemptoff != "" {
      releasem(mp)
      return
   }
   releasem(mp)
   mp = nil

   //清理标记位,为下一轮gc做准备
   for trigger.test() && sweepone() != ^uintptr(0) {
      sweep.nbgsweep++
   }

   //卡住 直到获取startSema信号,相当于加锁 只允许一个线程来操作
   semacquire(&work.startSema)
   //检查gc条件是否满足,
   if !trigger.test() {
      semrelease(&work.startSema) //不满足和释放信号量 并返回
      return
   }

   //检查gc是否被强制执行,如果是用户主动gc,那么久不应该参考统计数据
   work.userForced = trigger.kind == gcTriggerCycle

   //设置gc模式,是否以debug设置的模式运行
   mode := gcBackgroundMode
   if debug.gcstoptheworld == 1 {
      mode = gcForceMode
   } else if debug.gcstoptheworld == 2 {
      mode = gcForceBlockMode
   }

   //加锁 stw相关
   semacquire(&gcsema)
   semacquire(&worldsema)

   //如果启用了全局跟踪事件,那发送一个traceEvGCStart事件 表示gc开始了
   if trace.enabled {
      traceGCStart()
   }

   // 检查所有的p是否完成了mcache的清理
   for _, p := range allp {
      if fg := atomic.Load(&p.mcache.flushGen); fg != mheap_.sweepgen {
         println("runtime: p", p.id, "flushGen", fg, "!= sweepgen", mheap_.sweepgen)
         throw("p mcache not flushed")
      }
   }
   //启动开始后台标记的goroution,需要持有 worldsema
   gcBgMarkStartWorkers()

   //在标记开始之前重置全局状态,并重置所有 G 的堆栈扫描状态,并且这个过程运行在操作系统的堆栈上,防止污染
   systemstack(gcResetMarkState)

   //计算相关数据,防止gc过长
   work.stwprocs, work.maxprocs = gomaxprocs, gomaxprocs
   if work.stwprocs > ncpu {
      work.stwprocs = ncpu
   }
   work.heap0 = atomic.Load64(&gcController.heapLive)
   work.pauseNS = 0
   work.mode = mode

   now := nanotime()
   work.tSweepTerm = now
   work.pauseStart = now
   if trace.enabled {
      traceGCSTWStart(1) //同理,表示开始stw
   }
   // stw
   systemstack(stopTheWorldWithSema)
   // 在开始扫描之前确保清理完成
   systemstack(func() {
      finishsweep_m()
   })

   //顾名思义 清理 pools
   clearpools()

   work.cycles++

   // 重置 GC 控制器的状态并计算新 GC 周期的估计值
   gcController.startCycle(now, int(gomaxprocs))
   work.heapGoal = gcController.heapGoal

   // gc模式为gcBackgroundMode时,禁用g的调度
   if mode != gcBackgroundMode {
      schedEnableUser(false)
   }

   // 设置gc状态是_GCmark,相当于启动了写屏障
   setGCPhase(_GCmark)
   // gc开始标记前的一些设置
   gcBgMarkPrepare() // Must happen before assist enable.
   // 开始标记前,初始化根节点(堆栈 全局资源 杂项)
   gcMarkRootPrepare()

   // 标记微对象相关
   gcMarkTinyAllocs()

   // 设置gcBlackenEnabled=_GCmark 写屏障相关
   atomic.Store(&gcBlackenEnabled, 1)

   // 获取m
   mp = acquirem()

   // 停止stw,设置gc标记时的相关参数
   systemstack(func() {
      now = startTheWorldWithSema(trace.enabled)
      work.pauseNS += now - work.pauseStart
      work.tMark = now
      memstats.gcPauseDist.record(now - work.pauseStart)
   })

   // 释放 worldsema ,释放m
   semrelease(&worldsema)
   releasem(mp)

   // 启动g的调度
   if mode != gcBackgroundMode {
      Gosched()
   }
   //释放startSema
   semrelease(&work.startSema)
}
  • sweepone 函数:扫描runtime.mheap_.mcentral 清理标记,释放那些不被引用的资源对象。具体的回收工作是在其里面的runtime.mspan.sweep函数中完成。
  • gcBgMarkStartWorkers 会启动GOMAXPROCS个goroution进行标记工作,相当于每个处理器上一个。
  • trigger.test 函数:用于检查gc条件是否满足,上面我们的定时gc就是在这里判断时间是否超过预定值forcegcperiod
  • stopTheWorldWithSema :是stopTheWorld的核心实现,需持有worldsema 并禁用全局抢占
  • Gosched 让出当前cpu,会在未来的某个时间再次执行

清理回收

清理回收主要还是靠sweepone,类似下面的代码,和上文是一致的。

go 复制代码
for ... && sweepone() != ^uintptr(0) {
...
}

分代的垃圾回收策略

相信聪明的你 已经发现,本轮gc标记的垃圾,会在下一轮次才能回收。go将对象分为,新生代(young generation)和老年代(old generation)。这个分代策略在一定程度上确保了高效的内存回收。

当然这也不绝对,比如runtime.GC()会等待标记完成,清理一轮后才会进行函数返回。

尾语

go的垃圾回收,其实并不复杂,结合之前的go内存管理启动流程一起看效果更佳。

虽然还有很多地方没有解读,比如写屏障的部分,标记的部分,以及回收的部分。

不过 相关小伙伴们在看过这三篇博文之后,整体脉络是清晰的,顺着这个思路往下捋,细枝末节也是可以捋清楚的。

相关推荐
我是前端小学生5 小时前
Go语言中的方法和函数
go
探索云原生9 小时前
在 K8S 中创建 Pod 是如何使用到 GPU 的: nvidia device plugin 源码分析
ai·云原生·kubernetes·go·gpu
自在的LEE16 小时前
当 Go 遇上 Windows:15.625ms 的时间更新困局
后端·kubernetes·go
Gvto1 天前
使用FakeSMTP创建本地SMTP服务器接收邮件具体实现。
go·smtp·mailtrap
白泽来了2 天前
【Go进阶】手写 Go websocket 库(一)|WebSocket 通信协议
开源·go
witton2 天前
将VSCode配置成Goland的视觉效果
ide·vscode·编辑器·go·字体·c/c++·goland
非凡的世界2 天前
5个用于构建Web应用程序的Go Web框架
golang·go·框架·web
湫qiu2 天前
6.5840 Lab-Key/Value Server 思路
后端·go
我是前端小学生3 天前
Go语言中的init函数
go
我是前端小学生3 天前
Go语言中内部模块的可见性规则
go