Go垃圾回收机制

68 阅读5分钟

背景:被 “失控协程” 撑爆的实时推送服务

某社交 App 的实时消息推送服务(Go 语言开发)为提升并发效率,采用了 “一连接一协程” 的设计:每当用户打开 App 建立 WebSocket 连接时,服务就启动一个专属协程负责消息转发(从 Kafka 拉取该用户的消息,推送到客户端)。

初期用户量少时,系统稳定运行。但随着日活突破千万,运维团队发现了诡异现象:

  • 服务进程的协程数量从正常的几万飙升到数百万,且持续增长;
  • 内存占用随协程数同步上涨,几天内就耗尽了服务器的 64GB 内存,导致消息推送延迟甚至超时;
  • 更奇怪的是,即使部分用户断开连接(理论上对应协程应退出),协程总数仍未下降 —— 大量 “僵尸协程”(已无实际连接却仍在空循环等待消息)霸占着资源;
  • 监控显示 Go 的垃圾回收(GC)频繁触发,但内存释放微乎其微,这些运行中的协程仿佛成了 GC “看不见” 的漏网之鱼。

为什么这些明明无用的运行中协程,无法被 Go 的垃圾回收机制清理?这背后藏着 Go 协程与 GC 协作的底层逻辑……

垃圾回收机制

Go 的垃圾回收以 Mark&Sweep(标记 - 清除算法)为基础,自 v1.0 以来经历了多轮里程碑式优化,核心目标是减少 STW(Stop-The-World)停顿时间,平衡回收效率与程序运行连续性。以下是关键迭代过程的精简梳理:

  • v1.0:采用原始标记 - 清除算法,整个回收过程完全 STW(暂停所有程序执行),延迟较高,难以满足高并发场景。
  • v1.3:分离标记(Mark)与清除(Sweep)阶段 —— 标记阶段仍需 STW,但清除阶段可与程序并发执行,初步降低了对业务的阻塞时间。
  • v1.5:引入三色标记法,将对象分为白、灰、黑三色追踪可达性,使标记阶段可与程序并发执行(仅初始和收尾需短暂 STW),延迟从数百毫秒降至 10ms 以下,大幅提升实时性。
  • v1.8:新增混合写屏障(Hybrid Write Barrier) ,解决了三色标记中的 “对象漏标” 问题,彻底移除了对栈的重扫描过程,将单次 GC 停顿压缩至 0.5ms 以内,几乎消除了对高敏感业务的影响。

这些优化使 Go 的 GC 从 “全量阻塞” 逐步进化为 “低延迟并发回收”,为高吞吐、低延迟的服务场景(如微服务、实时数据处理)提供了核心支撑。

标记-清除算法

在垃圾回收(GC)的 “标记 - 清除法”(Mark-Sweep)中,被清除的是 “不可达对象”—— 即程序中没有任何活跃引用指向的对象(也称为 “垃圾对象”)。

标记 - 清除法分为两个两个核心步骤:

  1. 标记阶段:从 “根对象”(如全局变量、当前栈帧中的局部变量、寄存器中的引用等)出发,遍历所有能被直接或间接引用到的对象,给这些 “可达对象” 打上标记(标记为 “存活”)。
  2. 清除阶段:遍历整个堆内存,将所有未被标记的对象(即 “不可达对象”)视为垃圾,回收它们占用的内存空间,供后续新对象分配使用。

根对象具体包含哪些内容?

不同编程语言的根对象范围略有差异,但核心逻辑一致,以 Go 语言为例,根对象主要包括以下几类:

1. 当前执行栈中的活跃变量

即程序运行时,当前正在执行的函数(包括调用栈中的所有函数)里的局部变量和参数。

  • 例如:在函数 func f() { var a *int = new(int); g(a) } 中,a 是栈上的局部变量(引用了堆上的 int 对象),在 f 执行期间,a 就是根对象,它引用的堆对象会被标记为存活。
  • 栈上的变量本身存储在栈内存中(栈内存由编译器自动管理,随函数调用创建、退出销毁),但它们指向的堆对象是否存活,取决于这些栈变量是否属于根对象。

2. 全局变量(包级变量)

在包级别声明的变量(整个程序生命周期内都存在),无论是否被使用,都是根对象。

  • 例如:var globalCache = make(map[string]string),这个 globalCache 是全局变量,从程序启动到退出始终存在,它引用的 map 对象及其中的键值对,都会被视为可达对象。

3. 寄存器中的引用

CPU 寄存器中存储的、指向堆对象的引用。

  • 程序执行时,部分变量会被加载到寄存器中加速运算(如循环变量、频繁访问的指针),这些寄存器中的引用会被 GC 视为根对象,确保它们指向的堆对象不被误回收。

4. 活跃的协程(Goroutine)相关对象

Go 中每个运行或阻塞的协程(Goroutine)本身及其栈上的变量,都是根对象的一部分:

  • 协程的栈内存中存储的局部变量(如函数参数、临时指针);
  • 协程的状态信息(如调度信息、等待的锁 / 通道)。
  • 这也是 “运行中的协程不会被 GC 回收” 的核心原因:协程本身属于根对象,且其栈上的引用会让关联的堆对象保持可达。【看到这,就能理解为啥运行的协程不会被垃圾回收了!!!】

5. 被操作系统或底层 runtime 持有的引用

  • 例如:通过 cgo 调用 C 代码时,C 层持有的 Go 对象引用(需通过 runtime.KeepAlive 等机制告知 GC 该对象仍被使用);
  • 与操作系统交互的资源(如文件描述符、网络连接)关联的 Go 对象,只要资源未释放,对应的对象就是可达的。

存在问题

  1. 卡得狠:回收时必须暂停所有业务代码(STW),内存越大卡得越久,像视频卡帧一样。
  2. 碎得很:回收后内存坑坑洼洼(碎片化),下次想存个大对象,明明内存够却塞不下。
  3. 跑得慢:每次都要从头到尾扫一遍所有对象,内存越大、东西越多,跑得越慢。

三色标记法

摘自:community.apinto.com/d/34057-gol…