go gc

1. 什么是GC

GC,全称 Garbage Collection,即垃圾回收,是一种自动内存管理的机制。

2. 为什么GC

程序向操作系统申请的内存不再需要时

3. 怎么GC

  • 赋值器(Mutator:用户代码,负责修改对象引用关系。
  • 回收器(Collector:负责标记和回收无用内存。

4. GC 可观测

GODEBUG=gctrace=1 debug.ReadGCStats runtime.ReadMemStats

5. 为什么会出现内存泄漏

-  预期能被快速释放的内存因被根对象引用而没有得到迅速释放
		 - 指针
		 - 全局变量
 - Goroutine 泄漏
 Goroutine 作为一种逻辑上理解的轻量级线程,需要维护执行用户代码的上下文信息。在运行过程中也需要消耗一定的内存来保存这类信息,而这些内存在目前版本的 Go 中是不会被释放的。因此,如果一个程序持续不断地产生新的 goroutine、且不结束已经创建的 goroutine 并复用这部分内存,就会造成内存泄漏的现象.
 eg: 曾经项目中遇到的,mock了一个链接,忘记关闭 ``` // FillArgsInSQL 使用 mock db 将参数填回 SQL func FillArgsInSQL(sql string, args ...any) string {
db, _, _ := sqlmock.New()
defer db.Close() // <——  如果没关闭
gormDB, _ := gorm.Open(
	mysql.New(mysql.Config{
		Conn: db,   // Existing mock database connection
		SkipInitializeWithVersion: true, // 跳过 SELECT VERSION() 检查
	}),
	&gorm.Config{DryRun: true})
sqlStmt := gormDB.ToSQL(func(tx *gorm.DB) *gorm.DB {
	return tx.Raw(sql, JsonToBasicType(args)...)
})
return sqlStmt } ```

6. 常见的垃圾回收方法

所有的 GC 算法其存在形式可以归结为追踪(Tracing)和引用计数(Reference Counting)这两种形式的混合运用。 目前比较常见的 GC 实现方式包括:

  • 追踪式,分为多种不同类型,例如:
    • 标记清扫:从根对象出发,将确定存活的对象进行标记,并清扫可以回收的对象。
    • 标记整理:为了解决内存碎片问题而提出,在标记过程中,将对象尽可能整理到一块连续的内存上。
    • 增量式:将标记与清扫的过程分批执行,每次执行很小的部分,从而增量的推进垃圾回收,达到近似实时、几乎无停顿的目的。
    • 增量整理:在增量式的基础上,增加对对象的整理过程。
    • 分代式:将对象根据存活时间的长短进行分类,存活时间小于某个值的为年轻代,存活时间大于某个值的为老年代,永远不会参与回收的对象为永久代。并根据分代假设(如果一个对象存活时间不长则倾向于被回收,如果一个对象已经存活很长时间则倾向于存活更长时间)对对象进行回收。
  • 引用计数:根据对象自身的引用计数来回收,当引用计数归零时立即回收。
  • 6. golang 的 gc

    对于 Go 而言,

  • 无分代:不区分新生代和老年代对象。
  • 不整理:不移动对象,避免碎片整理。
  • 并发:GC 与用户代码并发执行,减少停顿。
  • 三色标记清扫算法:核心实现方式。 原因在于:

  • 对象整理的优势是解决内存碎片问题以及“允许”使用顺序内存分配器。但 Go 运行时的分配算法基于 tcmalloc,基本上没有碎片问题。 并且顺序内存分配器在多线程的场景下并不适用。Go 使用的是基于 tcmalloc 的现代内存分配算法,对对象进行整理不会带来实质性的性能提升。
  • 分代 GC 依赖分代假设,即 GC 将主要的回收目标放在新创建的对象上(存活时间短,更倾向于被回收),而非频繁检查所有对象。但 Go 的编译器会通过逃逸分析将大部分新生对象存储在栈上(栈直接被回收),只有那些需要长期存在的对象才会被分配到需要进行垃圾回收的堆中。也就是说,分代 GC 回收的那些存活时间短的对象在 Go 中是直接被分配到栈上,当 goroutine 死亡后栈也会被直接回收,不需要 GC 的参与,进而分代假设并没有带来直接优势。并且 Go 的垃圾回收器与用户代码并发执行,使得 STW 的时间与对象的代际、对象的 size 没有关系。Go 团队更关注于如何更好地让 GC 与用户代码并发执行(使用适当的 CPU 来执行垃圾回收),而非减少停顿时间这一单一目标上。 三色标级清楚算法:标计(白/灰/黑)->清除 理解三色标记法的关键是理解对象的三色抽象以及波面(wavefront)推进这两个概念。三色抽象只是一种描述追踪式回收器的方法,在实践中并没有实际含义,它的重要作用在于从逻辑上严密推导标记清理这种垃圾回收方法的正确性。也就是说,当我们谈及三色标记法时,通常指标记清扫的垃圾回收。

从垃圾回收器的视角来看,三色抽象规定了三种不同类型的对象,并用不同的颜色相称:

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

这样三种不变性所定义的回收过程其实是一个波面不断前进的过程,这个波面同时也是黑色对象和白色对象的边界,灰色对象就是这个波面。

当垃圾回收开始时,只有白色对象。随着标记过程开始进行时,灰色对象开始出现(着色),这时候波面便开始扩大。当一个对象的所有子节点均完成扫描时,会被着色为黑色。当整个堆遍历完成时,只剩下黑色和白色对象,这时的黑色对象为可达对象,即存活;而白色对象为不可达对象,即死亡。这个过程可以视为以灰色对象为波面,将黑色对象和白色对象分离,使波面不断向前推进,直到所有可达的灰色对象都变为黑色对象为止的过程。如下图所示: ![[go-gc.png]]

7. STW 是什么意思?

STW 可以是 Stop the World 的缩写,也可以是 Start the World 的缩写。通常意义上指指代从 Stop the World 这一动作发生时到 Start the World 这一动作发生时这一段时间间隔,即万物静止。STW 在垃圾回收过程中为了保证实现的正确性、防止无止境的内存增长等问题而不可避免的需要停止赋值器进一步操作对象图的一段过程。

在这个过程中整个用户代码被停止或者放缓执行, STW 越长,对用户代码造成的影响(例如延迟)就越大,早期 Go 对垃圾回收器的实现中 STW 长达几百毫秒,对时间敏感的实时通信等应用程序会造成巨大的影响。我们来看一个例子:

package main

import (
	"runtime"
	"time"
)

func main() {
	go func() {
		for {
		}
	}()

	time.Sleep(time.Millisecond)
	runtime.GC()
	println("OK")
}

上面的这个程序在 Go 1.14 以前永远都不会输出 OK,其罪魁祸首是进入 STW 这一操作的执行无限制的被延长。

尽管 STW 如今已经优化到了半毫秒级别以下,但这个程序被卡死原因是由于需要进入 STW 导致的。原因在于,GC 在需要进入 STW 时,需要通知并让所有的用户态代码停止,但是 for {} 所在的 goroutine 永远都不会被中断,从而始终无法进入 STW 阶段。实际实践中也是如此,当程序的某个 goroutine 长时间得不到停止,强行拖慢进入 STW 的时机,这种情况下造成的影响(卡死)是非常可怕的。好在自 Go 1.14 之后,这类 goroutine 能够被异步地抢占,从而使得进入 STW 的时间不会超过抢占信号触发的周期,程序也不会因为仅仅等待一个 goroutine 的停止而停顿在进入 STW 之前的操作上。

8. 标记清楚的难点在哪里

理想情况下,三色严格分开,没有用户态修改,是可以正常完成的。 但是并发回收的根本问题在于,用户态代码在回收过程中会并发地更新对象图,从而造成赋值器和回收器可能对对象图的结构产生不同的认知。这时以一个固定的三色波面作为回收过程前进的边界则不再合理。

时序 回收器 赋值器 说明
1 shade(A, gray)   回收器:根对象的子节点着色为灰色对象
2 shade(C, black)   回收器:当所有子节点着色为灰色后,将节点着为黑色
3   C.ref3 = C.ref2.ref1 赋值器:并发的修改了 C 的子节点
4   A.ref1 = nil 赋值器:并发的修改了 A 的子节点
5 shade(A.ref1, gray)   回收器:进一步灰色对象的子节点并着色为灰色对象,这时由于 A.ref1 为 nil,什么事情也没有发生
6 shade(A, black)   回收器:由于所有子节点均已标记,回收器也不会重新扫描已经被标记为黑色的对象,此时 A 被着色为黑色,scan(A) 什么也不会发生,进而 B 在此次回收过程中永远不会被标记为黑色,进而错误地被回收。
  • 初始状态:假设某个黑色对象 C 指向某个灰色对象 A ,而 A 指向白色对象 B;
  • C.ref3 = C.ref2.ref1:赋值器并发地将黑色对象 C 指向(ref3)了白色对象 B;
  • A.ref1 = nil:移除灰色对象 A 对白色对象 B 的引用(ref2);
  • 最终状态:在继续扫描的过程中,白色对象 B 永远不会被标记为黑色对象了(回收器不会重新扫描黑色对象),进而对象 B 被错误地回收。

总而言之,并发标记清除中面临的一个根本问题就是如何保证标记与清除过程的正确性。

跟对象

8. 根对象(**Root Set**)

  • GC 从根对象开始追踪,根对象包括:

  • 全局变量
  • 每个 goroutine 的执行栈
  • 寄存器中的指针

9. 写屏障

写屏障是 Go 并发垃圾回收中用于保证内存安全的关键机制。

什么是写屏障? 写屏障(Write Barrier)是一种在并发垃圾回收(GC)中用于维护对象引用关系正确性的技术。它的作用是在用户代码(赋值器)修改指针时,通知 GC 相关对象的变化,防止在并发标记过程中出现对象“误回收”或“丢失”。 为什么需要写屏障? 在 Go 的三色标记清扫算法中,GC 和用户代码是并发执行的。用户代码可能在 GC 标记阶段修改对象引用关系(比如把一个对象的指针从 A 改为 B),这会导致 GC 对对象可达性的判断出现偏差,进而可能错误地回收还在使用的对象。

写屏障的引入,就是为了解决这种并发修改带来的三色不变性破坏问题,确保 GC 的正确性。

三色不变性与写屏障

  • 三色不变性:在标记过程中,黑色对象不能直接引用白色对象(未被标记的对象),否则白色对象可能被错误回收。
  • 写屏障的作用就是在赋值操作时,自动对相关对象进行标记或处理,以维持三色不变性。

常见写屏障类型

  1. Dijkstra 插入屏障
    • 在赋值时,对新赋值的指针(目标对象)进行标记(变灰)。
    • 保证黑色对象不会直接引用白色对象。
    • 伪代码: ```
    • func DijkstraWritePointer(slot *unsafe.Pointer, ptr unsafe.Pointer) {     shade(ptr)      // 标记目标对象为灰色     *slot = ptr } ```
  2. Yuasa 删除屏障
  • 在赋值时,对被覆盖的旧指针(原对象)进行标记(变灰)。
  • 保证不会丢失从灰色对象到白色对象的路径。
  • 伪代码:
    func YuasaWritePointer(slot *unsafe.Pointer, ptr unsafe.Pointer) {
        shade(*slot)    // 标记原对象为灰色
        *slot = ptr
    }
    

  1. 混合写屏障(Go 的实现)
    • 同时对新旧指针都进行标记,结合了两者优点。
    • Go 1.8 及以后采用混合写屏障,提升了并发 GC 的正确性和效率。
    • 伪代码: ```
    • func HybridWritePointerSimple(slot unsafe.Pointer, ptr unsafe.Pointer) {     shade(slot)    // 标记原对象     shade(ptr)      // 标记新对象     *slot = ptr } ```

写屏障的实际效果

  • 保证并发标记的正确性:防止对象被错误回收或遗漏。
  • 降低 STWStop the World)时间:让更多标记工作在并发阶段完成,减少程序暂停。
  • 性能权衡:写屏障会带来一定的性能开销,但这是保证 GC 正确性的必要代价。Go 通过批量写屏障等优化手段降低了这部分开销。 总结 写屏障是 Go 并发垃圾回收不可或缺的机制。它通过在指针写操作时自动插入标记逻辑,确保三色标记清扫算法的正确性,防止对象丢失或误回收,是现代高性能 GC 的核心技术之一。

你如果想深入理解 Go 的 GC 行为,写屏障的原理和实现是必须掌握的关键知识点。三色标记法和STW等概念也与写屏障密切相关。

10. Go GC 实现细节

| 阶段 | 说明 | 赋值器状态 | | —————- | —————————– | —– | | SweepTermination | 清扫终止阶段,为下一个阶段的并发标记做准备工作,启动写屏障 | STW | | Mark | 扫描标记阶段,与赋值器并发执行,写屏障开启 | 并发 | | MarkTermination | 标记终止阶段,保证一个周期内标记任务完成,停止写屏障 | STW | | GCoff | 内存清扫阶段,将需要回收的内存归还到堆中,写屏障关闭 | 并发 | | GCoff | 内存归还阶段,将过多的内存归还给操作系统,写屏障关闭 | 并发 |

11. GoGC 触发

Go 语言中对 GC 的触发时机存在两种形式:

  1. 主动触发,通过调用 runtime.GC 来触发 GC,此调用阻塞式地等待当前 GC 运行完毕。
  2. 被动触发,分为两种方式:
    • 使用系统监控,当超过两分钟没有产生任何 GC 时,强制触发 GC。 env GOGC
    • 使用步调(Pacing)算法,其核心思想是控制内存增长的比例。
    • env GOMEMLIMTIT 1.19 后加入

https://golang.design/go-questions/memgc/ Golang GC 历史演进 官方 gc guide