Go 并发垃圾收集器
由于 Go 是一种命令式语言,它的值类型,内存访问模式和 C# (.NET 使用分代垃圾收集器)相当。
事实上,Go 程序通常是处理 request/response 任务(如 HTTP 服务器),这意味着 Go 程序表现出强烈的代际行为,Go 团队正在探索潜在的可以利用代际假说的算法,他们称之为“面向请求的垃圾收集器”。这本质上是一个可以策略调优的分代垃圾收集器。在处理请求/响应这种模式时,通过确保年轻代足够大以使通过处理请求产生的所有垃圾都在其中来优化 GC。(高可用架构译者注:指的是 Go下一代垃圾收集器 Transaction-Oriented Collector)
尽管如此,Go 的当前 GC 是不分代的。只是在后台运行标记/扫描。(高可用架构译者注:并发标记清除算法)
这样使暂停时间非常短 ,但使其他因素更糟糕。从我们的基本理论上面我们可以看到:
- GC吞吐量:GC时间与堆大小同步增长。简单来说,你的程序使用的内存越多,内存释放速度就越慢,你的计算机花费的时间就越多。如果你的程序没有并行化,你可以不用考虑这个问题。
- 整理:因为没有整理,GC 过程会产生内存碎片。程序也不会受益于在缓存中整齐排列的内容。
- 程序吞吐量:因为GC必须在每个周期做很多工作,所以会消耗不少CPU时间。
- 暂停分布:与程序并发运行的任何垃圾收集器都可能遇到Java中“并发模式失败”的问题:您的程序创建垃圾的速度比GC线程可以清除它快。在这种情况下,runtime别无选择只能完全停止程序,等待GC完成垃圾收集。因此当Go团队声明GC暂停非常低时,该声明只能适用于GC具有足够的CPU时间和空间以完成垃圾回收的情况。另外,由于Go编译器缺乏确保线程可以被快速可靠暂停这一功能,会导致暂停时间是否很低取决于您运行的是什么类型的代码(例如,base64 解码单个 goroutine 中的大 blob 会导致暂停时间上升)。
- 堆开销:因为通过标记/扫描收集堆非常慢,您需要大量的空间以确保不会遇见“并发模式故障”。 Go默认使用100%的堆开销会让程序需要的内存量增加一倍。
我们可以看到这些权衡:
服务1分配内存多于服务2,因此STW暂停在服务1中较高。但STW暂停持续时间在两个服务上都下降了一个数量级。我们看到切换后,两个服务后在GC中花费的CPU使用率增加了约20%。
在这个特定的情况下,Go 以更慢的收集器为代价换取暂停时间的数量级下降。这是一个好的权衡吗?暂停时间已经足够低吗?
付出更多的硬件成本以获得较低的暂停时间,在一些情况下未必有意义。如果你的服务器暂停时间从 10msec 降低到 1msec,你的用户真的会注意到吗?如果你必须加倍你的机器数量才能达成这一目的呢?
Go 将暂停时间优化作为首要目标,以至于它似乎愿意将程序减慢至任何数量级,以获得较短暂停。
2.
一. 什么是垃圾回收
曾几何时,内存管理是程序员开发应用的一大难题。传统的系统级编程语言(主要指C/C++)中,程序员必须对内存小心的进行管理操作,控制内存的申请及释放。稍有不慎,就可能产生内存泄露问题,这种问题不易发现并且难以定位,一直成为困扰开发者的噩梦。如何解决这个头疼的问题呢?
过去一般采用两种办法:
- 内存泄露检测工具。这种工具的原理一般是静态代码扫描,通过扫描程序检测可能出现内存泄露的代码段。然而检测工具难免有疏漏和不足,只能起到辅助作用。(Valgrind)
- 智能指针。这是c++中引入的自动内存管理方法,通过拥有自动内存管理功能的指针对象来引用对象,是程序员不用太关注内存的释放,而达到内存自动释放的目的。这种方法是采用最广泛的做法,但是对程序员有一定的学习成本(并非语言层面的原生支持),而且一旦有忘记使用的场景依然无法避免内存泄露。
为了解决这个问题,后来开发出来的几乎所有新语言(java,python,php等等)都引入了语言层面的自动内存管理 – 也就是语言的使用者只用关注内存的申请而不必关心内存的释放,内存释放由虚拟机(virtual machine)或运行时(runtime)来自动进行管理。而这种对不再使用的内存资源进行自动回收的行为就被称为垃圾回收。
二. 常见的垃圾回收算法① 引用计数
这是最简单的一种垃圾回收算法,和之前提到的智能指针异曲同工。对每个对象维护一个引用计数,当引用该对象的对象被销毁或更新时被引用对象的引用计数自动减一,当被引用对象被创建或被赋值给其他对象时引用计数自动加一。当引用计数为0时则立即回收对象。
这种方法的优点是实现简单,并且内存的回收很及时。这种算法在内存比较紧张和实时性比较高的系统中使用的比较广泛,如ios cocoa框架,php,python等。简单引用计数算法也有明显的缺点:
- 频繁更新引用计数降低了性能。一种简单的解决方法就是编译器将相邻的引用计数更新操作合并到一次更新;还有一种方法是针对频繁发生的临时变量引用不进行计数,而是在引用达到0时通过扫描堆栈确认是否还有临时对象引用而决定是否释放。等等还有很多其他方法,具体可以参考这里。
- 循环引用问题。当对象间发生循环引用时引用链中的对象都无法得到释放。最明显的解决办法是避免产生循环引用,如cocoa引入了strong指针和weak指针两种指针类型。或者系统检测循环引用并主动打破循环链。当然这也增加了垃圾回收的复杂度。
② 标记清除
该方法分为两步,标记从根变量开始迭代得遍历所有被引用的对象,对能够通过应用遍历访问到的对象都进行标记为“被引用”;标记完成后进行清除操作,对没有标记过的内存进行回收(回收同时可能伴有碎片整理操作)。
这种方法解决了引用计数的不足,但是也有比较明显的问题:每次启动垃圾回收都会暂停当前所有的正常代码执行,回收是系统响应能力大大降低!当然后续也出现了很多mark&sweep算法的变种(如三色标记法)优化了这个问题。
③ 分代收集
经过大量实际观察得知,在面向对象编程语言中,绝大多数对象的生命周期都非常短。分代收集的基本思想是,将堆划分为两个或多个称为 代(generation)的空间。新创建的对象存放在称为 新生代(young generation)中(一般来说,新生代的大小会比 老年代小很多),随着垃圾回收的重复执行,生命周期较长的对象会被 提升(promotion)到老年代中。因此,新生代垃圾回收和老年代垃圾回收两种不同的垃圾回收方式应运而生,分别用于对各自空间中的对象执行垃圾回收。新生代垃圾回收的速度非常快,比老年代快几个数量级,即使新生代垃圾回收的频率更高,执行效率也仍然比老年代垃圾回收强,这是因为大多数对象的生命周期都很短,根本无需提升到老年代。
三. Go的GC工作机制Go的GC自打一开始就被很多人诟病,经过这么多年的发展Go的GC已经变得非常的优秀了,以下是Go的GC算法里程碑
- v1.1 STW (stop the world)
- v1.3 Mark STW, Sweep 并行
- v1.5 三色标记法
- v1.8 hybrid write barrier
Mark and Sweep (标记清除)
① Mark and Sweep
Mark and Sweep
- 标记(Mark): 找出所有不可达对象,然后做上标记
- 清除(Sweep): 回收标记好的对象
我们通过以下图解来解释 标记清除 算法是如何工作的
- 开始标记,程序暂停,此时程序和对象的关系如下图所示
- 找到所有可达对象,并做上标记
- 标记完成后开始清除未标记的对象
- 清除完成后,对象如下图所示
标记清除
- STW (stop the world) 标记对象的时候程序需要暂停,导致程序出现卡顿 (最主要的问题)
- 标记需要扫描整个堆
- 清除对象会产生堆碎片
STW
② Tri-color Marking
标记清除Tri-color Marking
白色灰色灰色黑色灰色白色黑色白色
三色标记法STW
标记操作和用户逻辑并行写屏障清除操作和用户逻辑并行
通过允许用户逻辑在标记和清除操作上做到并行处理来缩短STW的时间,提升整体GC的性能。