jasper的技术小窝

关注DevOps、运维监控、Python、Golang、开源、大数据、web开发、互联网

深入Golang之垃圾回收

作者:jasper | 分类:Golang | 标签:   | 阅读 1766 次 | 发布:2017-12-25 10:12 p.m.

接着上一篇的来说,这里我们来看一下在Golang中垃圾回收的机制。其实Golang的垃圾回收在随着版本的更迭,一直在做调整和优化,这里是基于1.9版本的来分析。

GC概述

首先对于常见的垃圾回收算法做个简单的介绍:

  • 引用计数:这是一种最简单的垃圾回收算法,对每一个对象维护一个应用的计数,当引用该对象的对象被销毁的时候,这个被引用对象的引用计数会自动减一;当有被引用的对象被创建时,计数器加一。当计数器为0的时候,就回收该对象。该GC算法的最大的好处是将内存的管理和用户程序的执行放在一起,这样可以把GC的代价分散到整个程序,不会出现STW;而且可以做到对象很快被回收,不像其他算法在heap被耗尽或者达到某一个阈值才回收。但是缺点是该算法不能处理循环引用;而且在实时地维护引用计数时会一定程度上需要额外资源。其中Python和PHP就是使用的该GC方式。
  • 标记-清除:这是一个很古老的算法了,该算法分为两个步骤,首先从根变量迭代遍历所有被引用的对象,能够访问到的对象会被标记为“被引用”;然后会对没有标记过的进行回收。优点是解决了引用计数的不足。缺点则是需要STW,而且垃圾回收后可能存在大量的磁盘碎片。标记-清除算法后面有了一种变种三色标记法,Golang现在就是使用的该算法,后面我们再细说。
  • 分代收集:分代收集的思想是把heap两个或者多个代空间,新创建的对象存放在称为新生代中,随着垃圾回收的重复执行,生命周期较长的对象会被提升到老年代中,对于新生代的区域的垃圾回收频率要明显高于老年代区域。这样对不同的区域可以使用不用回收算法,这样可以达到更优的性能,但是其缺点是实现太复杂。该算法在JVM的各种GC算法中大量被运用到。

GC的条件

1、其实在上一篇里面说了,在做内存分配的时候,有一些GC的逻辑在里面,这时候我们可以来看看:

func mallocgc(size uintptr, typ *_type, needzero bool) unsafe.Pointer {
   shouldhelpgc := false
    if size <= maxSmallSize{
        // ......
        shouldhelpgc = c.nextFree(xxxx)
    }else{
        shouldhelpgc = true
    }
    if shouldhelpgc {
        if t := (gcTrigger{kind: gcTriggerHeap}); t.test() {
            gcStart(gcBackgroundMode, t)
        }
    }
}

可以看到如果分配的对象大于32kbytes,或者在分配小对象时发现span已经满了的时候,再判断满足gcTrigger的条件(在这里gcTriggerHeap模式下,条件是memstats.heap_live >= memstats.gc_trigger,即是当前堆上的活跃对象大于我们初始化时候设置的 GC 触发阈值)时,会触发一次GC。

而这个阈值的值是怎么来的呢?

trigger := ^uint64(0)
    if gcpercent >= 0 {
        trigger = uint64(float64(memstats.heap_marked) * (1 + triggerRatio))
        minTrigger := heapminimum
        if trigger < minTrigger {
            trigger = minTrigger
        }
    }
    memstats.gc_trigger = trigger

逻辑很清楚不超过minTrigger(这里的minTrigger默认是4MB*GOGC/100,而GOGC默认为100,所有这个minTrigger为4MB),一般情况下肯定超过啦,所以公式就是uint64(float64(memstats.heap_marked) * (1 + triggerRatio))。这里的triggerRatio的实现过程略复杂就不赘述了,其大体结论是根据当前与上次的heap size的比例来决定,默认情况下是GOGC=100,即新增一倍就会触发,通过设大环境变量GOGC可以减少GC的触发,设置"GOGC=off"可以彻底关掉GC。

2、在Golang程序运行时,会启动一个forcegc的helper goroutine:

func init() {
    go forcegchelper()
}

