Go GC 简介

Python043

Go GC 简介,第1张

GC 与 mutator 线程并发运行,允许多个 GC 线程并行运行

GC 是一个使用写屏障的并发标记和清除。

GC 是非分代的,非紧凑的。

Allocation 是按照大小隔离每个 P 分配的区域来完成的,以在消除常见情况下的锁的同时,最小化碎片。

了解 GC 的好地方,可以从 Richard Jones 的 gchandbook.org 开始。

1. GC 执行清除终止

      a. Stop the world ,这将导致所有 P 达到 GC 安全点。

      b. 清除任何未清除过的 spans ,只有在预期时间之前强制执行此 GC 周期时,才会有未清除的 span 。

2. GC 执行标记阶段

      a.   准备标记阶段,将 gcphase 设置为 _GCmark (从 _GCoff 开始),启用写屏障,启用 mutator assist ,并对根标记作业进行排队。

在所有 P 都启用写屏障之前,不会扫描任何对象,这是使用 STW 完成的。

       b. Start the world ,从现在开始,GC 工作由调度器启动的 标记worker 和作 为 allocation 的一部分执行的 assists 来完成。

写屏障将覆写的指针和任何指针写的新指针值都着色。

新分配的对象立即被标记为黑色。

      c.   GC 执行根标记作业。包括: 扫描所有栈 , 着色所有全局变量 ,以及 着色堆外运行时数据结构中的任何堆指针 。

扫描栈会停止goroutine,对goroutine栈中找到的任何指针进行着色,然后恢复goroutine。

        d.   GC 耗尽灰色对象的工作队列,将每个 灰色 对象扫描为 黑色 ,并对在该对象中找到的所有指针进行着色(反过来可能会将这些指针添加到工作队列中)。

       e.   由于 GC work 分散在本地缓存中,因此 GC 使用 分布式终止算法 来检测何时不再有根标记作业或灰色对象(参见 gcMarkDone 函数)。

此时,GC 状态转换到标记终止( gcMarkTermination )。

3. GC 执行标记终止 gcMarkTermination

      a. Stop the world

      b. 将 gcphase 设置为 _GCmarktermination ,并禁用 workers 和 assists。

      c. 进行内务整理,如 flushing mcaches

4. GC 执行清除阶段

       a. 准备清除阶段,将 gcphase 设置为 _GCoff ,设置清除状态并禁用写屏障。

      b. Start the world ,从现在开始,新分配的对象是白色的,如有必要,在使用 spans 前 allocating 清除 spans 。

       c. GC 在后台进行 并发清除 并响应 allocation ,见下面的描述。

5. 当分配足够时,重复上面 1 开始的步骤,参见下面关于 GC rate 的讨论。

清除阶段与正常程序执行并发进行。

在后台 goroutine 中,堆被惰性(当 goroutine 需要另一个 span 时)且并发地逐个 span 扫描(这有助于不是 CPU bound 的程序)。

在 STW 标记终止 的结尾,所有的 span 都被标记为 需要清除 。

后台清除器 goroutine 简单地逐个清除 span 。

为了避免在存在未清除的 span 时请求更多的 OS内存 ,当 goroutine 需要另一个 span 时,它首先尝试通过清除来回收这些内存。

当 goroutine 需要分配一个新的 小对象span 时,它会清除相同大小的小对象 span ,直到释放至少一个对象为止。

当 goroutine 需要从堆中分配 大对象span 时,它会清除 span ,直到将至少那么多页面释放到堆中。

有一种情况,这可能是不够的:如果 goroutine 清除并释放两个不相邻的 单页span 到堆中,那么它将分配一个新的 双页span ,但是仍然可以有其他 单页未清除的span ,可以组合成 双页的span 。

确保在未清除的 span 上不进行任何操作(这会破坏 GC 位图中的标记位)至关重要。

在 GC 期间,所有 mcache 都被刷新到 中央缓存 中,因此它们是空的。

当一个 goroutine 抓取一个新的 span 到 mcache 时, goroutine 会清除 mcache 。

当 goroutine 显式释放对象或设置 finalizer 时,goroutine 确保 span 已经清除(通过清除或者等待并发清除完成)。

finalizer goroutine 仅在所有 span 已经清除时才开始。

当下一次 GC 启动时,它将清除所有尚未清除的 span (如果有的话)。

下一次 GC 是在我们分配了与已经使用的内存成正比的额外内存量之后。

该比例由 GOGC 环境变量控制(默认为 100 )。

如果 GOGC=100 ,而我们使用的是 4M ,那么当达到 8M 时,我们将再次进行 GC(此标记在 next_gc 变量中被跟踪)。

获取 GOGC :

这使得 GC成本 与 allocation 成本 成线性比例。

调整 GOGC 只会改变线性常量(以及使用的额外内存量)。

为了防止在扫描大型对象时出现长时间的暂停,并提高并行性,垃圾收集器将大于 maxObletBytes 的对象的扫描作业分解为最多 maxObletBytes 的 oblets 。

当扫描遇到大对象时,它只扫描第一个 oblet ,并将其余 oblets 作为新的扫描作业排队。

前段时间在golang-China读到这个贴:

个人觉得golang十分适合进行网游服务器端开发,写下这篇文章总结一下。

从网游的角度看:

要成功的运营一款网游,很大程度上依赖于玩家自发形成的社区。只有玩家自发形成一个稳定的生态系统,游戏才能持续下去,避免鬼城的出现。而这就需要多次大量导入用户,在同时在线用户量达到某个临界点的时候,才有可能完成。因此,多人同时在线十分有必要。

