关于go:33-GolangGC标记-清理

30次阅读

共计 8202 个字符,预计需要花费 21 分钟才能阅读完成。

  上一篇文章咱们次要介绍了三色标记法与写屏障技术,基于这些根底,本篇文章将重点介绍垃圾回收的整个解决流程(开启 - 标记 - 标记完结 - 清理),包含标记协程主流程,经典的 startTheworld/stopTheworld 问题,辅助标记是什么,清理过程等等。

垃圾回收概述

  Go 语言将垃圾回收分为三个阶段:标记(三色标记扫描),标记终止(此时业务逻辑暂停,会再次扫描),未启动(可能也会执行清理工作);定义如下:

_GCoff             = iota // GC not running; sweeping in background, write barrier disabled
_GCmark                   // GC marking roots and workbufs: allocate black, write barrier ENABLED
_GCmarktermination        // GC mark termination: allocate black, P's help GC, write barrier ENABLED

  垃圾回收过程的启动函数为 gcStart,该函数次要逻辑如下:

  • 首先查看上一次垃圾回收是否还有 mspan 未被清理,如果有还须要执行清理工作;
  • 垃圾回收器的初始化是不能同时进行的,这里通过锁解决并发问题;
  • 垃圾回收过程也是通过创立协程实现的,只是这些协程和一般的用户协程有所不同罢了;
  • 垃圾回收的某些初始化工作,是不能与用户协程并发执行的,所以在初始化过程中还须要暂停用户协程(也就是传说中的 STW);
func gcStart(trigger gcTrigger) {
    // 如果还有 mspan 未被清理,执行清理工作
    for trigger.test() && sweepone() != ^uintptr(0) {sweep.nbgsweep++}

    // 启动垃圾回收过程须要加锁
    semacquire(&work.startSema)  //startSema protects the transition from "off" to mark or mark termination.
    // 有这把锁能力 stopTheworld
    semacquire(&worldsema)    //Holding worldsema grants an M the right to try to stop the world.

    // 创立垃圾回收主协程
    gcBgMarkStartWorkers()

    //STW
    systemstack(stopTheWorldWithSema)

    // 设置垃圾回收阶段
    setGCPhase(_GCmark)

    // 预处理须要标记的根对象
    gcMarkRootPrepare()

    // 设置标识位(很多中央有用到这个标识判断是否在标记)atomic.Store(&gcBlackenEnabled, 1)

    // 复原用户协程
    systemstack(func() {now = startTheWorldWithSema(trace.enabled)
    })

    semrelease(&worldsema)
    semrelease(&work.startSema)
}

  gcStart 函数这就执行完结了?标记过程呢?没看到对应的逻辑啊。想想标记过程必定是漫长的,如果由 gcStart 函数同步调用,那可是会阻塞函数调用方的。留神上述主流程创立了垃圾回收主协程,就是这些协程执行的标记过程。

func gcBgMarkStartWorkers() {
    // 与 P 数目保持一致
    for gcBgMarkWorkerCount < gomaxprocs {go gcBgMarkWorker()
    }
}

func gcBgMarkWorker() {

    for {
        // Go to sleep until woken by
        // gcController.findRunnableGCWorker.
        gopark(......) 
        {
            // gopark 协程换出之前,会将该协程注册到公共 pool
            // Release this G to the pool.
            gcBgMarkWorkerPool.push(......)
        }

        decnwait := atomic.Xadd(&work.nwait, -1)

        systemstack(func() {
            // 标记扫描
            gcDrain(......)
        })

        incnwait := atomic.Xadd(&work.nwait, +1)

        // 如果该协程是最初一个执行完一轮标记工作,并且没有标记工作须要解决,则标记过程完结
        if incnwait == work.nproc && !gcMarkWorkAvailable(nil) {gcMarkDone()   // 最终调用到 gcMarkTermination,垃圾回收状态转换为_GCmarktermination、_GCoff
        }
    }

}

  这里咱们须要关注两点:1)标记扫描主函数是 gcDrain,该函数次要执行了咱们上一篇文章介绍的三色标记过程,这里就不再赘述。2)垃圾回收协程是一个 for 循环,不过循环开始都是通过 gopark 协程让出 CPU,该协程在什么时候被调度呢?与用户协程一样吗?留神到正文是这么说的,协程始终休眠直到被 ”gcController.findRunnableGCWorker” 唤醒。要了解这一过程,只能去看看 Go 语言调度器了:

