ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

go-questions 深度解析:Go 垃圾回收(GC)的认识——从基本概念到三色标记与写屏障

go-questions 深度解析:Go 垃圾回收(GC)的认识——从基本概念到三色标记与写屏障 文档教程【免费下载链接】go-questions Go 程序员面试笔试宝典 | 从问题切入串连 Go 语言相关的所有知识融会贯通。 https://golang.design/go-questions项目地址https://gitcode.com/gh_mirrors/go/go-questions点击查看免费下载导读本文以 go-questions 仓库中 content/memgc/1-垃圾回收的认识.md 为骨架系统讲解 Go GC 的核心原理GC 是什么、根对象是什么、常见实现方式、三色标记法、STW、四种观察 GC 的手段、GC 下内存泄漏的成因以及并发标记清除的难点与写屏障/混合写屏障的实现。读完本文你将能看懂GODEBUGgctrace1的日志、会用go tool trace与调试 API 监控 GC并理解 Go 为何选择无分代、不整理、并发的三色标记清扫算法为后续的 GC 调优见 3-垃圾回收的优化问题.md打下坚实基础。1. 什么是 GC有什么作用GC全称Garbage Collection即垃圾回收是一种自动内存管理的机制。当程序向操作系统申请的内存不再需要时垃圾回收主动将其回收并供其他代码进行内存申请时复用或者将其归还给操作系统。这种针对内存级别资源的自动回收过程即为垃圾回收而负责垃圾回收的程序组件即为垃圾回收器。垃圾回收是一个完美的 Simplicity is Complicated 的例子一方面程序员受益于 GC无需操心、也不再需要对内存进行手动的申请和释放操作GC 在程序运行时自动释放残留的内存另一方面GC 对程序员几乎不可见仅在程序需要进行特殊优化时通过提供可调控的 API如GOGC、runtime.GC、debug.SetGCPercent等对 GC 的运行时机、运行开销进行把控的时候才得以现身。通常垃圾回收器的执行过程被划分为两个半独立的组件赋值器Mutator这一名称本质上指代的是用户态的代码。因为对垃圾回收器而言用户态的代码仅仅是在修改对象之间的引用关系也就是在对象图对象之间引用关系的一个有向图上进行操作。回收器Collector负责执行垃圾回收的代码。从源码结构看Go 运行时在 content/memgc/2-垃圾回收机制的实现.md 中进一步将一次完整的回收划分为清扫终止SweepTermination、并发标记Mark、标记终止MarkTermination、内存清扫GCoff与内存归还GCoff五个阶段其中清扫终止与标记终止两个阶段处于 STW 状态其余阶段与赋值器并发执行。2. 根对象到底是什么根对象在垃圾回收术语中又叫做根集合它是垃圾回收器在标记过程时最先检查的对象包括全局变量程序在编译期就能确定的、存在于程序整个生命周期的变量。执行栈每个 goroutine 都包含自己的执行栈这些执行栈上包含栈上的变量及指向分配的堆内存区块的指针。寄存器寄存器的值可能表示一个指针参与计算的这些指针可能指向某些赋值器分配的堆内存区块。正是从这些根对象出发回收器才能沿着对象图逐步标记所有可达对象完成哪些对象存活、哪些对象死亡的判断。3. 常见的 GC 实现方式有哪些Go 语言使用的是什么所有的 GC 算法其存在形式都可以归结为**追踪Tracing和引用计数Reference Counting**这两种形式的混合运用。追踪式 GC从根对象出发根据对象之间的引用信息一步步推进直到扫描完毕整个堆并确定需要保留的对象从而回收所有可回收的对象。Go、Java、V8 对 JavaScript 的实现等均为追踪式 GC。引用计数式 GC每个对象自身包含一个被引用的计数器当计数器归零时自动得到回收。因为此方法缺陷较多在追求高性能时通常不被应用。Python、Objective-C 等均为引用计数式 GC。目前比较常见的 GC 实现方式包括追踪式分为多种不同类型例如标记清扫从根对象出发将确定存活的对象进行标记并清扫可以回收的对象。标记整理为了解决内存碎片问题而提出在标记过程中将对象尽可能整理到一块连续的内存上。增量式将标记与清扫的过程分批执行每次执行很小的部分从而增量地推进垃圾回收达到近似实时、几乎无停顿的目的。增量整理在增量式的基础上增加对对象的整理过程。分代式将对象根据存活时间的长短进行分类存活时间小于某个值的为年轻代存活时间大于某个值的为老年代永远不会参与回收的对象为永久代。并根据分代假设如果一个对象存活时间不长则倾向于被回收如果一个对象已经存活很长时间则倾向于存活更长时间对对象进行回收。引用计数根据对象自身的引用计数来回收当引用计数归零时立即回收。关于各类方法的详细介绍及其实现不在本文中详细讨论这里聚焦于 Go 的选择。对于 Go 而言Go 的 GC 目前使用的是无分代对象没有代际之分、不整理回收过程中不对对象进行移动与整理、并发与用户代码并发执行的三色标记清扫算法。原因在于关于对象整理对象整理的优势是解决内存碎片问题以及允许使用顺序内存分配器。但 Go 运行时的分配算法基于 tcmalloc基本上没有碎片问题并且顺序内存分配器在多线程的场景下并不适用。Go 使用的是基于 tcmalloc 的现代内存分配算法对对象进行整理不会带来实质性的性能提升。关于分代分代 GC 依赖分代假设即 GC 将主要的回收目标放在新创建的对象上存活时间短更倾向于被回收而非频繁检查所有对象。但 Go 的编译器会通过逃逸分析将大部分新生对象存储在栈上栈直接被回收只有那些需要长期存在的对象才会被分配到需要进行垃圾回收的堆中。也就是说分代 GC 回收的那些存活时间短的对象在 Go 中是直接被分配到栈上当 goroutine 死亡后栈也会被直接回收不需要 GC 的参与进而分代假设并没有带来直接优势。并且 Go 的垃圾回收器与用户代码并发执行使得 STW 的时间与对象的代际、对象的 size 没有关系。Go 团队更关注于如何更好地让 GC 与用户代码并发执行使用适当的 CPU 来执行垃圾回收而非减少停顿时间这一单一目标上。关于逃逸分析如何把对象分配到栈上、从而减轻 GC 压力可以继续阅读仓库中的 1-逃逸分析是怎么进行的.md。这也是Go 程序员面试笔试宝典中编译模块与 GC 模块的衔接点。4. 三色标记法是什么理解三色标记法的关键是理解对象的三色抽象以及波面wavefront推进这两个概念。三色抽象只是一种描述追踪式回收器的方法在实践中并没有实际含义它的重要作用在于从逻辑上严密推导标记清理这种垃圾回收方法的正确性。也就是说当我们谈及三色标记法时通常指标记清扫的垃圾回收。从垃圾回收器的视角来看三色抽象规定了三种不同类型的对象并用不同的颜色相称白色对象可能死亡未被回收器访问到的对象。在回收开始阶段所有对象均为白色当回收结束后白色对象均不可达。灰色对象波面已被回收器访问到的对象但回收器需要对其中的一个或多个指针进行扫描因为它们可能还指向白色对象。黑色对象确定存活已被回收器访问到的对象其中所有字段都已被扫描黑色对象中任何一个指针都不可能直接指向白色对象。这样三种不变性所定义的回收过程其实是一个波面不断前进的过程。这个波面同时也是黑色对象和白色对象的边界灰色对象就是这个波面。当垃圾回收开始时只有白色对象。随着标记过程开始进行灰色对象开始出现着色这时候波面便开始扩大。当一个对象的所有子节点均完成扫描时会被着色为黑色。当整个堆遍历完成时只剩下黑色和白色对象——这时的黑色对象为可达对象即存活而白色对象为不可达对象即死亡。这个过程可以视为以灰色对象为波面将黑色对象和白色对象分离使波面不断向前推进直到所有可达的灰色对象都变为黑色对象为止。如下图所示图中展示了根对象、可达对象、不可达对象黑、灰、白对象以及波面之间的关系。5. STW 是什么意思STW可以是Stop the World的缩写也可以是Start the World的缩写。通常意义上指代从Stop the World这一动作发生时到Start the World这一动作发生时这一段时间间隔即万物静止。STW 在垃圾回收过程中为了保证实现的正确性、防止无止境的内存增长等问题而不可避免的需要停止赋值器进一步操作对象图的一段过程。在这个过程中整个用户代码被停止或者放缓执行STW越长对用户代码造成的影响例如延迟就越大。早期 Go 对垃圾回收器的实现中STW长达几百毫秒对时间敏感的实时通信等应用程序会造成巨大的影响。我们来看一个例子该程序的完整源码可查看 content/memgc/code/5/main.gopackage main import ( runtime time ) func main() { go func() { for { } }() time.Sleep(time.Millisecond) runtime.GC() println(OK) }上面的这个程序在 Go 1.14 以前永远都不会输出OK其罪魁祸首是进入 STW 这一操作的执行无限制地被延长。尽管 STW 如今已经优化到了半毫秒级别以下但这个程序被卡死的原因是由于需要进入 STW 导致的。原因在于GC 在需要进入 STW 时需要通知并让所有的用户态代码停止但是for {}所在的 goroutine 永远都不会被中断从而始终无法进入 STW 阶段。实际实践中也是如此当程序的某个 goroutine 长时间得不到停止强行拖慢进入 STW 的时机这种情况下造成的影响卡死是非常可怕的。好在自 Go 1.14 之后这类 goroutine 能够被异步地抢占从而使得进入 STW 的时间不会超过抢占信号触发的周期程序也不会因为仅仅等待一个 goroutine 的停止而停顿在进入 STW 之前的操作上。提示关于 goroutine 的调度模型GMP、抢占与系统监控线程的作用可继续阅读仓库中的 content/sched/6-GPM 是什么.md 与 content/sched/14-sysmon 后台监控线程做了什么.md。6. 如何观察 Go GC我们以下面的程序为例完整源码见 content/memgc/code/6/main.go先使用四种不同的方式来介绍如何观察 GCpackage main func allocate() { _ make([]byte, 120) } func main() { for n : 1; n 100000; n { allocate() } }方式1GODEBUGgctrace1我们首先可以通过如下命令开启 GC 追踪$ go build -o main $ GODEBUGgctrace1 ./main gc 1 0.000s 2%: 0.0090.230.004 ms clock, 0.110.083/0.019/0.140.049 ms cpu, 4-6-2 MB, 5 MB goal, 12 P scvg: 8 KB released scvg: inuse: 3, idle: 60, sys: 63, released: 57, consumed: 6 (MB) gc 2 0.001s 2%: 0.0181.10.029 ms clock, 0.220.047/0.074/0.0480.34 ms cpu, 4-7-3 MB, 5 MB goal, 12 P scvg: inuse: 3, idle: 60, sys: 63, released: 56, consumed: 7 (MB) gc 3 0.003s 2%: 0.0180.590.011 ms clock, 0.220.073/0.008/0.0420.13 ms cpu, 5-6-1 MB, 6 MB goal, 12 P scvg: 8 KB released scvg: inuse: 2, idle: 61, sys: 63, released: 56, consumed: 7 (MB) gc 4 0.003s 4%: 0.0190.700.054 ms clock, 0.230.051/0.047/0.0850.65 ms cpu, 4-6-2 MB, 5 MB goal, 12 P scvg: 8 KB released scvg: inuse: 3, idle: 60, sys: 63, released: 56, consumed: 7 (MB) scvg: 8 KB released scvg: inuse: 4, idle: 59, sys: 63, released: 56, consumed: 7 (MB) gc 5 0.004s 12%: 0.0210.260.49 ms clock, 0.260.046/0.037/0.115.8 ms cpu, 4-7-3 MB, 5 MB goal, 12 P scvg: inuse: 5, idle: 58, sys: 63, released: 56, consumed: 7 (MB) gc 6 0.005s 12%: 0.0200.170.004 ms clock, 0.250.080/0.070/0.0530.051 ms cpu, 5-6-1 MB, 6 MB goal, 12 P scvg: 8 KB released scvg: inuse: 5, idle: 58, sys: 63, released: 56, consumed: 7上述输出样例也保存在仓库的 content/memgc/code/6/gc.txt 中可对照查看。在这个日志中可以观察到两类不同的信息一类是用户代码向运行时申请内存产生的垃圾回收日志另一类是运行时向操作系统申请/归还内存产生的日志。第一类用户代码分配触发的 GCgc 2 0.001s 2%: 0.0181.10.029 ms clock, 0.220.047/0.074/0.0480.34 ms cpu, 4-7-3 MB, 5 MB goal, 12 P含义如下表所示字段含义gc 2第二个 GC 周期0.001程序开始后的 0.001 秒2%该 GC 周期中 CPU 的使用率0.018标记开始时STW 所花费的时间wall clock1.1标记过程中并发标记所花费的时间wall clock0.029标记终止时STW 所花费的时间wall clock0.22标记开始时STW 所花费的时间cpu time0.047标记过程中标记辅助所花费的时间cpu time0.074标记过程中并发标记所花费的时间cpu time0.048标记过程中GC 空闲的时间cpu time0.34标记终止时STW 所花费的时间cpu time4标记开始时堆的大小的实际值7标记结束时堆的大小的实际值3标记结束时标记为存活的对象大小5标记结束时堆的大小的预测值goal12P 的数量wall clock 与 cpu time 的关系wall clock 是指开始执行到完成所经历的实际时间包括其他程序和本程序所消耗的时间cpu time 是指特定程序使用 CPU 的时间它们存在以下关系wall clock cpu time充分利用多核wall clock ≈ cpu time未并行执行wall clock cpu time多核优势不明显第二类运行时向操作系统申请内存产生的 GC归还多余内存scvg: 8 KB released scvg: inuse: 3, idle: 60, sys: 63, released: 57, consumed: 6 (MB)含义如下表所示字段含义8 KB released向操作系统归还了 8 KB 内存3已经分配给用户代码、正在使用的总内存大小 (MB)60空闲以及等待归还给操作系统的总内存大小MB63通知操作系统中保留的内存大小MB57已经归还给操作系统的或者说还未正式申请的内存大小MB6已经从操作系统中申请的内存大小MB方式2go tool tracego tool trace的主要功能是将统计而来的信息以可视化的方式展示给用户。要使用此工具可以通过调用 trace APIpackage main func main() { f, _ : os.Create(trace.out) defer f.Close() trace.Start(f) defer trace.Stop() (...) }并通过如下命令启动可视化界面$ go tool trace trace.out 2019/12/30 15:50:33 Parsing trace... 2019/12/30 15:50:38 Splitting trace... 2019/12/30 15:50:45 Opening browser. Trace viewer is listening on http://127.0.0.1:51839选择链接可以获得可视化视图右上角的问号可以打开帮助菜单主要使用方式包括w/s 键可以用于放大或者缩小视图a/d 键可以用于左右移动按住 Shift 可以选取多个事件。方式3debug.ReadGCStats此方式可以通过代码的方式来直接实现对感兴趣指标的监控例如我们希望每隔一秒钟监控一次 GC 的状态func printGCStats() { t : time.NewTicker(time.Second) s : debug.GCStats{} for { select { case -t.C: debug.ReadGCStats(s) fmt.Printf(gc %d last%v, PauseTotal %v\n, s.NumGC, s.LastGC, s.PauseTotal) } } } func main() { go printGCStats() (...) }我们能够看到如下输出$ go run main.go gc 4954 last2019-12-30 15:19:37.505575 0100 CET, PauseTotal 29.901171ms gc 9195 last2019-12-30 15:19:38.50565 0100 CET, PauseTotal 77.579622ms gc 13502 last2019-12-30 15:19:39.505714 0100 CET, PauseTotal 128.022307ms gc 17555 last2019-12-30 15:19:40.505579 0100 CET, PauseTotal 182.816528ms gc 21838 last2019-12-30 15:19:41.505595 0100 CET, PauseTotal 246.618502ms方式4runtime.ReadMemStats除了使用 debug 包提供的方法外还可以直接通过运行时的内存相关 API 进行监控func printMemStats() { t : time.NewTicker(time.Second) s : runtime.MemStats{} for { select { case -t.C: runtime.ReadMemStats(s) fmt.Printf(gc %d last%v, next_heap_size%vMB\n, s.NumGC, time.Unix(int64(time.Duration(s.LastGC).Seconds()), 0), s.NextGC/(120)) } } } func main() { go printMemStats() (...) }输出示例$ go run main.go gc 4887 last2019-12-30 15:44:56 0100 CET, next_heap_size4MB gc 10049 last2019-12-30 15:44:57 0100 CET, next_heap_size4MB gc 15231 last2019-12-30 15:44:58 0100 CET, next_heap_size4MB gc 20378 last2019-12-30 15:44:59 0100 CET, next_heap_size6MB当然后两种方式能够监控的指标很多debug.GCStats和runtime.MemStats都包含大量字段读者可以自行查阅对应文档这里不再赘述。7. 有了 GC为什么还会发生内存泄漏在一个具有 GC 的语言中我们常说的内存泄漏用严谨的话来说应该是预期的能很快被释放的内存由于附着在了长期存活的内存上、或生命期意外地被延长导致预计能够立即回收的内存而长时间得不到回收。在 Go 中由于 goroutine 的存在所谓的内存泄漏除了附着在长期对象上之外还存在多种不同的形式。形式1预期能被快速释放的内存因被根对象引用而没有得到迅速释放当有一个全局对象时可能不经意间将某个变量附着在其上且忽略将其进行释放则该内存永远不会得到释放。例如var cache map[interface{}]interface{}{} func keepalloc() { for i : 0; i 10000; i { m : make([]byte, 110) cache[i] m } }这里的cache是全局变量属于根集合中的全局变量被它引用的 1KB 字节切片永远可达因而永远不会被回收。形式2goroutine 泄漏Goroutine 作为一种逻辑上理解的轻量级线程需要维护执行用户代码的上下文信息。在运行过程中也需要消耗一定的内存来保存这类信息而这些内存在目前版本的 Go 中是不会被释放的。因此如果一个程序持续不断地产生新的 goroutine、且不结束已经创建的 goroutine 并复用这部分内存就会造成内存泄漏的现象例如func keepalloc2() { for i : 0; i 100000; i { go func() { select {} }() } }select {}使 goroutine 永久阻塞既不会结束也不会被复用其执行栈等内存就无法归还。值得一提的是这种形式的 goroutine 泄漏还可能由channel 泄漏导致。channel 的泄漏本质上与 goroutine 泄漏存在直接联系。Channel 作为一种同步原语会连接两个不同的 goroutine如果一个 goroutine 尝试向一个没有接收方的无缓冲 channel 发送消息则该 goroutine 会被永久地休眠整个 goroutine 及其执行栈都得不到释放例如var ch make(chan struct{}) func keepalloc3() { for i : 0; i 100000; i { // 没有接收方goroutine 会一直阻塞 go func() { ch - struct{}{} }() } }关于 channel 的底层数据结构与同步语义可继续阅读仓库中的 content/channel/2-channel 底层的数据结构是什么.md。验证我们可以通过如下形式来调用上述两个函数该完整验证程序保存在 content/memgc/code/7/main.go其中同时包含了keepalloc3的验证package main import ( os runtime/trace ) func main() { f, _ : os.Create(trace.out) defer f.Close() trace.Start(f) defer trace.Stop() keepalloc() keepalloc2() }运行程序go run main.go会看到程序中生成了trace.out文件我们可以使用go tool trace trace.out命令得到下图可以看到图中的 Heap 在持续增长没有内存被回收产生了内存泄漏的现象。8. 并发标记清除法的难点是什么在没有用户态代码并发修改三色抽象的情况下回收可以正常结束。但是并发回收的根本问题在于用户态代码在回收过程中会并发地更新对象图从而造成赋值器和回收器可能对对象图的结构产生不同的认知。这时以一个固定的三色波面作为回收过程前进的边界则不再合理。我们不妨考虑赋值器写操作的例子时序回收器赋值器说明1shade(A, gray)回收器根对象的子节点着色为灰色对象2shade(C, black)回收器当所有子节点着色为灰色后将节点着为黑色3C.ref3 C.ref2.ref1赋值器并发的修改了 C 的子节点4A.ref1 nil赋值器并发的修改了 A 的子节点5shade(A.ref1, gray)回收器进一步将灰色对象的子节点着色为灰色这时由于A.ref1为nil什么事情也没有发生6shade(A, black)回收器由于所有子节点均已标记回收器不会重新扫描已经被标记为黑色的对象此时 A 被着色为黑色scan(A)什么也不会发生进而 B 在此次回收过程中永远不会被标记为黑色进而错误地被回收初始状态假设某个黑色对象 C 指向某个灰色对象 A而 A 指向白色对象 BC.ref3 C.ref2.ref1赋值器并发地将黑色对象 C 指向ref3了白色对象 BA.ref1 nil移除灰色对象 A 对白色对象 B 的引用ref2最终状态在继续扫描的过程中白色对象 B 永远不会被标记为黑色对象了回收器不会重新扫描黑色对象进而对象 B 被错误地回收。总而言之并发标记清除中面临的一个根本问题就是如何保证标记与清除过程的正确性。9. 什么是写屏障、混合写屏障如何实现要讲清楚写屏障就需要理解三色标记清除算法中的强弱不变性以及赋值器的颜色理解它们需要一定的抽象思维。写屏障是一个在并发垃圾回收器中才会出现的概念垃圾回收器的正确性体现在不应出现对象的丢失也不应错误地回收还不需要回收的对象。可以证明当以下两个条件同时满足时会破坏垃圾回收器的正确性条件 1赋值器修改对象图导致某一黑色对象引用白色对象条件 2从灰色对象出发到达白色对象的、未经访问过的路径被赋值器破坏。只要能够避免其中任何一个条件则不会出现对象丢失的情况因为如果条件 1 被避免则所有白色对象均被灰色对象引用没有白色对象会被遗漏如果条件 2 被避免即便白色对象的指针被写入到黑色对象中但从灰色对象出发总存在一条没有访问过的路径从而找到到达白色对象的路径白色对象最终不会被遗漏。我们不妨将三色不变性所定义的波面根据这两个条件进行削弱当满足原有的三色不变性定义或上面的两个条件都不满足时的情况称为强三色不变性strong tricolor invariant当赋值器令黑色对象引用白色对象时满足条件 1 时的情况称为弱三色不变性weak tricolor invariant。当赋值器进一步破坏灰色对象到达白色对象的路径时进一步满足条件 2 时即打破弱三色不变性也就破坏了回收器的正确性或者说在破坏强弱三色不变性时必须引入额外的辅助操作。弱三色不变性的好处在于只要存在未访问的能够到达白色对象的路径就可以将黑色对象指向白色对象。如果我们考虑并发的用户态代码回收器不允许同时停止所有赋值器就涉及了存在的多个不同状态的赋值器。为了对概念加以明确还需要换一个角度把回收器视为对象把赋值器视为影响回收器这一对象的实际行为即影响 GC 周期的长短从而引入赋值器的颜色黑色赋值器已经由回收器扫描过不会再次对其进行扫描。灰色赋值器尚未被回收器扫描过或尽管已经扫描过但仍需要重新扫描。赋值器的颜色对回收周期的结束产生影响如果某种并发回收器允许灰色赋值器的存在则必须在回收结束之前重新扫描对象图如果重新扫描过程中发现了新的灰色或白色对象回收器还需要对新发现的对象进行追踪但是在新追踪的过程中赋值器仍然可能在其根中插入新的非黑色的引用如此往复直到重新扫描过程中没有发现新的白色或灰色对象。于是在允许灰色赋值器存在的算法中最坏的情况下回收器只能将所有赋值器线程停止才能完成其根对象的完整扫描也就是我们所说的 STW。为了确保强弱三色不变性的并发指针更新操作需要通过赋值器屏障技术来保证指针的读写操作一致。因此我们所说的 Go 中的写屏障、混合写屏障其实是指赋值器的写屏障。赋值器的写屏障作为一种同步机制使赋值器在进行指针写操作时能够通知回收器进而不会破坏弱三色不变性。有两种非常经典的写屏障Dijkstra 插入屏障和 Yuasa 删除屏障。灰色赋值器的 Dijkstra 插入屏障的基本思想是避免满足条件 1// 灰色赋值器 Dijkstra 插入屏障 func DijkstraWritePointer(slot *unsafe.Pointer, ptr unsafe.Pointer) { shade(ptr) *slot ptr }为了防止黑色对象指向白色对象应该假设*slot可能会变为黑色为了确保ptr不会在被赋值到*slot前变为白色shade(ptr)会先将指针ptr标记为灰色进而避免了条件 1。Dijkstra 插入屏障的好处在于可以立刻开始并发标记但存在两个缺点由于 Dijkstra 插入屏障的保守在一次回收过程中可能会残留一部分对象没有回收成功只有在下一个回收过程中才会被回收在标记阶段中每次进行指针赋值操作时都需要引入写屏障这无疑会增加大量性能开销。为了避免造成性能问题Go 团队在最终实现时没有为所有栈上的指针写操作启用写屏障而是当发生栈上的写操作时将栈标记为灰色但此举产生了灰色赋值器将会需要标记终止阶段 STW 时对这些栈进行重新扫描。黑色赋值器的 Yuasa 删除屏障的基本思想是避免满足条件 2// 黑色赋值器 Yuasa 屏障 func YuasaWritePointer(slot *unsafe.Pointer, ptr unsafe.Pointer) { shade(*slot) *slot ptr }为了防止丢失从灰色对象到白色对象的路径应该假设*slot可能会变为黑色为了确保ptr不会在被赋值到*slot前变为白色shade(*slot)会先将*slot标记为灰色进而该写操作总是创造了一条灰色到灰色或者灰色到白色对象的路径进而避免了条件 2。Yuasa 删除屏障的优势在于不需要标记结束阶段的重新扫描结束时能够准确地回收所有需要回收的白色对象缺陷是 Yuasa 删除屏障会拦截写操作进而导致波面的退后产生冗余的扫描。Go 的混合写屏障Go 在 1.8 的时候为了简化 GC 的流程同时减少标记终止阶段的重扫成本将 Dijkstra 插入屏障和 Yuasa 删除屏障进行混合形成混合写屏障。该屏障提出时的基本思想是对正在被覆盖的对象进行着色且如果当前栈未扫描完成则同样对指针进行着色。但在最终实现时原提案中对ptr的着色还额外包含对执行栈的着色检查但由于时间有限并未完整实现过所以混合写屏障在目前的实现伪代码是// 混合写屏障 func HybridWritePointerSimple(slot *unsafe.Pointer, ptr unsafe.Pointer) { shade(*slot) shade(ptr) *slot ptr }在这个实现中如果无条件对引用双方进行着色自然结合了 Dijkstra 和 Yuasa 写屏障的优势但缺点也非常明显因为着色成本是双倍的而且编译器需要插入的代码也成倍增加随之带来的结果就是编译后的二进制文件大小也进一步增加。为了针对写屏障的性能进行优化Go 1.10 前后Go 团队随后实现了批量写屏障机制。其基本想法是将需要着色的指针统一写入一个缓存每当缓存满时统一对缓存中的所有ptr指针进行着色。在 Go 的演进历史上混合写屏障的引入使得 STW 停顿时间缩减至半个毫秒左右并彻底移除了栈的重扫描过程相关版本细节可参考 4-历史及演进.md。10. 延伸阅读从认识到调优本文作为垃圾回收的认识篇回答的是GC 是什么、如何运转、如何观察、存在哪些难点的问题。仓库中与本文一脉相承的后续内容还包括2-垃圾回收机制的实现.mdGC 的五阶段流程、主动/被动触发时机、步调Pacing算法与标记辅助Mark Assist机制3-垃圾回收的优化问题.mdGC 关注的四类指标以及通过控制 goroutine 数量、使用sync.Pool复用内存、调整GOGC三个实战例子演示如何定位与缓解 GC 瓶颈对应示例代码位于 content/memgc/code/144-历史及演进.md从 Go 1 串行三色标记清扫到 Go 1.14 异步抢占的演进脉络以及并发栈重扫、ROC、传统分代 GC 等未被采用的方案5-总结.md对二十个 GC 问题的整体收束与主要参考文献。此外与 GC 紧密相关的底层机制还包括决定对象分配在栈上还是堆上的 1-逃逸分析是怎么进行的.md以及 goroutine 调度器对 GC 扫描栈的影响见 content/sched 模块。在面试与实践中将这几块知识串联起来才能真正把 Go 内存管理与性能调优融会贯通。赞分享文档教程【免费下载链接】go-questions Go 程序员面试笔试宝典 | 从问题切入串连 Go 语言相关的所有知识融会贯通。 https://golang.design/go-questions项目地址https://gitcode.com/gh_mirrors/go/go-questions点击查看免费下载相关推荐go-questions 深度解析Go 垃圾回收GC调优实战——从性能指标、三大调优案例到调优 APIgo questions 深度解析Go 垃圾回收GC调优实战——从性能指标、三大调优案例到调优 API Go 的 GC垃圾回收被设计为成比例触发、大部文档教程终极指南Go 垃圾回收演进之路 — 从标记清除到并发 GC 的技术革命终极指南Go 垃圾回收演进之路 — 从标记清除到并发 GC 的技术革命 Go 语言Golang的垃圾回收机制是其核心特性之一它从最初的简单标记清除算法发后端认证鉴权运维网络安全Go 语言 GC 的历史演进与现存问题从串行三色标记到混合写屏障Go 语言 GC 的历史演进与现存问题从串行三色标记到混合写屏障 导读 本文是「Go 程序员面试笔试宝典」垃圾回收器专题中历史及演进一节的完整展开系统梳文档教程上一篇ElasticJob调度算法完全指南如何自定义分布式任务调度策略下一篇3分钟看懂微信数据库密钥提取PyWxDump工作原理与现状完整解析创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表