GC
- 垃圾回收(Garbage Collection),是自动管理内存的机制。在 C/C++
中,开发者需要手动处理自己的内存管理与申请与回收,由于开发者对操作系统和语言的理解问题会导致浪费内存空间和悬挂指针的问题。因此许多新兴语言引入了语言层面的自动内存管理,开发者无需对内存进行手动释放,内存释放由 虚拟机(virtual machine)或者运行时(runtime) 来对不再使用的内存资源进行自动回收。其中 golang 选择使用 runtime 处理这件事。
golang 的内存分配有 堆 (Heap) 栈 (Stack) 的概念,不同于 C++
的设计理念让开发者自行处理变量是应该开在栈上还是堆上,golang 处理了自动化策略,写了自己的一套 逃逸分析(Escape analysis)。
其中有两点原则可能可以确定自己变量的位置:
利用 RLIMIT_STACK
环境变量可以自行指定线程栈大小
因为开发者无法自行处理内存的分配与释放,golang 的逃逸分析需要自动化处理 gc 问题。
需要注意的是用户 无法 自行将虚拟内存 RSS 归还给 system 而且在 golang 中 gc 和回收到系统是有 gap 的,作为两个操作在执行,可能会导致一些内存占用率不符合预期的问题。
这里不会探讨关于逃逸分析相关问题,如果想了解推荐阅读
内存分配相关的问题推荐阅读,一些小细节比如不同大小的对象内存分配的不同点还是挺有意思的
注意:golang 到目前的版本(1.15)仍然在优化自己的 gc 方式及效率,如果需要使用相应的版本希望还是了解对应的历史遗留问题,即使是当前也不建议自行处理内存相关问题,因为 golang 没有提供任何开发者与系统直接交互内存申请的方式
runtime.GC
, debug.SetGCPercent
, 和 debug.FreeOSMemory
都不能触发并发GC,他们触发的GC都是阻塞的,go1.9可以了,变成了在垃圾回收之前只阻塞调用GC的goroutine。(2)debug.SetGCPercent
只在有必要的情况下才会触发GC。如果不需要知道具体实现已经针对内存占用量问题进行调优,只是简单使用 golang 进行开发可以简单的对其了解。
C++ 中智能指针的设计采用 引用计数 的方式进行:
shared_ptr
引用计数清空的时候它就会递归调用析构函数并将内存交还给系统 RSSgolang 中的 gc 则做到了开发者无感知:
func sz(x string) *int {
return &len(x)
}
其中的“临时变量” len(x)
在正确的使用后仍然会存在与内存中,且在不用到它之后的某个时间点会自动释放掉交还给系统
如果使用 pprof
或者 GODEBUG=gctrace=1 go run main.go
可以看到它的释放周期和与系统交互的相关内容
简单来说分为几个步骤:
阶段 | 说明 | 赋值器状态 |
---|---|---|
SweepTermination | 清扫终止(标记开始)阶段 | STW |
Mark | 三色标记阶段 | 并发 |
MarkTermination | 标记终止阶段 | STW |
GCoff | 内存清扫阶段 | 并发 |
GCoff | 内存归还阶段 | 并发 |
STW 可以是Stop The World的缩写,也可以是Start The World的缩写。通常意义上指的是从Stop The World到Start The World这一段时间间隔。垃圾回收过程中为了保证准确性、防止无止境的内存增长等问题而不可避免的需要停止赋值器进一步操作对象图以完成垃圾回收。STW时间越长,对用户代码造成的影响越大。
下面具体介绍每个部分的过程与细节
在 golang 源码中可以找到 src/runtime/mem_linux.go
,src/runtime/mgc.go
,src/runtime/mgcsavenge.go
可以方便理解和阅读。
所谓三色是指:
标记过程如下:
起初所有的对象都是白色的;
从根对象出发扫描所有可达对象,标记为灰色,放入待处理队列;
从待处理队列中取出灰色对象,将其引用的对象标记为灰色并放入待处理队列中,自身标记为黑色;
重复步骤 3,直到待处理队列为空,此时白色对象即为不可达的“垃圾”,回收白色对象;
根对象在垃圾回收的术语中又叫做根集合,它是垃圾回收器在标记过程时最先检查的对象,包括以下内容:
假设下面的场景,已经被标记为灰色的对象2,未被标记的对象3被对象2用指针p引用;此时已经被标记为黑色的对象4创建指针q 指向未被标记的对象3,同时对象2将指针p移除;对象4已经被标记为黑色,对象3未被引用,对象2删除与对象3的引用,导致最后对象3被误清除
可能的解决方法: 整个过程STW,浪费资源,且对用户程序影响较大,由此引入了屏障机制
把回收器视为对象,把赋值器视为影响回收器这一对象的实际行为(即影响 GC 周期的长短),从而引入赋值器的颜色:
写入前,对指针所要指向的对象进行着色
// 灰色赋值器 Dijkstra 插入屏障
func DijkstraWritePointer(slot *unsafe.Pointer, ptr unsafe.Pointer) {
shade(ptr) //先将新下游对象 ptr 标记为灰色
*slot = ptr
}
//说明:
添加下游对象(当前下游对象slot, 新下游对象ptr) {
//step 1
标记灰色(新下游对象ptr)
//step 2
当前下游对象slot = 新下游对象ptr
}
//场景:
A.添加下游对象(nil, B) //A 之前没有下游, 新添加一个下游对象B, B被标记为灰色
A.添加下游对象(C, B) //A 将下游对象C 更换为B, B被标记为灰色
Dijkstra 插入屏障的好处在于可以立刻开始并发标记。但存在两个缺点:
总结特点:在标记开始时无需STW,可直接开始,并发进行,但结束时需要STW来重新扫描栈
写入前,对指针所在对象进行着色
// 黑色赋值器 Yuasa 屏障
func YuasaWritePointer(slot *unsafe.Pointer, ptr unsafe.Pointer) {
shade(*slot) 先将*slot标记为灰色
*slot = ptr
}
//说明:
添加下游对象(当前下游对象slot, 新下游对象ptr) {
//step 1
if (当前下游对象slot是灰色 || 当前下游对象slot是白色) {
标记灰色(当前下游对象slot) //slot为被删除对象, 标记为灰色
}
//step 2
当前下游对象slot = 新下游对象ptr
}
//场景
A.添加下游对象(B, nil) //A对象,删除B对象的引用。B被A删除,被标记为灰(如果B之前为白)
A.添加下游对象(B, C) //A对象,更换下游B变成C。B被A删除,被标记为灰(如果B之前为白)
总结特点:标记结束不需要STW,但是回收精度低,GC 开始时STW 扫描堆栈记录初始快照,保护开始时刻的所有存活对象;且容易产生“冗余”扫描
大大缩短了 STW 时间
// 混合写屏障
func HybridWritePointerSimple(slot *unsafe.Pointer, ptr unsafe.Pointer) {
shade(*slot)
shade(ptr)
*slot = ptr
}
按照堆栈分为四种场景:
Golang 中的混合屏障结合了删除写屏障和插入写屏障的优点,只需要在开始时并发扫描各goroutine的栈,使其变黑并一直保持,标记结束后,因为栈空间在扫描后始终是黑色的,无需进行re-scan,减少了STW 的时间
为了打开写屏障,必须停止每个goroutine,让垃圾收集器观察并等待每个goroutine进行函数调用, 等待函数调用是为了保证goroutine停止时处于安全点。
// 如果goroutine4 处于如下循环中,运行时间取决于slice numbers的大小
func add(numbers []int) int {
var v int
for _, n := range numbers {
v += n
}
return v
}
下面的代码中,由于for{}
循环所在的goroutine 永远不会中断,导致始终无法进入STW阶段,资源浪费;Go 1.14 之后,此类goroutine 能被异步抢占,使得进入STW的时间不会超过抢占信号触发的周期,程序也不会因为仅仅等待一个goroutine的停止而停顿在进入STW之前的操作上。
func main() {
go func() {
for {
}
}()
time.Sleep(time.Milliecond)
runtime.GC()
println("done")
}
一旦写屏障打开,垃圾收集器就开始标记阶段,垃圾收集器所做的第一件事是占用25%CPU。
标记阶段需要标记在堆内存中仍然在使用中的值。首先检查所有现goroutine的堆栈,以找到堆内存的根指针。然后收集器必须从那些根指针遍历堆内存图,标记可以回收的内存。
当存在新的内存分配时,会暂停分配内存过快的那些 goroutine,并将其转去执行一些辅助标记(Mark Assist)的工作,从而达到放缓继续分配、辅助 GC 的标记工作的目的。
关闭写屏障,执行各种清理任务(STW)
清理阶段用于回收标记阶段中标记出来的可回收内存。当应用程序goroutine尝试在堆内存中分配新内存时,会触发该操作,清理导致的延迟和吞吐量降低被分散到每次内存分配时。
清除阶段出现新对象:
清除阶段是扫描整个堆内存,可以知道当前清除到什么位置,创建的新对象判定下,如果新对象的指针位置已经被扫描过了,那么就不用作任何操作,不会被误清除,如果在当前扫描的位置的后面,把该对象的颜色标记为黑色,这样就不会被误清除了
什么时候进行清理?
主动触发(runtime.GC()) 被动触发 (GC百分比、定时)
运行时中有GC 百分比的配置选项,默认情况下为100。此值表示在下一次垃圾收集必须启动之前可以分配多少新内存的比率。将GC百分比设置为100意味着:基于在垃圾收集完成后标记为活动的堆内存量,下次垃圾收集前,堆内存使用可以增加100%。
演示一个GC过程,并输出相关信息,使用GODEBUG
变量生成GC trace
func gcfinished() *int {
p := 1
runtime.SetFinalizer(&p, func(_ *int) {
println("gc finished")
atomic.StoreUint64(&stop, 1)
})
return &p
}
func allocate() {
_ = make([]byte, int((1<<18)))
}
func main() {
f, _ := os.Create("trace.out")
defer f.Close()
trace.Start(f)
defer trace.Stop()
gcfinished()
// 当完成 GC 时停止分配
for n := 1; n < 50; n++ {
println("#allocate: ", n)
allocate()
}
println("terminate")
}
Go 的 GC 被设计为成比例触发、大部分工作与赋值器并发、不分代、无内存移动且会主动向操作系统归还申请的内存。因此最主要关注的、能够影响赋值器的性能指标有:
goroutine 的执行时间占其生命周期总时间非常短的一部分,但大部分时间都花费在调度器的等待上了,说明同时创建大量 goroutine 对调度器产生的压力确实不小,我们不妨将这一产生速率减慢,一批一批地创建 goroutine。
func concat() {
for n := 0; n < 100; n++ {
for i := 0; i < 8; i++ {
go func() {
s := "Go GC"
s += " " + "Hello"
s += " " + "World"
_ = s
}()
}
}
}
//改进
func concat() {
wg := sync.WaitGroup{}
for n := 0; n < 100; n++ {
wg.Add(8)
for i := 0; i < 8; i++ {
go func() {
s := "Go GC"
s += " " + "Hello"
s += " " + "World"
_ = s
wg.Done()
}()
}
wg.Wait()
}
}
newBuf()
产生的申请的内存过多, sync.Pool 是内存复用的一个最为显著的例子
func newBuf() []byte {
return make([]byte, 10<<20)
}
b := newBuf()
//改进
var bufPool = sync.Pool{
New: func() interface{} {
return make([]byte, 10<<20)
},
}
b := bufPool.Get().([]byte)
降低收集器的启动频率(提高GC百分比)无法帮助垃圾收集器更快完成收集工作。降低频率会导致垃圾收集器在收集期间完成更多的工作。 可以通过减少新分配对象数量来帮助垃圾收集器更快完成收集工作
linux 中容器化重要的组件 cgroup 中对 memory 和 cpu 的限制不是通过直接的虚拟化限制,而是超过分配限额时触发 OOM,而 GOGC 的调优实际上是对获取系统内存最大量进行操作的,所以 golang 目前对其的支持并不好,在容器化应用中需要对内存更小心的使用。
原文:https://www.cnblogs.com/badcw/p/14192895.html