func schedule() {
    if gp == nil && gcBlackenEnabled != 0 {gp = gcController.findRunnableGCWorker(_g_.m.p.ptr())
    }
}

  到这里,垃圾回收的启动过程以及工作协程的主逻辑咱们根本有个大抵解了,当然有一些细节目前还未具体介绍,如 STW,如 gcDrain,如标记完结阶段(有趣味能够本人学习钻研)等等。

startTheworld/stopTheworld

  上一篇文章咱们提到因为用户协程与垃圾回收工作协程并发执行,所以须要写屏障,这就能够了吗?当然不是,垃圾回收的某些初始化工作,是不能与用户协程并发执行的,所以在初始化过程中还须要暂停用户协程,这就是所谓的 stopTheworld。如何暂停用户协程呢?

  思考一下,线程 M 调度协程 G 是须要绑定逻辑处理器 P 的,那如果没有可用的逻辑解决 P 当然也就无奈调度用户协程了?逻辑处理器 P 能够分为三种:1)闲暇,没有被任何线程 M 绑定,这种间接更新其状态即可;2)零碎调用中,阐明已被线程 M 绑定,并且正在执行零碎调用,同样的间接更新状态即可(系统调度返回后,检测逻辑处理器 P 的状态不对,线程 M 会休眠);3)运行中,也就是已被线程 M 绑定,并且正在调度用户协程,这种是须要告诉其暂停用户协程的,如何告诉呢?还记得介绍 Go 语言调度器提到的抢占式调度吗?合作式抢占调度与基于信号的抢占式调度。对,就是通过这两种计划实现的(与 Go 版本无关)。

  stopTheWorldWithSema 函数的实现逻辑如下:

func stopTheWorldWithSema() {
    // 调度器锁
    lock(&sched.lock)
    // 期待暂停的 P 数目
    sched.stopwait = gomaxprocs
    // 标识 GC 期待运行
    atomic.Store(&sched.gcwaiting, 1)

    // 告诉所有运行中的 P 暂停用户协程(抢占式调度:合作式或基于信号实现)preemptall()

    // 暂停以后 P
    _g_.m.p.ptr().status = _Pgcstop // Pgcstop is only diagnostic.
    sched.stopwait--

    for _, p := range allp {
        s := p.status
        // 暂停零碎调用中的 P
        if s == _Psyscall && atomic.Cas(&p.status, s, _Pgcstop) {
            p.syscalltick++
            sched.stopwait--
        }
    }

    // 暂停闲暇 P
    for {p := pidleget()
        if p == nil {break}
        p.status = _Pgcstop
        sched.stopwait--
    }

    wait := sched.stopwait > 0
    unlock(&sched.lock)

    // 如果还有 P 没有暂停,循环阻塞期待
    if wait {
        for {
            // wait for 100us, then try to re-preempt in case of any races
            if notetsleep(&sched.stopnote, 100*1000) {noteclear(&sched.stopnote)
                break
            }
            preemptall()}
    }
}

  这里貌似还有一个问题:sched.stopwait 保护着须要暂停 P 的数目,P 处于运行状态的时候,是基于信号告诉用户协程暂停的,告诉的后果是不确定的,所以这里才会循环阻塞期待;只是用户协程暂停时,怎么更新 sched.stopwait 呢?想想用户协程让出 CPU 之后,该执行什么逻辑呢?当然是调度器了!

func schedule() {
    // 如果期待 gc,则暂停 M
    if sched.gcwaiting != 0 {gcstopm()
        goto top
    }
}

func gcstopm() {
    sched.stopwait--
    // 如果所有 P 都暂停了,告诉
    if sched.stopwait == 0 {notewakeup(&sched.stopnote)
    }
}

  原来是这么暂停用户协程的,看来还是须要对 Go 调度器有较深理解。startTheworld 就是一个反过程,这里就不在赘述了。

辅助标记

  辅助标记什么意思呢?谁辅助,辅助谁呢?咱们曾经晓得,Go 语言启动了多个协程用户解决标记扫描工作,思考一下,与此同时,用户协程还在失常分配内存,如果内存调配过快呢?甚至超过了标记扫描的速度呢?为了解决这个问题,Go 语言是这么做的:如果某些用户协程分配内存过快,则须要帮忙执行一些标记扫描工作,并且甚至还会暂停其调度。

  在内存调配入口函数 mallocgc 很容易找到这段逻辑:

func mallocgc(size uintptr, typ *_type, needzero bool) unsafe.Pointer {
    // 只有垃圾回收过程才会走到辅助标记
    if gcBlackenEnabled != 0 {assistG = getg()
        
        assistG.gcAssistBytes -= int64(size)
        // 小于 0,阐明有欠债,须要帮忙辅助标记
        if assistG.gcAssistBytes < 0 {gcAssistAlloc(assistG)
        }
    }
}

  怎么掂量是否内存调配过快呢?垃圾回收协程执行了多少标记扫描工作(相当于工作挣钱,全局保护了一个现金池),相应的用户协程就能申请肯定比例的内存(用户协程花钱);用户协程申请了内存(买货色付钱),有了欠债,怎么办,先从全局现金池借呗,如果不够怎么办(申请内存过多),不够了再帮忙挣钱呗!

func gcAssistAlloc(gp *g) {

retry:
    // 申请了内存(买货色),就须要付钱,assistWorkPerByte 定义了之间的比例关系
    assistWorkPerByte := gcController.assistWorkPerByte.Load()
    assistBytesPerWork := gcController.assistBytesPerWork.Load()

    // 计算该用户协程须要付多少钱
    debtBytes := -gp.gcAssistBytes
    scanWork := int64(assistWorkPerByte * float64(debtBytes))

    // 全局现金池,须要先借钱
    bgScanCredit := atomic.Loadint64(&gcController.bgScanCredit)
    // 相当于借的钱
    stolen := int64(0)
    if bgScanCredit > 0 {

        // 全局现金池不够,不足以还债
        if bgScanCredit < scanWork {
            stolen = bgScanCredit
            // 债必定还没还完
            gp.gcAssistBytes += 1 + int64(assistBytesPerWork*float64(stolen))
        } else {

            // 能够还完债
            stolen = scanWork
            gp.gcAssistBytes += debtBytes
        }

        // 借钱了,全局扣除
        atomic.Xaddint64(&gcController.bgScanCredit, -stolen)

        // 用户协程还剩了这些债权
        scanWork -= stolen

        if scanWork == 0 {
            // We were able to steal all of the credit we needed.
            return
        }
    }

    // 辅助标记(挣钱还债)systemstack(func() {gcAssistAlloc1(gp, scanWork)
    })

    // 还有欠债
    if gp.gcAssistBytes < 0 {
        // 被抢占了,让出 CPU;一旦复原执行,再次借钱
        if gp.preempt {Gosched()
            goto retry
        }

        // 阻塞
        if !gcParkAssist() {goto retry}
    }
}

  辅助标记过程与垃圾回收协程根本相似,通过 gcDrainN 函数实现;一旦辅助标记也没有还清债权,则阻塞用户协程,这里是将其增加到全局队列 work.assistQueue(不能放到 P 协程队列,不然还会被调度)。什么时候再复原该用户协程的执行呢?当然是垃圾回收协程做了更多的工作之后,发现有用户协程因为申请内存过快被阻塞,解除的。

// 垃圾回收标记扫描主逻辑
func gcDrain(gcw *gcWork, flags gcDrainFlags) {
    if gcw.heapScanWork > 0 {
        if flushBgCredit {
            //gcFlushBgCredit 函数更新全局现金池,复原阻塞的用户协程
            gcFlushBgCredit(gcw.heapScanWork - initScanWork)
        }
        gcw.heapScanWork = 0
    }
}

清理

  清理不是很简略吗?之前介绍过,mspan.allocBits 记录内存闲暇与否,0 示意闲暇,1 示意已调配;mspan.gcmarkBits 用户标记彩色和红色对象,0 示意红色也就是须要回收的对象,1 示意彩色对象,在三色标记实现之后,只须要 allocBits=gcmarkBits 就能够了(参考 sweepone 函数实现)。

  首先清理是一个异步过程,并不是说三色标记实现之后,就清理所有的 mspan。分配内存的时候,从 mcentral 获取 mspan 的时候,如果没有清理则执行清理工作,清理之后如果有闲暇内存则返回;另外,垃圾回收启动的时候,也须要清理上一次标记后的所有 mspan。

  怎么标记 mspan 有没有被清理呢?Go 语言应用字段 sweepgen 示意,这是一个整型,通过与全局的 mheap_.sweepgen 比拟,以此判断该 mspan 是否已被清理,判断形式如下:

