Golang GC浅析和性能分析工具pprof的结合使用
注:下文以最常用的1.14版本分析,可能有部分原理不具备时效性
一,GC的基本定义与常用实现方式
1,基本定义:
GC——全称 Garbage Collection
,即垃圾回收,是一种自动内存管理的机制。
堆和栈:
在程序中,栈可被简单的认为是函数内局部变量所存储的地方,每个函数都有自己独特的一个栈,在函数运行完之后就会被回收。
而堆则是存放一些常期需要存活数据的地方,若有GC策略,堆内存则根据需要自动进行回收。
(值得一提的是,在golang里对于一个变量是否分配在栈上还是堆上会进行逃逸分析,这是golang对内存管理的一个优化,有兴趣的同学可以搜搜)
赋值器和回收器:
GC的实现可抽象为两个组件:赋值器和回收器
-
赋值器(Mutator):可被认为是维护和修改对象之间引用关系的组件。因为对GC而言,用户态的代码仅仅只是在修改对象之间的引用关系。而GC回收的一系列判定操作都是通过对象之间的引用关系而来的。
-
回收器(Collector):根据对象之间的引用维护自己的标记(引用计数法是计数,标记清除法则是标记染色),并负责根据标记执行GC。
-
根据不同的GC方式的实现细节,赋值器和回收器两个分别的数量都可能是不一定的
-
赋值器和回收器分别维护两张不一样的对象图,只是说最终会达到一致而已。
2,为何要有GC:
GC主要是为了提高开发代码的效率,程序的稳定,像C++这类没有GC的语言,代码编写不当会经常容易出现内存泄漏的情况。
一方面,程序员受益于 GC,无需操心、也不再需要对内存进行手动的申请和释放操作,GC 在程序运行时自动释放残留的内存。另一方面,GC 对程序员几乎不可见,仅在程序需要进行特殊优化时,通过提供可调控的 API,对 GC 的运行时机、运行开销进行把控的时候才得以现身。
3,基本实现方式:
基本上,GC从大的定义来说可以分为以下两类:
-
追踪式 GC
追踪式GC采用有向图的方式记录和管理堆(heap)中的所有对象。通过这种方式确定哪些对象是可达的,哪些对象是不可达的。从根对象出发,根据对象之间的引用信息,一步步推进直到扫描完毕整个堆并确定需要保留的对象,从而回收所有可回收的对象。Go、 Java的实现等均为追踪式 GC。根对象在追踪式GC的术语中又叫做根集合,它是垃圾回收器在标记过程时最先检查的对象,不通的语言和编译器都有不同的规定,但基本上是将变量和运行栈空间作为根(注意:是集合,许多人都会误认为GC中的根是一个单点,但其实根是一个笼统的称呼,实际上GC会根据根集合的所有对象来进行GC的回收)
在追踪式GC下,GC又可为标记清除法,分代法等等:
-
标记清除法:从根对象出发,将确定存活的对象进行标记,并清扫可以回收的对象。分为标记和清除两个阶段:
-
标记:从根对象出发,将确定存活的对象进行标记为不可回收
-
清除:将没有标记的对象进行回收
-
-
-
分代法:象根据存活时间的长短进行分类,存活时间小于某个值的为年轻代,存活时间大于某个值的为老年代,永远不会参与回收的对象为永久代。并根据分代假设(如果一个对象存活时间不长则倾向于被回收,如果一个对象已经存活很长时间则倾向于存活更长时间)对对象进行回收。
-
引用计数式 GC
每个对象自身包含一个被引用的计数器,当计数器归零时自动得到回收。因为此方法缺陷较多,在追求高性能时通常不被应用。Python、Objective-C,PHP 等均为引用计数式 GC。
4,QA:
(1)在标记清除法中,赋值器和回收器的关系是什么?
-
赋值器可简单的认为是用户的代码。负责修改对象图中对象之间的引用关系;像标记,清除这些操作,都是由回收器(GC的代码)来执行的,回收器是无权修改对象之间的引用关系的。
-
不要误认为是赋值器执行标记这一阶段,回收器执行清理这一阶段。
(1)标记清理法最大的缺点和难点是什么?它是如何实现正确的内存回收的?
-
由于赋值器会不断修改对象图中各个对象的引用关系,这时候如果回收器想要进行GC标记清理操作的话,必然会有一个停顿时间,用于赋值器的停止,并让回收器进行内存的回收,若没有这个时间,回收器将有可能错误回收或没回收对象。
-
这一个停顿时间叫STW:可以是
Stop the World
的缩写,也可以是Start the World
的缩写。通常意义上指指代从Stop the World
这一动作发生时到Start the World
这一动作发生时这一段时间间隔,即万物静止。STW 在垃圾回收过程中是为了保证实现的正确性、防止无止境的内存增长等问题而不可避免的需要停止赋值器进一步操作对象图的一段过程。在这个过程中整个赋值器被停止或者放缓执行,STW
越长,对赋值器造成的影响(例如延迟)就越大。 -
因此,标记清除法最大的缺点和难点就在于STW的时间上,在golang中,为了缩短STW和提高用户代码执行的效率,采用了让赋值器和回收器并发运行的方式, 等于是将回收器进行标记清除这一步骤提前了一部分。
-
同时,为了解决并发下,内存的正确回收,golang会采用一种叫做混合写屏障+三色标记的方式。
二,Golang GC设计原理和基本过程
1,golang使用的GC实现方式:
golang使用的GC方法,其实是标记清除法的一种变形,它被官方誉为是非分代,非紧缩,并发的三色标记清理算法
非分代: 对象没有老年代,新生代之分,因为分代法的GC的基本依据是分代假设,即越新的对象会越容易被回收。但Golang有逃逸分析,通过这种分析会将大部分新生对象都会放在栈内,函数一运行完就回收了,自然不用进行复杂的分代处理。
非紧缩: 因为golang使用的是基于 tcmalloc 的现代内存分配算法,对对象进行整理不会带来实质性的性能提升。(tcmalloc的原理和策略比较复杂,有兴趣的可以去了解golang的内存管理,大概原理是将内存碎片通过链表的方式来连接起来处理,而不是单纯的整理紧缩)
并发: 如上所说,回收与用户代码并发执行,不会等到用户代码全部执行完再去进行标记和清理操作,实际上golang为了达到回收器和赋值器的并发,采用了一种名叫混合写屏障的技术
三色标记清理法:
-
三色标记清理法实际上是将对象分为以下三种不同的颜色:
- 白色对象(可能死亡):未能被根对象访达的对象。在回收开始阶段,所有对象均为白色,当回收结束后,白色对象均不可达。
- 灰色对象(波面):能被根对象访达的对象,但仍然需要对其中的一个或多个指针进行扫描,因为他们可能还指向白色对象。
- 黑色对象(确定存活):能被根对象访问到的对象,其中所有字段都已被扫描,黑色对象中任何一个指针都不可能直接指向白色对象。
-
示例图:
- 整个GC过程,可抽象看作是两个波面边界不断前进的过程,通过不断标记染色回收,使最后对象只有黑色和白色,最终对白色对象进行回收
2,golang GC的具体实现流程(抽象描述):
(1)标记:
- 一开始,所有对象都是白色的,当标记开始时,会将根对象染成灰色,表示能访达,但对于回收器来说,它们对其他对象的引用还不明确
- 从灰色对象中,挑选一个染成黑色,同时将其引用的对象置灰,表示该对象能访达,已明确引用关系,但是它引用的对象的引用还不明确
- 重复2步骤直至只有黑色对象和白色对象
(2)回收:当染色完成之后,将白色对象进行回收,将黑色对象再次染白。
3,golang GC的具体实现细节(具体描述)
(1)三色标记清除法中”三色“的具体含义:
-
在三色标记清除法中,可以证明,当以下两个条件同时满足时会破坏回收器的正确性:
- 条件 1: 赋值器修改对象图,导致某一黑色对象引用白色对象;
- 条件 2: 从灰色对象出发,到达白色对象的、未经访问过的路径被赋值器破坏。
-
只要能够避免其中任何一个条件,则不会出现对象丢失的情况,因为:
- 如果条件 1 被避免,则所有白色对象均被灰色对象引用,没有白色对象会被遗漏;
- 如果条件 2 被避免,即便白色对象的指针被写入到黑色对象中,但从灰色对象出发,总存在一条没有访问过的路径,从而找到到达白色对象的路径,白色对象最终不会被遗漏。
-
能避免条件2即称为弱三色不变性,能同时避免条件1,2则称为强三色不变性
-
golang保证的其实就是弱三色不变性
(2)在golang,赋值器也可以分作不同的颜色:
-
黑色赋值器:已经由回收器扫描过,不会再次对其进行扫描。
-
灰色赋值器:尚未被回收器扫描过,或尽管已经扫描过但仍需要重新扫描。
(3)写屏障:实际上是指赋值器的写屏障,赋值器的写屏障作为一种同步机制,使赋值器在对对象的引用进行操作时,能够“通知”回收器,进而不会破坏弱三色不变性。
写屏障(write barrier)的实际命名含义为,在赋值器写(修改引用)之前,对条件进行约束(给写加上屏障),以维持三色不变性
实际上,有两种非常著名的写屏障,Dijkstra插入屏障和Yuasa删除写屏障,下面仅描述它们在golang中的实现方式
-
Dijkstra插入屏障:
-
golang早期使用的方式,在赋值器增添新的引用的时候,通知回收器将引用的对象染成灰色,这样的话能保证一个对象被引用了之后,绝对不会变成白色,从而保证强三色不变性
-
由于栈上的引用是经常变的,golang又把栈上的对象当作根对象,这时候如果每次都要使用写屏障的话,会带来很大的消耗,因此在golang中,对栈上的插入写屏障处理是特殊的。Go选择仅对堆上的指针插入增加写屏障。
-
这样为了保证三色不变形,就需要说在标记结束后,需要对栈上的对象再扫描一次,重新染色。
-
具体抽象示意图:(注意,回收器collector和赋值器mutator是并发进行的)
-
- 步骤
- 回收器将根对象指向 A 对象标记成黑色并将 A 对象指向的对象 B 标记成灰色;
- 赋值器修改 A 对象的指针,将原本指向 B 对象的指针指向 D 对象,这时触发写屏障将 CD对象标记成灰色;
- 回收器依次遍历程序中的其他灰色对象,将它们分别标记成黑色;
- **GC完后,对象B会在下一次的标记流程中,被标记为白色, 从而被回收****
**
- **若是上图皆为栈上的对象,则不对回收器进行任何通知,等到最终标记结束之后,再对该赋值器STW,对对象图重新扫描一遍,从而确定需要回收的对象。**
- 实际在go1.7 的时候选择的是将栈标记为恒灰,意思是无论如何在结束之后都要扫描栈的赋值器
- 之所以叫“插入”写屏障,其实意思是“插入”新的指向的时候进行染色,如上图的D被插入了A的指向
- golang的插入写屏障从上可得知,其耗时在于栈对象的重新扫描上
-
Yuasa删除写屏障:
-
golang从未单独实现过删除写屏障,这里指描述其最普遍的实现流程:
-
其做法是每当有引用被删除的时候,对原来的对象进行置灰。但有个很重要的前提,有一个前提条件,就是起始的时候,把整个根部扫描一遍,让所有的可达对象全都在灰色保护下
-
抽象示意图:
-
- 步骤:
- 回收器将根对象指向 A 对象标记成黑色并将 A 对象指向的对象 B 标记成灰色;
- 赋值器将 A 对象原本指向 B 的指针指向 C,触发删除写屏障,将改动通知给回收器,但是因为 B 对象已经是灰色的,所以不做改变;
- 赋值器将 B 对象原本指向 C 的指针删除,触发删除写屏障,将改动通知给回收器,白色的 C 对象被涂成灰色;
- 垃圾收集器依次遍历程序中的其他灰色对象,将它们分别标记成黑色;
- 之所以叫”删除“写屏障,其实意思是删除指向的时候,对对象进行染色。
- 插入屏障和删除屏障各有优缺点,Dijkstra的插入写屏障在标记开始时无进行扫描,可直接开始,并发进行,但结束时需要重新扫描栈;Yuasa的删除写屏障则需要在GC开始时扫描堆栈来记录初始快照,这个过程会保护开始时刻的所有存活对象,但在结束的时候不需要重新扫描栈
(3)混合写屏障:golang目前使用的写屏障策略
-
定义:golang实际上是将 Dijkstra 插入屏障和 Yuasa 删除屏障进行混合,并对进行自身的改进
-
其大致思想是对引用双方进行着色,无论是指向的还是被指向的,从而综合了两者的优点,在官方被描述为对正在被覆盖的对象进行着色,且如果当前栈未扫描完成, 则同样对指针进行着色
-
伪代码:
func HybridWritePointer(slot *unsafe.Pointer, ptr unsafe.Pointer) {
shade(*slot)
if (current is grey)
{
shade(ptr)
}
*slot = ptr
}
-
具体步骤:
- GC开始将栈上的对象全部扫描并标记为黑色,只扫描栈,不扫描堆
- GC期间,任何在栈上创建的新对象,均为黑色。
- 被删除的对象标记为灰色。
- 被添加的对象标记为灰色。
- 其实从根本上还是对栈进行了特殊处理
-
通过上述方式,能够将原来插入写屏障的对栈的rescan给去除
(4)至此,golang GC的流程和实现细节已经很明确如下:
回收器阶段 | 说明 | 赋值器状态 |
---|---|---|
SweepTermination | 清扫终止阶段,为下一个阶段的并发标记做准备工作(todo:准备会干啥) | STW |
Mark | 扫描标记阶段,与赋值器并发执行,写屏障开启 | 并发 |
MarkTermination | 标记终止阶段,保证一个周期内标记任务完成 | STW |
GCoff | 内存清扫阶段,将需要回收的内存归还到堆中 | 并发 |
GCoff | 内存归还阶段,将过多的内存归还给操作系统 | 并发 |
(WB是write barrier)
4,QA:
(1)触发golang gc的时机是什么?
-
主动触发,通过调用 runtime.GC 来触发 GC,此调用阻塞式地等待当前 GC 运行完毕。
-
被动触发,分为两种方式:
-
使用系统监控,当超过两分钟没有产生任何 GC 时,强制触发 GC。
-
使用步调(Pacing)算法,其核心思想是控制内存增长的比例。
-
(2) 有了 GC,为什么还会发生内存泄露?
在 Go 中,由于 goroutine 的存在,所谓的内存泄漏除了附着在长期对象上之外,还存在多种不同的形式。
形式1:预期能被快速释放的内存因被根对象引用而没有得到迅速释放
当有一个全局对象时,可能不经意间将某个变量附着在其上,且忽略的将其进行释放,则该内存永远不会得到释放。例如:
var cache = map[interface{}]interface{}{}
func keepalloc() {
for i := 0; i < 10000; i++ {
m := make([]byte, 1<<10)
cache[i] = m
}
}
形式2:goroutine 泄漏
Goroutine 作为一种逻辑上理解的轻量级线程,需要维护执行用户代码的上下文信息。在运行过程中也需要消耗一定的内存来保存这类信息,而这些内存在目前版本的 Go 中是不会被释放的。因此,如果一个程序持续不断地产生新的 goroutine、且不结束已经创建的 goroutine 并复用这部分内存,就会造成内存泄漏的现象,例如:
func keepalloc2() {
for i := 0; i < 100000; i++ {
go func() {
select {}
}()
}
}
(3)golang的标记清理分三色而不是传统标记清除法两色原因?
- 通过弱三色不变性理论和写屏障的结合,能保证并发下内存回收的正确与稳定。
三,Golang GC原理和pprof工具在实际代码中的结合使用
1,实际业务中的使用
由于golang的GC设计的十分完善,我们在实际工作中可能碰到的GC相关的问题会很少,但在一些代码编写不当的极端场景下,可能会导致GC占用大量的CPU或者内存一直没有得到回收,从而导致线上机器的宕机,另外,从GC的方式来说,我们可以通过分析一些GC的函数,来更进一步的优化程序的性能,从而到达节省资源的目的。这里推荐使用pprof工具,来对GC进行分析和调优。
2,一般来说会与GC有关的线上问题
(1)内存泄漏导致的内存告警:
如上所说的,如果有内存泄漏,将有可能导致机器上的内存永远不会被回收,从而导致内存的告警
根据命令,得出的内存图(这里由于不是线上服务,测试案例不好写,可看作是):
可看出,是keepalloc这一函数所占的内存过多,即可对其进行优化。
实际的代码:
package main
import (
"net/http"
_ "net/http/pprof"
)
var cache = sync.Map
func keepalloc() {
for i := 0; i < 10000; i++ {
m := make([]byte, 1<<10)
cache.Store(i,i)
}
}
func main() {
go func() {
http.ListenAndServe("localhost:6060", nil)
}()
http.HandleFunc("/example2", func(w http.ResponseWriter, r *http.Request) {
keepalloc()
})
http.ListenAndServe(":8080", nil)
}
(2)频发GC导致的CPU利用率过高
package main
import (
"fmt"
"net/http"
_ "net/http/pprof"
)
func newBuf() []byte {
return make([]byte, 10<<20)
}
func main() {
go func() {
http.ListenAndServe("localhost:6060", nil)
}()
http.HandleFunc("/example2", func(w http.ResponseWriter, r *http.Request) {
b := newBuf()
// 模拟执行一些工作
for idx := range b {
b[idx] = 1
}
fmt.Fprintf(w, "done, %v", r.URL.Path[1:])
})
http.ListenAndServe(":8080", nil)
}
因为是频繁触发了GC,所以肯定就是有内存分配过多了,通过pprof工具分析可得,是newBuf函数分配的内存过多
所以我们就可以通过sync.pool的方式,来达到内存的复用:sync.pool可以相当于提前分配好一定量的内存,以供别人使用,这样就不用说每次都申请分配内存
package main
import (
"fmt"
"net/http"
_ "net/http/pprof"
"sync"
)
// 使用 sync.Pool 复用需要的 buf
var bufPool = sync.Pool{
New: func() interface{} {
return make([]byte, 10<<20)
},
}
func main() {
go func() {
http.ListenAndServe("localhost:6060", nil)
}()
http.HandleFunc("/example2", func(w http.ResponseWriter, r *http.Request) {
b := bufPool.Get().([]byte)
for idx := range b {
b[idx] = 0
}
fmt.Fprintf(w, "done, %v", r.URL.Path[1:])
bufPool.Put(b)
})
http.ListenAndServe(":8080", nil)
}
3,总结来说,golang GC的调优和查看问题,可以从这些方面出发
-
对全局变量和goroutine要有认识,避免出现内存泄漏
-
减少并复用内存,例如使用 sync.Pool 来复用需要频繁创建临时对象,例如提前分配足够的内存来降低多余的拷贝。
-
控制内存分配的速度,限制 goroutine 的数量,从而提高赋值器对 CPU 的利用率。(待举例子)
当然,我们还应该谨记 过早优化是万恶之源这一警语,在没有遇到应用的真正瓶颈时,将宝贵的时间分配在开发中其他优先级更高的任务上。
转载自:https://juejin.cn/post/7350985823078875187