func forcegchelper() {
    forcegc.g = getg()
    for {
        lock(&forcegc.lock)
        if forcegc.idle != 0 {
            throw("forcegc: phase error")
        }
        atomic.Store(&forcegc.idle, 1)
        goparkunlock(&forcegc.lock, "force gc (idle)", traceEvGoBlock, 1)
        if debug.gctrace > 0 {
            println("GC forced")
        }
        gcStart(gcBackgroundMode, gcTrigger{kind: gcTriggerTime, now: nanotime()})
    }
}

那这里goroutine的g是怎么来的呢,其实这来自于sysmon:

func sysmon() {
    // ......
    if t := (gcTrigger{kind: gcTriggerTime, now: now}); t.test() && atomic.Load(&forcegc.idle) != 0 {
            lock(&forcegc.lock)
            forcegc.idle = 0
            forcegc.g.schedlink = 0
            injectglist(forcegc.g)
            unlock(&forcegc.lock)
        }
}

不一样的是这里的gcTrigger的模式为gcTriggerTime,在该模式下需要满足当前时间距离上一次GC时间需大于forcegcperiod,这个值在Golang里面为两分钟var forcegcperiod int64 = 2 * 60 * 1e9

3、除此之外还可以主动调用GC来回收,有两处可以实现:

runtime.GC()
debug.FreeOSMemory()

其中FreeOSMemory底层也是调用的runtime.GC,这里用的gcTrigger的模式为gcTriggerCycle,要求启动新一轮的GC, 已启动则跳过。

GC的运行过程

Golang的GC的过程其实就是一个三色标记法的实现,对于三色标记法,"三色"的概念可以简单的理解为:

  • 白色:还没有搜索过的对象(白色对象会被当成垃圾对象)
  • 灰色:正在搜索的对象
  • 黑色:搜索完成的对象(不会当成垃圾对象,不会被GC)

其过程可以大体总结为:

1、首先创建三个集合:白、灰、黑。
2、将所有对象放入白色集合中。
3、然后从根节点开始遍历所有对象,把遍历到的对象从白色集合放入灰色集合。
4、之后遍历灰色集合,将灰色对象引用的对象从白色集合放入灰色集合,之后将此灰色对象放入黑色集合。
5、重复4直到灰色中无任何对象。
6、通过写屏障检测对象有变化,重复以上操作。
7、回收所有白色对象

这里的写屏障(write barrier)是因为在GC的时候用户代码可以同时运行,这样在扫描的时候,对象的依赖树可能被改变了,为了避免这个问题,Golang在GC中标记阶段会启用写屏障。

下面我在代码层来看一下这个过程,这一过程主要是在函数gcStart,让我们来逐段分析:

一、Sweep Termination:

这一阶段对未清扫的span进行清扫, 只有上一轮的GC的清扫工作完成才可以开始新一轮的GC:

for trigger.test() && gosweepone() != ^uintptr(0) {
    sweep.nbgsweep++
}

// gosweepone函数
func gosweepone() uintptr {
    var ret uintptr
    systemstack(func() {
        ret = sweepone()
    })
    return ret
}

func sweepone() uintptr {
npages := ^uintptr(0)
sg := mheap_.sweepgen
for {
    s := mheap_.sweepSpans[1-sg/2%2].pop()
}
// ...
}

Sweep的具体逻辑如上,内存管理都是基于span的,mheap_ 是一个全局的变量,所有分配的对象都会记录在mheap_ 中。清扫的时候扫描没有标记的span就可以回收了。主要就是上面的pop方法,该方法会从buffer中移除span。

二、Mark:

该阶段扫描所有根对象, 和根对象可以到达的所有对象, 标记它们不被回收:

if mode == gcBackgroundMode {
    gcBgMarkStartWorkers()
}

// gcBgMarkStartWorkers函数为:
func gcBgMarkStartWorkers() {
    for _, p := range &allp {
        if p == nil || p.status == _Pdead {
            break
        }
        if p.gcBgMarkWorker == 0 {
            go gcBgMarkWorker(p)
            notetsleepg(&work.bgMarkReady, -1)
            noteclear(&work.bgMarkReady)
        }
    }
} 