// if sweepgen == h->sweepgen - 2, the span needs sweeping
// if sweepgen == h->sweepgen - 1, the span is currently being swept
// if sweepgen == h->sweepgen, the span is swept and ready to use
// if sweepgen == h->sweepgen + 1, the span was cached before sweep began and is still cached, and needs sweeping
// if sweepgen == h->sweepgen + 3, the span was swept and then cached and is still cached
// h->sweepgen is incremented by 2 after every GC

  sweep 就是清理的意思,gen 是第几代的缩写(generation)。正文说 h ->sweepgen 没开启一轮 GC,自增 2;假如初始 h ->sweepgen 等于 X,mspan.sweepgen 也等于 X(新申请的 mspan.sweepgen 间接赋值为 h ->sweepgen),依据下面形容,该 mspan 曾经清理能够应用;开启新一轮 GC 后,h->sweepgen 等于 X +2,满足第一个条件,阐明该 mspan 须要被清理。设计的还是十分奇妙的,不然还须要设法保护每一个 mspan 的清理状。

  清理 mspan 是有可能并发执行的,用户协程申请内存时就有可能执行清理工作,所以清理 mspan 也是须要加锁的(基于 cas),上面是获取待清理 mspan 逻辑:

func (l *sweepLocker) tryAcquire(s *mspan) (sweepLocked, bool) {
    // 状态非待清理
    if atomic.Load(&s.sweepgen) != l.sweepGen-2 {return sweepLocked{}, false
    }
    // 设置状态为清理中
    if !atomic.Cas(&s.sweepgen, l.sweepGen-2, l.sweepGen-1) {return sweepLocked{}, false
    }
    return sweepLocked{s}, true
}

// 获取 mspan 执行清理的过程
if s, ok := sl.tryAcquire(s); ok {if s.sweep(false)
}

  另外,如果 mspan 被缓存在 mcache 应用状况下,mspan.sweepgen 满足的是第四以及第五个条件;当 mspan 无可用内存调配时,会从 mcache 缓存删除,此时也会批改 mspan.sweepgen;另外在垃圾回收标记终止阶段,也会删除所有逻辑处理器 P 的 mcache(同样会批改 mspan.sweepgen)。所以不必放心缓存的 mspan 无奈被清理。

  最初,还记得 mcentral 的构造定义吗(如下)?partial 与 full 都是一个数组,数组长度为 2,都是一个数组索引的 mspan 曾经被清理,另一个数组索引的 mspan 还未被清理。那到底 partial[0] 是已被清理的,还是 partial[1] 是已被清理的呢?答案是不肯定。

type mcentral struct {
    spanclass spanClass

    //partial 存储有闲暇内存的 mspan
    partial [2]spanSet // list of spans with a free object

    //full 存储的 mspan 没有闲暇内存
    full    [2]spanSet // list of spans with no free objects
}

  咱们看看 Go 语言是如何获取已被清理和未清理的 mspan:

func (c *mcentral) partialUnswept(sweepgen uint32) *spanSet {return &c.partial[1-sweepgen/2%2]
}

func (c *mcentral) partialSwept(sweepgen uint32) *spanSet {return &c.partial[sweepgen/2%2]
}

  sweepgen 每次自增 2,只能是偶数;如 2、4、6、8,然而除以 2 之后,就有可能是奇数了,如 1、2、3、4。假如以后 sweepgen 等于 10,依据下面代码的计算形式,则本轮已清理的 mspan 都在 partial[1],未清理的都在 partial[0],开启下一轮之前,须要清理所有的 mspan,清理后的 mspan 都在 partial[1];新一轮 GC 开启,h->sweepgen 自增 2 等于 12,依据下面代码的计算形式,已清理的 mspan 都在 partial[0],未清理的都在 partial[1](刚好 partial[1] 数组的 mspan 在新一轮 GC 标记扫描后,是须要被清理的)。

  同样的,因为 h ->sweepgen 自增 2,已清理的 mspan 和未清理的 mspan,在数组 partial 和 full 的索引是轮询的,防止了每次开启 GC 后,还须要迁徙 mcentral 的所有 mspan。

总结

  本篇文章次要介绍了垃圾回收的根本流程,包含垃圾回收工作协程的创立与调度,经典的 startTheworld/stopTheworld 问题,辅助标记是什么,清理过程(重点推敲 sweepgen 的设计思路)等等。更多细节还须要读者一直学习钻研。

正文完
 0