- 原文:[A Guide to the Go Garbage Collector](https://tip.golang.org/doc/gc-guide) # Introduction - [Go spec](https://tip.golang.org/ref/spec) 并没有规定 GC 的存在,只是规定了 Go values 的底层存储由语言自己负责,这允许使用不同的内存管理技术。 - 这个 guide 只适用于官方 toolchain,Gccgo 和 Gollvm 也使用了类似的 GC 实现,但细节会有不同。 ## Where Go Values Live - 不需要被 GC 管理的内存:stack allocation - 存储在 local variables 的 non-pointer Go Values - 可以提前知道何时释放,Go complier 直接生成清理指令,比通过 GC 高效。 - escape to the heap:dynamic memory allocation - 无法使用 stack allocation,无法确定它的 lifetime、何时使用或者释放。 - 堆逃逸满足传递性。 ## Tracing Garbage Collection - 通过传递性地跟踪指针,来判断哪些是使用中的(称为 live)的 objects。 - 术语: - **Object**:一段动态分配的内存,包含一个到多个 Go values。 - **Pointer**:引用 object 中任意值的内存地址。包括 - `*T` 形式 - 部分 Go 内置如 values Strings,slices,channels,maps 和 interface 等包含内存地址的 - objects 和 指向其他 objects 的 pointers 组成了 **object graph**。为了识别 live memory,GC 从程序的 **roots** 开始遍历 object graph,这个过程称为 **scanning**。 - roots 的两个例子是 local variables 和 global variables - Go 的 GC 使用了 mark-sweep 技术 - **mark**:扫描时将遇到的 values 标记为 live - **sweep**:扫描完成后,遍历所有内存将未标记的内存设置为可分配的(available for allocation) - Go 的 GC 是 **non-moving** 的 - moving:将对象移动到内存的新部分,并留下一个转发指针,稍后用于更新所有应用程序的指针。 ------- # The GC cycle - **GC cycle**: 三阶段,sweeping -> turning off -> marking ## Understanding costs - GC 成本模型: 1. GC 只涉及两种资源:CPU time 和 物理内存 2. GC 的内存成本包含: - live heap memory - new heap memory allocated(当前 GC cycle 内,mark phase 之前的,不一定最终存活) - metadata space(比例较小) 3. 每个 cycle 的 CPU cost 固定,以及与 live heap 成比例的边际成本 - 考虑稳态(steady-state)的场景 - 应用分配新内存的速率(每秒多少字节)是不变的 - 应用的 object graph 基本保持不变(对象的大小相似,指针的数量大致不变,图的最大深度大致不变) - 在稳定的场景下,当 live heap size 不变时,每次 GC cycle 的间隔相同,则成本就是一样的。 - 间隔相同,代表 cycle 期间新分配的内存相同,live heap 也相同 - **GC frequency**:代表了 CPU time 和 内存之间的权衡 - 频率越低,CPU 消耗越小,累计的新分配内存越多,导致 heap 越大 ## GOGC - GOGC 参数 $Target\ heap\ memory = Live\ heap + (Live\ heap + GC\ roots) * GOGC / 100$ - target heap 表示 heap size 达到多大时执行 GC,它控制了 GC 的频率,越多等待 GC 的时间就越长。 > **doubling GOGC will double heap memory overheads and roughly halve GC CPU cost** - 更改 GOGC 参数 - 环境变量 - 通过 [SetGCPercent](https://pkg.go.dev/runtime/debug#SetGCPercent) API - 设置 `GOGC=off` 或者 `SetGCPercent(-1)` 可以关闭 GC ## Memory limit - Go 1.19 之前不会考虑内存是有限的,GC 的 target heap size 是固定的,可能会突然超出系统的内存限制(比如发生瞬时的分配高峰)。 - Go 1.19 增加了一个 runtime memory limit 的配置,通过环境变量 `GOMEMLIMIT` 或者 `SetMemoryLimit` 来更改。 - 当 heap 接近这个限制时,会更积极地执行 GC。 - Metrics - [`runtime.MemStats`](https://pkg.go.dev/runtime#MemStats) - [`runtime/metrics`](https://pkg.go.dev/runtime/metrics) - 即使 GOGC 是关闭的,memory limit 仍然会起作用,它实际上设置了 GC 的最小频率。 - memory limit 的问题 - thrashing:如果出现一个瞬时的非常大的 heap 峰值, 可能会导致持续的 GC(长时间达到内存限制),导致程序停滞。 - 因此,memory limit 定义为**软限制**,Go runtime 不保证始终能满足它。 - 其内部工作原理是,GC 为其在某个时间窗口内可以使用的 CPU 时间设置了**上限**(例如 50%),这可能会导致 GC 被延迟,新内存继续可以被分配,最终超出 memoy limit。 - 最坏场景下,CPU 50%用于 GC,拖慢程序两倍。 ### 建议的使用方式 - 当执行环境完全在你的控制之下,Go 程序是唯一访问内存资源的程序,可以设置 memory limit,例如为容器预留 5-10 % ,其余的设置为 memory limit。 - 可以随时实时调整 memory limit ,以适应条件的变化。 - 不要关闭 GOGC,如果 Go 程序与其他程序共享有限的内存。相反,保留内存限制,因为这可能有助于抑制不良的瞬态行为,但可以将 GOGC 设置为某个较小的合理值以适应平均情况。 - 在部署的执行环境无法控制时,不要使用内存限制,尤其是在程序的内存使用与其输入成比例时。 - 例如 CLI tool 或者 desktop 应用,无法清楚知道输入的大小,系统可用的内存。 - 不要设置内存限制,来避免在程序已经接近其环境的内存限制时 OOM。 - 这实际上是用严重的应用程序减速风险替代了内存不足的风险,即使 Go 努力缓解抖动,这通常也不是一个有利的交换。在这种情况下,更有效的方法是增加环境的内存限制(然后可能设置一个内存限制)或减少 GOGC(这比抖动缓解提供了更清晰的权衡)。 ## Latency - Go GC 不完全是 STW 的,大多数 GC 工作可以跟应用并行执行。GC 的 STW 时间会导致应用的延迟增高。 - 关键结论:减少 GC 频率也可能带来延迟改善。 - 延迟的一些来源: - mark 到 sweep 之间的阶段转换带来的短暂 STW pause - GC 占用 CPU 资源导致调度延迟 - User goroutines assisting the GC in response to a high allocation rate - 当分配速率很高时,运行时系统可能会要求一些 goroutines 暂时协助执行垃圾回收任务。 - GC 的 mark 阶段对 pointer 的写入需要额外的工作 - Running goroutines 必须被暂停当它们的 roots 被扫描时 - 这些延迟可以通过  [execution traces](https://tip.golang.org/doc/diagnostics#execution-tracer) 可视化(除了指针写入的额外开销) ## Additional resources - [The GC Handbook](https://gchandbook.org/)—An excellent general resource and reference on garbage collector design. - [TCMalloc](https://google.github.io/tcmalloc/design.html)—Design document for the C/C++ memory allocator TCMalloc, which the Go memory allocator is based on. - [Go 1.5 GC announcement](https://tip.golang.org/blog/go15gc)—The blog post announcing the Go 1.5 concurrent GC, which describes the algorithm in more detail. - [Getting to Go](https://tip.golang.org/blog/ismmkeynote)—An in-depth presentation about the evolution of Go's GC design up to 2018. - [Go 1.5 concurrent GC pacing](https://docs.google.com/document/d/1wmjrocXIWTr1JxU-3EQBI6BK6KgtiFArkG47XK73xIQ/edit)—Design document for determining when to start a concurrent mark phase. - [Smarter scavenging](https://tip.golang.org/issue/30333)—Design document for revising the way the Go runtime returns memory to the operating system. - [Scalable page allocator](https://tip.golang.org/issue/35112)—Design document for revising the way the Go runtime manages memory it gets from the operating system. - [GC pacer redesign (Go 1.18)](https://tip.golang.org/issue/44167)—Design document for revising the algorithm to determine when to start a concurrent mark phase. - [Soft memory limit (Go 1.19)](https://tip.golang.org/issue/48409)—Design document for the soft memory limit. -------- # A note about virtual memory - 虚拟内存只是由操作系统维护的映射,所以通常可以非常低廉地预留很大的虚拟内存,而不实际映射到物理内存。 - Go runtime - 从不删除它 map 的 virtual memory。而是使用 OS 提供的操作,释放 VM 范围内的物理内存,将不再需要的内存返回给操作系统。 - 为多个内部数据结构预留了大量虚拟内存地址空间。在 64 位平台上,通常具大约 700 MiB。 - 因此对于 Go 程序,关注 VSS 意义不大,应该关注 RSS,它代表了实际的物理内存使用。 -------- # Optimization guide ## Identifying costs - 在尝试优 GC 之前,首先要确定 GC 是主要成本。可以借助一些工具来识别。 1. **CPU profiles** 的 GC 关键函数 - 不是 leaf functions,可能不会体现在 pprof tool 的 top 命令中 - **runtime.gcBgMarkWorker**:mark worker 协程的入口函数,可以体现 marking 和 scanning 的时间。 - `runtime.gcDrainMarkWorkerIdle`:表示 GC 在应用程序空闲时,使用额外的 CPU对 GC 进行加速。场景:应用使用一个协程运行,但 GOMAXPROCS 大于 1. - **runtime.mallocgc**: - 堆内存的分配入口。在此花费的累计时间较大(>15%)通常表示分配了大量内存。 - **runtime.gcAssistAlloc**: - goroutine让出一些时间来协助垃圾回收器进行扫描和标记。在这里花费的大量累计时间(>5%)表明应用程序在分配速度方面可能超过了垃圾回收器。 2. **Execution traces** - CPU profiles 专注于总体的时间占用,trace 用于识别单体的、延迟相关的。 -  文档:[`runtime/trace`](https://pkg.go.dev/runtime/trace) 3. **GC traces** - 启用:GODEBUG=gctrace=1,打印到 STDERR,指标含义:[gctrace](https://pkg.go.dev/runtime#hdr-Environment_Variables) - 更深入的:GODEBUG=gcpacertrace=1 ## Eliminating heap allocations - 减少 GC 成本的一种方法是让 GC 一开始就管理更少的值。下面描述的技术可以在性能上产生一些最大的改进,因为正如 GOGC 部分所示,Go 程序的分配率是影响 GC 频率的主要因素,也是本指南使用的关键成本指标。 - **Heap profiling** - pprof tool sample_index 选项 - _[`runtime.MemProfileRate`](https://pkg.go.dev/runtime#pkg-variables).:更改采样率 - **Escape analysis** - 利用 Go 编译器的逃逸分析,让 Go 编译器为这部分内存找到替代且更高效的存储方式,例如在 goroutine 栈中。 - `$ go build -gcflags=-m=3 [package]` - Vscode go 插件配置: 1. Set the [`ui.codelenses` setting to include `gc_details`](https://github.com/golang/vscode-go/wiki/settings#uicodelenses) . 2. Enable the overlay for escape analysis by [setting `ui.diagnostic.annotations` to include `escape`](https://github.com/golang/vscode-go/wiki/settings#uidiagnosticannotations) . ## Implementation-specific optimizations - 对象和指针的复杂图既限制了并行性,又为 GC 生成了更多工作。因此,GC 包含了一些针对特定常见结构的优化。以下是对性能优化最直接有用的一些。 - Pointer-free values are segregated from other values. - 从不严格需要指针的数据结构中消除指针可能是有利的,因为这减少了 GC 对程序施加的缓存压力。因此,依赖于索引而非指针值的数据结构虽然类型较差,但可能表现更好。只有当明确对象图很复杂且 GC 花费大量时间进行标记和扫描时,这才值得这样做。 - The GC will stop scanning values at the last pointer in the value. - 因此,将 struct 类型值中的指针字段组合到**该值开始位置**可能是有利的。这只有在明确应用程序花费大量时间进行标标记和扫描时才值得这样做。(理论上编译器可以自动完成此操作,但尚未实现,并且 struct 字段按源代码中的书写顺序排列。) - 另外,GC 必须与其看到的几乎每个指针交互,因此,例如使用切片中的索引代替指针,可以帮助降低 GC 成本。 ## Linux transparent huge pages(THP) - 在生产环境中运行 Go 程序时,在 Linux 上启用透明大页(THP)可以提高吞吐量和降低延迟,但会增加内存使用。