每天,Pusher
(原作者的一个程序)将数十亿的信息实时地(准确地说是从发送方到达接收方所需时间在100
毫秒以下),其重要原因是Go语言
的低延迟垃圾回收实现。
垃圾回收会导致程序的暂时停止,这是所有实时系统烦恼的根源之一。这篇博客将介绍Go语言
的GC
机制,从而理解它的高效之处。
1.从Haskell到Go
现在的Pusher
原来是用Haskell
语言写的,后来才用Go
重写,根本原因在于GHC
有根本性的延迟问题。更具体地说,是因为GHC
的垃圾回收器是根据Working set
(内存内的对象)到达某一个比例后来进行一次会导致全局中止的垃圾回收处理。这样当内存中存在大量对象时,会每隔数百毫秒就进行一次垃圾回收,而且是没有意义的垃圾回收(可能只有极少数的未被引用的对象)。
而Go
的垃圾回收期则可以与其他线程并行执行,避免了全局停止时间的出现。甚至每一次版本更新,都能感受到延迟的进一步降低,可见Go
开发团队对此的重视程度。
2.并行垃圾回收的运行原理
到底Go
是如何实现GC
的并行处理的呢?其核心在于三色标记和扫除算法,一下的图片将展示此算法的运行机制,请重点留意它是如何实现并行处理的。
2.1.时期1:程序运行
改程序对多个链表进行操作,一开始共有A
、B
、C
三个节点对象,红色的对象A
和B
是根对象,通常来说都是可达的。垃圾回收器将对象分为黑、灰、白三个集合,因为现在GC周期
还没开始,所以他们都属于白色集合。
2.2.时期2:程序运行
新增一个对象D
,作为A
的next节点
,因为一般GC线程
初始化之后新增的对象都会被分到灰色集合中,D
也不例外。
2.3.时期3:GC扫描
GC周期
开始时,根对象都将移动到灰色集合中,此时灰色集合中有A
、B
、D
三个对象。注意,此时正常程序并没有因此而停止。
2.4.时期4:GC扫描
为了实行扫描,GC
首先选择根对象A
,把它移动到黑色集合并把它所引用的子对象移动到灰色集合,此时A
只引用了D
,而D
本来就属于灰色集合,因此并不需要移动。无论进行到哪个阶段,GC
都可以计算出剩余的对象移动次数=2*|white|+|grey|
,把全部阶段都完成至少需要一次的移动,以使得剩余的对象移动次数=0
。
2.5.时期5:程序运行
新对象E
生成,并作为C
的next节点
,正如时期2
所说的,它被分配到灰色集合。程序也因此而增加了所需的GC
阶段数,导致最终的扫除阶段被延迟。
2.6.时期6:程序运行
此时B
把next指针
指向了对象E
,从而使对象C
变成不可达对象。也就是说,对象C
将残留在白色集合中,这个集合正是最终的扫除阶段会被回收内存空间的。
2.7.时期7:GC扫描
扫描继续进行,GC
这次选择了对象D
,但D
并没有下层对象,即本次无可移动到灰色集合的对象。
2.8.时期8:程序运行
此时B
又把next指针
设置为空,对象E
也变得不可达。嗯,正如你所想的,E
位于灰色集合中,并不能被回收,是不是会有内存泄露的风险啊?但这实际上并不是问题,E
将在下一次GC周期
被回收。三色标记和扫除算法能保证在GC周期
开始时不可达的对象将会在周期结束时被回收。
2.9.时期9:GC扫描
GC
这次选择了对象E进行扫描,因此E
将被移动到黑色集合,但E
并没有下层对象,注意这里对象C
将永远不会移动到其他集合,因为它是E
的上层对象而不是下层对象。
2.10.时期10:GC扫描
GC
在最后将选择灰色集合中的对象B
进行扫描,此时灰色集合将变为空。
2.11.时期11:GC清除
GC
将回收白色集合中的对象(垃圾)的内存空间,它们是绝对的不可达对象,可以放心地杀死。而对象E是在该GC周期
内突然变得不可达的,将留在下一个GC周期
才被清除。
2.12.时期12:GC重置
在实际运用中,没有必要把所有黑色集合中的存留对象再移动会白色集合,只需将黑色集合重新解释为白色集合,白色集合重新解释为黑色集合即可,既简单又快速。
3.两个全局停止时期
第一个是为了确定根对象的栈空间扫描,第二个是GC扫描
的最终清除阶段。好消息是,第二个全局停止时期在最近的版本中已经被优化了,完全可以避免。然而这两个全局停止时期即使对于一个很大的堆也不用1毫秒
即可完成。
4.Latency(延迟) vs. Throughput(吞吐量)
即使利用并行GC
对于很大的堆也能提供大规模的低延迟服务,那为什么还有很多人选择全局停止的垃圾回收器(比如Haskell
的GHC
)呢?Go
的并行垃圾回收器与GHC
的全局停止GC
估计只好那么一点点吧?
实际上,Go
实现如此的低延迟是有代价的,其中最大的是吞吐量的下降。由于需要实现并行处理,线程间同步和多余的数据生成复制都会占用实际逻辑业务代码运行的时间。GHC
的全局停止GC
对于实现高吞吐量来说是十分合适的,而Go
则更擅长与低延迟。
并行GC
的第二个代价是不可预测的堆空间扩大。程序在GC
的运行期间仍能不断分配任意大小的堆空间,因此我们需要在到达最大的堆空间之前实行一次GC
,但是过早实行GC
会造成不必要的GC扫描
,这也是需要衡量利弊的。因此在使用Go
时,需要自行保证程序有足够的内存空间。