再来看网游的常见玩法,除了排行榜这类统计和数据汇总的功能外,基本没有需要大量CPU时间的应用。以前的项目里,即时战斗产生的各种伤害计算对CPU的消耗也不大。玩家要完成一次操作,需要通过客户端-服务器端-客户端这样一个来回,为了获得高响应速度,满足玩家体验,服务器端的处理也不能占用太多时间。所以,每次请求对应的CPU占用是比较小的。

网游的IO主要分两个方面,一个是网络IO,一个是磁盘IO。网络IO方面,可以分成美术资源的IO和游戏逻辑指令的IO,这里主要分析游戏逻辑的IO。游戏逻辑的IO跟CPU占用的情况相似,每次请求的字节数很小,但由于多人同时在线,因此并发数相当高。另外,地图信息的广播也会带来比较频繁的网络通信。磁盘IO方面,主要是游戏数据的保存。采用不同的数据库,会有比较大的区别。以前的项目里,就经历了从MySQL转向MongoDB这种内存数据库的过程,磁盘IO不再是瓶颈。总体来说,还是用内存做一级缓冲,避免大量小数据块读写的方案。

针对网游的这些特点,golang的语言特性十分适合开发游戏服务器端。

首先,go语言提供goroutine机制作为原生的并发机制。每个goroutine所需的内存很少,实际应用中可以启动大量的goroutine对并发连接进行响应。goroutine与gevent中的greenlet很相像,遇到IO阻塞的时候,调度器就会自动切换到另一个goroutine执行,保证CPU不会因为IO而发生等待。而goroutine与gevent相比,没有了python底层的GIL限制,就不需要利用多进程来榨取多核机器的性能了。通过设置最大线程数,可以控制go所启动的线程,每个线程执行一个goroutine,让CPU满负载运行。

同时,go语言为goroutine提供了独到的通信机制channel。channel发生读写的时候,也会挂起当前操作channel的goroutine,是一种同步阻塞通信。这样既达到了通信的目的,又实现同步,用CSP模型的观点看,并发模型就是通过一组进程和进程间的事件触发解决任务的。虽然说,主流的编程语言之间,只要是图灵完备的,他们就都能实现相同的功能。但go语言提供的这种协程间通信机制,十分优雅地揭示了协程通信的本质,避免了以往锁的显式使用带给程序员的心理负担,确是一大优势。进行网游开发的程序员,可以将游戏逻辑按照单线程阻塞式的写,不需要额外考虑线程调度的问题,以及线程间数据依赖的问题。因为,线程间的channel通信,已经表达了线程间的数据依赖关系了,而go的调度器会给予妥善的处理。

另外,go语言提供的gc机制,以及对指针的保护式使用,可以大大减轻程序员的开发压力,提高开发效率。

展望未来,我期待go语言社区能够提供更多的goroutine间的隔离机制。个人十分推崇erlang社区的脆崩哲学,推动应用发生预期外行为时,尽早崩溃,再fork出新进程处理新的请求。对于协程机制,需要由程序员保证执行的函数不会发生死循环,导致线程卡死。如果能够定制goroutine所执行函数的最大CPU执行时间,及所能使用的最大内存空间,对于提升系统的鲁棒性,大有裨益。

Golang采用了三色标记法来进行垃圾回收,那么在什么场景下会触发这个回收动作呢?

源码主要位于文件 src/runtime/mgc.go go version 1.16

触发条件从大方面说,可分为 手动触发 和 系统触发 两种方式。手动触发一般很少用,主要由开发者通过调用 runtime.GC() 函数来实现,而对于系统自动触发是 运行时 根据一些条件判断来进行的,这也正是本文要介绍的内容。

不管哪种触发方式,底层回收机制是一样的,所以我们先看一下手动触发,根据它来找系统触发的条件。

可以看到开始执行GC的是 gcStart() 函数,它有一个 gcTrigger 参数,是一个触发条件结构体,它的结构体也很简单。

其实在Golang 内部所有的GC都是通过 gcStart() 函数,然后指定一个 gcTrigger 的参数来开始的,而手动触发指定的条件值为 gcTriggerCycle 。 gcStart 是一个很复杂的函数,有兴趣的可以看一下源码实现。

对于 kind 的值有三种,分别为 gcTriggerHeap 、 gcTriggerTime 和 gcTriggerCycle 。

运行时会通过 gcTrigger.test() 函数来决定是否需要触发GC,只要满足上面基中一个即可。

到此我们基本明白了这三种触发GC的条件,那么对于系统自动触发这种,Golang 从一个程序的开始到运行,它又是如何一步一步监控到这个条件的呢?

其实 runtime 在程序启动时,会在一个初始化函数 init() 里启用一个 forcegchelper() 函数,这个函数位于 proc.go 文件。

为了减少系统资源占用,在 forcegchelper 函数里会通过 goparkunlock() 函数主动让自己陷入休眠,以后由 sysmon() 监控线程根据条件来恢复这个gc goroutine。

可以看到 sysmon() 会在一个 for 语句里一直判断这个 gcTriggerTime 这个条件是否满足,如果满足的话,会将 forcegc.g 这个 goroutine 添加到全局队列里进行调度(这里 forcegc 是一个全局变量)。

调度器在调度循环 runtime.schedule 中还可以通过垃圾收集控制器的 runtime.gcControllerState.findRunnabledGCWorker 获取并执行用于后台标记的任务。