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内存管理启动流程一起看效果更佳。

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

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

相关推荐
梦想很大很大8 小时前
使用 Go + Gin + Fx 构建工程化后端服务模板(gin-app 实践)
前端·后端·go
lekami_兰13 小时前
MySQL 长事务:藏在业务里的性能 “隐形杀手”
数据库·mysql·go·长事务
却尘16 小时前
一篇小白也能看懂的 Go 字符串拼接 & Builder & cap 全家桶
后端·go
ん贤17 小时前
一次批量删除引发的死锁,最终我选择不加锁
数据库·安全·go·死锁
mtngt111 天前
AI DDD重构实践
go
Grassto3 天前
12 go.sum 是如何保证依赖安全的?校验机制源码解析
安全·golang·go·哈希算法·go module
Grassto4 天前
11 Go Module 缓存机制详解
开发语言·缓存·golang·go·go module
程序设计实验室5 天前
2025年的最后一天,分享我使用go语言开发的电子书转换工具网站
go
我的golang之路果然有问题5 天前
使用 Hugo + GitHub Pages + PaperMod 主题 + Obsidian 搭建开发博客
golang·go·github·博客·个人开发·个人博客·hugo
啊汉7 天前
古文观芷App搜索方案深度解析:打造极致性能的古文搜索引擎
go·软件随想