gcBgMarkStartWorkers为每个P启动了一个后台标记任务gcBgMarkWorker,而gcBgMarkWorker的核心代码为:

func gcBgMarkWorker(_p_ *p) {
    // .... 
    for{
    // ....
    systemstack(func() {
        switch _p_.gcMarkWorkerMode {
            // 根据不同的gcMarkWorkerMode执行:
            gcDrain(&_p_.gcw, flag)
        }
    })
    }
}

通过systemstack切换到g0运行,根据不同的Mark模式来执行标记,直到被抢占或者无更多任务或者达到一定量时,需要计算后台的扫描量来减少辅助GC和唤醒等待中的G,这就是真正实现标记的gcDrain函数,这个函数是需要写屏蔽的:

func gcDrain(gcw *gcWork, flags gcDrainFlags) {
    if !writeBarrier.needed {
        throw("gcDrain phase incorrect")
    }

    // 标记根对象
    if work.markrootNext < work.markrootJobs {
        for !(preemptible && gp.preempt) {
            job := atomic.Xadd(&work.markrootNext, +1) - 1
            if job >= work.markrootJobs {
                break
            }
            // 标记根对象的执行函数
            markroot(gcw, job)
            if idle && pollWork() {
                goto done
            }
        }
    }

    for !(preemptible && gp.preempt) {
        //从本地灰色标记队列中获取对象, 获取不到则从全局标记队列获取
        var b uintptr
        if blocking {
            b = gcw.get()
        } else {
            b = gcw.tryGetFast()
            if b == 0 {
                b = gcw.tryGet()
            }
        }
        if b == 0 {
            break
        }
        //扫描灰色对象的引用对象,标记为灰色,入灰色队列
        scanobject(b, gcw)
    }
}

三、Mark termination:

Mark阶段结束之后,我们继续往下看来到了Mark termination阶段,这个阶段完成标记工作, 重新扫描部分根对象,注意这个阶段是会STW的:

systemstack(stopTheWorldWithSema)
gcMarkTermination(memstats.triggerRatio)

通过stopTheWorldWithSema来达到STW的目的,在gcMarkTermination中会运行systemstack(startTheWorldWithSema)来重新start the world。其中gcMarkTermination中就是要完成标记的工作:

systemstack(func() {
        gcMark(startTime)
}       

具体的实现在gcMark中,细节就不再多说了。

四、Sweep:

该过程会按标记结果清扫span,其实改段的代码在上面的gcMarkTermination中,因为这个过程也是需要STW的,所以要在startTheWorldWithSema之前:

setGCPhase(_GCoff)
gcSweep(work.mode)

func gcSweep(mode gcMode) {
     // 非并行GC
    if !_ConcurrentSweep || mode == gcForceBlockMode {
        for sweepone() != ^uintptr(0) {
            sweep.npausesweep++
        }
    }

    // 并行式清扫
    if sweep.parked {
        sweep.parked = false
        ready(sweep.g, 0, true)
    }
}

非并行模式下调用sweepone(),这个在上面的第一阶段中已经有所介绍,而在并行式清扫中,是在GC初始化的时候启动的bgsweep()来处理:

func bgsweep(c chan int) {
        // 清扫一个span, 然后进入调度
        for gosweepone() != ^uintptr(0) {
            sweep.nbgsweep++
            Gosched()
        }
        // 释放一些未使用的标记队列缓冲区到heap
        for freeSomeWbufs(true) {
            Gosched()
        }
        // 如果清扫未完成则继续循环
        lock(&sweep.lock)
        if !gosweepdone() {
            continue
        }
}

可以看到,最后调用的仍然是sweepone()来实现sweep的功能。

至此整个GC的过程分析完成。

结论

Golang的垃圾回收也是经历了多个版本的更迭,其目的都是为了让GC变得更加高效并减少停顿时间。其实垃圾回收是一个十分复杂的问题,并不是这一篇文章就能够说清楚的。而且Golang在之后的版本肯定还会有更多的优化,让我们拭目以待。


转载请注明出处:http://www.opscoder.info/golang_gc.html

【上一篇】 深入Golang之内存管理
【下一篇】 深入Golang之unsafe
其他分类: