这篇文章与笔者之前所写几篇不同,是一篇综述型的文章,将从 GC 理论、在 Golang 中的应用、以及如何去做优化,这三个角度逐次进行阐述,文章中对于一些技术点会引用到多篇文章,希望读者也都能进行阅读,这有助于更全面的了解 Golang GC。
同时,特别鸣谢 @王德宇 同学对这篇文章的斧正,以及撰写过程中的诸多帮助与解答。
理论
GC 和内存分配方式是强相关的两个技术,因此在分析两者的设计原理之时,要结合起来一起看。
GC 算法
标记-清除
标记-整理
标记-复制
分代收集
关于以上算法的简单介绍
内存分配方式
线性分配
线性分配(Bump Allocator)是一种高效的内存分配方法,但是有较大的局限性。当我们使用线性分配器时,只需要在内存中维护一个指向内存特定位置的指针,如果用户程序向分配器申请内存,分配器只需要检查剩余的空闲内存、返回分配的内存区域并修改指针在内存中的位置,即移动下图中的指针:
线性分配器虽然线性分配器实现为它带来了较快的执行速度以及较低的实现复杂度,但是线性分配器无法在内存被释放时重用内存。如下图所示,如果已经分配的内存被回收,线性分配器无法重新利用红色的内存:
线性分配器回收内存因为线性分配器具有上述特性,所以需要与合适的垃圾回收算法配合使用,例如:标记压缩(Mark-Compact)、复制回收(Copying GC)和分代回收(Generational GC)等算法,它们可以通过拷贝的方式整理存活对象的碎片,将空闲内存定期合并,这样就能利用线性分配器的效率提升内存分配器的性能了。因为线性分配器需要与具有拷贝特性的垃圾回收算法配合,所以 C 和 C++ 等需要直接对外暴露指针的语言就无法使用该策略。
应用代表:Java(如果使用 Serial, ParNew 等带有 Compact 过程的收集器时,采用分配的方式为线性分配)
问题:内存碎片
解决方式:GC 算法中加入「复制/整理」阶段
空闲链表分配
空闲链表分配器(Free-List Allocator)可以重用已经被释放的内存,它在内部会维护一个类似链表的数据结构。当用户程序申请内存时,空闲链表分配器会依次遍历空闲的内存块,找到足够大的内存,然后申请新的资源并修改链表:
空闲链表分配器因为不同的内存块通过指针构成了链表,所以使用这种方式的分配器可以重新利用回收的资源,但是因为分配内存时需要遍历链表,所以它的时间复杂度是 ()。空闲链表分配器可以选择不同的策略在链表中的内存块中进行选择,最常见的是以下四种:
- 首次适应(First-Fit)— 从链表头开始遍历,选择第一个大小大于申请内存的内存块;
- 循环首次适应(Next-Fit)— 从上次遍历的结束位置开始遍历,选择第一个大小大于申请内存的内存块;
- 最优适应(Best-Fit)— 从链表头遍历整个链表,选择最合适的内存块;
- 隔离适应(Segregated-Fit)— 将内存分割成多个链表,每个链表中的内存块大小相同,申请内存时先找到满足条件的链表,再从链表中选择合适的内存块;
应用代表:GO、Java(如果使用 CMS 这种基于标记-清除,采用分配的方式为空闲链表分配)
问题:相比线性分配方式的 bump-pointer 分配操作(top += size),空闲链表的分配操作过重,例如在 GO 程序的 pprof 图中经常可以看到 mallocgc() 占用了比较多的 CPU;
在 Golang 里的应用
完整讲解:https://time.geekbang.org/col...
内存分配方式
Golang 采用了基于空闲链表分配方式的 TCMalloc 算法。
关于 TCMalloc
官方文档
- Front-end:它是一个内存缓存,提供了快速分配和重分配内存给应用的功能。它主要有2部分组成:Per-thread cache 和 Per-CPU cache。
- Middle-end:职责是给Front-end提供缓存。也就是说当Front-end缓存内存不够用时,从Middle-end申请内存。它主要是 Central free list 这部分内容。
- Back-end:这一块是负责从操作系统获取内存,并给Middle-end提供缓存使用。它主要涉及 Page Heap 内容。
TCMalloc将整个虚拟内存空间划分为n个同等大小的Page。将n个连续的page连接在一起组成一个Span。
PageHeap向OS申请内存,申请的span可能只有一个page,也可能有n个page。ThreadCache内存不够用会向CentralCache申请,CentralCache内存不够用时会向PageHeap申请,PageHeap不够用就会向OS操作系统申请。
GC 算法
Golang 采用了基于并发标记与清扫算法的三色标记法。
Golang GC 的四个阶段
Mark Prepare - STW
做标记阶段的准备工作,需要停止所有正在运行的goroutine(即STW),标记根对象,启用内存屏障,内存屏障有点像内存读写钩子,它用于在后续并发标记的过程中,维护三色标记的完备性(三色不变性),这个过程通常很快,大概在10-30微秒。Marking - Concurrent
标记阶段会将大概25%(gcBackgroundUtilization)的P用于标记对象,逐个扫描所有G的堆栈,执行三色标记,在这个过程中,所有新分配的对象都是黑色,被扫描的G会被暂停,扫描完成后恢复,这部分工作叫后台标记(gcBgMarkWorker)。这会降低系统大概25%的吞吐量,比如MAXPROCS=6,那么GC P期望使用率为6*0.25=1.5,这150%P会通过专职(Dedicated)/兼职(Fractional)/懒散(Idle)三种工作模式的Worker共同来完成。
这还没完,为了保证在Marking过程中,其它G分配堆内存太快,导致Mark跟不上Allocate的速度,还需要其它G配合做一部分标记的工作,这部分工作叫辅助标记(mutator assists)。在Marking期间,每次G分配内存都会更新它的”负债指数”(gcAssistBytes),分配得越快,gcAssistBytes越大,这个指数乘以全局的”负载汇率”(assistWorkPerByte),就得到这个G需要帮忙Marking的内存大小(这个计算过程叫revise),也就是它在本次分配的mutator assists工作量(gcAssistAlloc)。Mark Termination - STW
标记阶段的最后工作是Mark Termination,关闭内存屏障,停止后台标记以及辅助标记,做一些清理工作,整个过程也需要STW,大概需要60-90微秒。在此之后,所有的P都能继续为应用程序G服务了。Sweeping - Concurrent
在标记工作完成之后,剩下的就是清理过程了,清理过程的本质是将没有被使用的内存块整理回收给上一个内存管理层级(mcache -> mcentral -> mheap -> OS),清理回收的开销被平摊到应用程序的每次内存分配操作中,直到所有内存都Sweeping完成。当然每个层级不会全部将待清理内存都归还给上一级,避免下次分配再申请的开销,比如Go1.12对mheap归还OS内存做了优化,使用NADV_FREE延迟归还内存。关于 GC 触发阈值
对应关系如下:- GC开始时内存使用量:GC trigger;
- GC标记完成时内存使用量:Heap size at GC completion;
- GC标记完成时的存活内存量:图中标记的Previous marked heap size为上一轮的GC标记完成时的存活内存量;
- 本轮GC标记完成时的预期内存使用量:Goal heap size;
存在问题
- GC Marking - Concurrent 阶段,这个阶段有三个问题:
a. GC 协程和业务协程是并行运行的,大概会占用 25% 的CPU,使得程序的吞吐量下降;
b. 如果业务 goroutine 分配堆内存太快,导致 Mark 跟不上 Allocate 的速度,那么业务 goroutine 会被招募去做协助标记,暂停对业务逻辑的执行,这会影响到服务处理请求的耗时。
c. GO GC 在稳态场景下可以很好的工作,但是在瞬态场景下,如定时的缓存失效,定时的流量脉冲,GC 影响会急剧上升。一个典型例子:IO 密集型服务 耗时优化 - GC Mark Prepare、Mark Termination - STW 阶段,这两个阶段虽然按照官方说法时间会很短,但是在实际的线上服务中,有时会在 trace 图中观测到长达十几 ms 的停顿,原因可能为:OS 线程在做内存申请的时候触发内存整理被“卡住”,Go Runtime 无法抢占处于这种情况的 goroutine ,进而阻塞 STW 完成。(内存申请卡住原因:HugePage配置不合理)
过于关注 STW 的优化,带来服务吞吐量的下降(高峰期内存分配和 GC 时间的 CPU 占用超过 30% );
性能问题之 GC
这里谈一下 GC 的问题,或者说内存管理的问题。
内存管理包括了内存分配和垃圾回收两个方面,对于 Go 来说,GC 是一个并发 - 标记 - 清除(CMS)算法收集器。但是需要注意一点,Go 在实现 GC 的过程当中,过多地把重心放在了暂停时间——也就是 Stop the World(STW)的时间方面,但是代价是牺牲了 GC 中的其他特性。
我们知道,GC 有很多需要关注的方面,比如吞吐量——GC 肯定会减慢程序,那么它对吞吐量有多大的影响;还有,在一段固定的 CPU 时间里可以回收多少垃圾;另外还有 Stop the World 的时间和频率;以及新申请内存的分配速度;还有在分配内存时,空间的浪费情况;以及在多核机器下,GC 能否充分利用多核等很多方面问题。非常遗憾的是,Golang 在设计和实现时,过度强调了暂停时间有限。但这带来了其他影响:比如在执行的过程当中,堆是不能压缩的,也就是说,对象也是不能移动的;还有它也是一个不分代的 GC。所以体现在性能上,就是内存分配和 GC 通常会占用比较多 CPU 资源。
我们有同事进行过一些统计,很多微服务在晚高峰期,内存分配和 GC 时间甚至会占用超过 30% 的 CPU 资源。占用这么高资源的原因大概有两点,一个是 Go 里面比较频繁地进行内存分配操作;另一个是 Go 在分配堆内存时,实现相对比较重,消耗了比较多 CPU 资源。比如它中间有 acquired M 和 GC 互相抢占的锁;它的代码路径也比较长;指令数也比较多;内存分配的局部性也不是特别好。- 由于 GC 不分代,每次 GC 都要扫描全量的存活对象,导致 GC 开销较高。(解决方式:GO 的分代 GC)
优化
强烈建议阅读官方这篇 Go 垃圾回收指南(翻译)
目标
- 降低 CPU 占用;
- 降低服务接口延时;
方向
- 降低 GC 频率;
- 减少堆上对象数量;
问题:为什么降低 GC 频率可以改善延迟
那么关键的一点是,降低 GC 频率也可能会改善延迟。 这不仅适用于通过修改调整参数来降低 GC 频率,例如增加 GOGC 和/或内存限制,还适用于优化指南中描述的优化。
然而,理解延迟通常比理解吞吐量更复杂,因为它是程序即时执行的产物,而不仅仅是成本的聚合之物。 因此,延迟和GC频率之间的联系更加脆弱,可能不那么直接。 下面是一个可能导致延迟的来源列表,供那些倾向于深入研究的人使用。
- 当 GC 在标记和扫描阶段之间转换时,短暂的 stop-the-world 暂停
- 调度延迟是因为 GC 在标记阶段占用了 25% 的 CPU 资源
- 用户 goroutine 在高内存分配速率下的辅助标记
- 当 GC 处于标记阶段时,指针写入需要额外的处理(write barrier)
- 运行中的 goroutine 必须被暂停,以便扫描它们的根。
手段
sync.pool
原理: 使用 sync.pool() 缓存对象,减少堆上对象分配数;
Pool's purpose is to cache allocated but unused items for later reuse, relieving pressure on the garbage collector.
注意: sync.pool 是全局对象,读写存在竞争问题,因此在这方面会消耗一定的 CPU,但之所以通常用它优化后 CPU 会有提升,是因为它的对象复用功能对 GC 和内存分配带来的优化,因此 sync.pool 的优化效果取决于锁竞争增加的 CPU 消耗与优化 GC 与内存分配减少的 CPU 消耗这两者的差值;
设置 GOGC 参数(go 1.19 之前)
原理: GOGC 默认值是 100,也就是下次 GC 触发的 heap 的大小是这次 GC 之后的 heap 的一倍,通过调大 GOGC 值(gcpercent)的方式,达到减少 GC 次数的目的;
公式:gc_trigger = heap_marked * (1+gcpercent/100)
heap_marked:上一个 GC 中被标记的(存活的)字节数;
gcpercent:通过 GOGC 来设置,默认是 100,也就是当前内存分配到达上次存活堆内存 2 倍时,触发 GC;在 go 1.19 及之后,这个公式变为了 heap_marked + (heap_marked + GC roots) * gcpercent / 100
GC roots:全局变量和goroutine的栈
存在问题: GOGC 参数不易控制,设置较小提升有限,设置较大容易有 OOM 风险,因为堆大小本身是在实时变化的,在任何流量下都设置一个固定值,是一件有风险的事情。
ballast 内存控制(go 1.19 之前)
原理: 仍然是从利用了下次 GC 触发的 heap 的大小是这次 GC 之后的 heap 的一倍这一原理,初始化一个生命周期贯穿整个 Go 应用生命周期的超大 slice,用于内存占位,增大 heap_marked 值降低 GC 频率;实际操作有以下两种方式
公式:gc_trigger = heap_marked * (1+gcpercent/100)
gcpercent:通过 GOGC 来设置,默认是 100,也就是当前内存分配到达上次存活堆内存 2 倍时,触发 GC;
heap_marked:上一个 GC 中被标记的(存活的)字节数;
相比于设置 GOGC 的优势
- 安全性更高,OOM 风险小;
- 效果更好,可以从 pprof 图看出,后者的优化效果更大;
负面考量
问:虽然通过大切片占位的方式可以有效降低 GC 频率,但是每次 GC 需要扫描和回收的对象数量变多了,是否会导致进行 GC 的那一段时间产生耗时毛刺?
答:不会,GC 有两个阶段 mark 与 sweep,unused_objects 只与 sweep 阶段有关,但这个过程是非常快速的;mark 阶段是 GC 时间占用最主要的部分,但其只与当前的 inuse_objects 有关,与 unused_objects 无太大关系;因此,综上所述,降低频率确实会让每次 GC 时的 unused_objects 有所增长,但并不会对 GC 增加太多负担;
关于 ballast 内存控制更详细的内容:https://blog.twitch.tv/en/201...
以上三种优化操作的相关实践: Go 语言-计算密集型服务 性能优化
GCTuner(go 1.19 之前)
原理: https://eng.uber.com/how-we-s...
实现: https://github.com/bytedance/...
简述: 同上文讲到的设置 GOGC 参数的思路相同,但增加了自动调整的设计,而非在程序初始设置一个固定值,可以有效避免高峰期的 OOM 问题。
优点: 不需要修改 GO 源码,通用性较强;
缺点: 对内存的控制不够精准。
GO SetMemoryLimit(go 1.19 及之后)
原理: https://github.com/golang/pro...
简述:
通过对 Go 使用的内存总量设置软内存限制来调整 Go 垃圾收集器的行为。
此选项有两种形式:runtime/debug调用的新函数SetMemoryLimit和GOMEMLIMIT环境变量。总之,运行时将尝试通过限制堆的大小并通过更积极地将内存返回给底层平台来维持此内存限制。这包括一种有助于减轻垃圾收集死循环的机制。最后,通过设置GOGC=off,Go 运行时将始终将堆增长到满内存限制。
这个新选项使应用程序可以更好地控制其资源经济性。它使用户能够:
- 更好地利用他们已经拥有的内存;
- 自信地降低他们的内存限制,知道 Go 会遵守他们;
- 避免使用不受支持的 GC 调整方式;
效果测试:https://colobu.com/2022/06/20...
其他:
- 与 GCTuner 的区别:
a. 两者都是通过调节 heapgoal 和 gctrigger 的值(GC 触发阈值),达到影响 GC 触发时机的目的;
b. GCTuner 对于 heapgoal 值的调整,依赖 SetFinalizer 的执行时机,在执行时通过设置 GOGC 参数间接调整的,在每个 GC 周期时最多调整一次;而 SetMemoryLimit 是一直在实时动态调整的,在每次检查是否需要触发GC的时候重新算的,不仅是每一轮 GC 完时决定,因此对于内存的控制更加精准。 - 对内存的控制非常精准,可以关注到所有由 runtime 管理的内存,包括全局 Data 段、BSS 段所占用的内存;goroutine 栈的内存;被GC管理的内存;非 GC 管理的内存,如 trace、GC 管理所需的 mspan 等数据结构;缓存在 Go Heap 中没有被使用,也暂时未归还操作系统的内存;
- 一般配合 GOGC = off 一起使用,可以达到最好的效果。
Bigcache
原理: https://colobu.com/2019/11/18...
会在内存中分配大数组用以达到 0 GC 的目的,并使用 map[int]int,维护对对象的引用;
当 map 中的 key 和 value 都是基础类型时,GC 就不会扫到 map 里的 key 和 value
存在问题: 由于大数组的存在,会起到同 ballast 内存控制手段的效果,一定程度上会影响到 GC 频率;
相关实践: IO 密集型服务 耗时优化
fastcache
与 bigcache 类似,但使用 syscall.mmap 申请堆外内存,避免了像 bigcache 影响 GC 的问题;
堆外分配
绕过 Go runtime 直接分配内存,使 runtime 感知不到此块内存,从而不增加 GC 开销。
- fastcache:直接调用 syscall.mmap 申请堆外内存使用;
- offheap:使用 cgo 管理堆外内存;
问题:管理成本高,灵活性低;
GAB(Goroutine allocation buffer)
优化对象分配效率,并减少 GC 扫描的工作量。
经过调研发现,很多微服务进行内存分配时,分配的对象大部分都是比较小的对象。基于这个观测,我们设计了 GAB(Goroutine allocation buffer)机制,用来优化小对象内存分配。Go 的内存分配用的是 tcmalloc 算法,传统的 tcmalloc,会为每个分配请求执行一个比较完整的 malloc GC 方法,而我们的 Gab 为每个 Goroutine 预先分配一个比较大的 buffer,然后使用 bump-pointer 的方式,为适合放进 Gab 里的小对象来进行快速分配。我们算法和 tcmalloc 算法完全兼容,而且它的分配操作可以随意被 Stop the world 打断。虽然我们的 Gab 优化可能会造成一些空间浪费,但是在很多微服务上测试后,发现 CPU 性能大概节省了 5% 到 12%。
Go1.20 arena
官方文档: https://github.com/golang/go/...
简述: 可以经由 runtime 申请内存,但由用户手动管理此块堆内存。因为是经由 runtime 申请的,可以被 runtime 感知到,因此可以纳入 GC 触发条件中的内存计算里,有效降低 OOM 风险。
其他
Java 中如何优化 GC:ZGC在去哪儿机票运价系统实践、Java中9种常见的CMS GC问题分析与解决