背景
Golang的垃圾回收器使用的是并行三色标记回收算法。该算法对比分代算法的最大问题就是,无法区分年轻代和老年代对象,如果老年代对象非常多的话,新生代对象的回收效率就会下降。如果程序没有减慢对象分配速度的话,golang为了避免出现内存不足,会强制降低相关协程的执行速度,挤出cpu帮助垃圾回收。结果就是程序出现延迟和卡顿,尤其业务频繁的时候。这对游戏玩家来说是非常糟糕的体验。
有没有办法解决这个问题呢?当然有,思路很显然,对象太多就减少对象分配,回收慢就提高回收效率。但是具体怎么操作,又要保证安全,又要保证有可操作性,就很难了。
分析
减少对象分配,一个是少创建对象,逻辑层面可以合并一些对象,用值代替指针,都是需要调整逻辑结构的微观层面的操作方法,有效但是杯水车薪。
重用对象也能减少分配,首当其冲的办法就是对象池。对象池简单实用,但是缺点就是太简陋了。分配的对象类型固定,还要使用者手动放回,而且保证不再操作已放回的对象。这对对象类型非常繁多,关系非常复杂的程序来说,可操作性不高。而且如果没有gc兜底,对象泄露了也很难捕捉。对于某些大型对象,关系简单的情况,效果很好。
提高回收效率的话,golang本身做了很多底层回收优化,留给应用层的空间不大。针对老生代对象问题,如果能够让GC跳过这些对象的扫描,必然节约很大的开销。可惜golang没有直接提供对应的支持,唯一发现的就是go:notinheap标记,可惜主要作用是优化写屏障,卡的很死。
还有一个大杀器CGO,CGO调用C运行库跳过GC分配内存,也跳过了GC扫描,非常好的思路。但是C内存对象在go里面使用限制太多,像map这种常用数据接口根本没有对应办法实现,因为不能操作符重载和泛型,结果就是使用起来太复杂了。所有的数据操作方式都要走C的风格,像是回到了远古时代,简直是对高级语言的侮辱。
因为实际情况是,对象数量确实是减少不了多少的,剩下唯一的办法只有减少GC或者不GC。其实CGO如果可行的话,对象被搬运到C,既能减少go这边的大量对象分配,也就减少了GC的压力,非常完美。可惜差一点就达成。如果哪天CGO可以像微软的C++/CLI一样流畅的跨语言操作.NET,就牛逼了。这点还是不得不佩服微软的技术实力。当然.NET设计之初就是为了跨语言而生的,情况有点不一样。扯远了。
回头来说,有没有办法用go实现一个GC透明的分配器呢。
答案是肯定的。
但是,需要考虑几个问题,是池子加强版吗?那本质还是池子啊!肯定不能是池子,我们要的是一个真正的分配器。他能自己管理内存,绕过GC的扫描,能从内存中任意分配对象,这才能满足我们的需求。
思路
终于知道我们需要什么了!为了不重复造轮子,先看看有没有现成的成熟方案吧。
- https://github.com/shoenig/offheap 太简陋了,基本等于啥都没有
- offheap package - github.com/glycerine/offheap - pkg.go.dev 这个好像功能很多,但是好像主要是优化hash表,而且还是要手动释放,接口使用起来有点繁琐,对老代码改动太大了。
- https://github.com/cachelot/cachelot好像主要是像cache一样使用呢,也是要大改代码
没有一个符合我们需求的轮子呢,自己撸一个可行吗?那就试试吧。
纯CGO的实现还是算了,用起来麻烦,调试也麻烦,编译还贼慢,直接pass。既然选择golang就是为了方便啊。那就直接用golang实现吧,还能利用golang的高级功能呢!
内存不就是byte数组吗?这个简单。怎么把一个地址转成对象呢?unsafe指针强转貌似可以,reflect.NewAt也可以,这个NewAt不就是简化版C++的placement new吗?爱了。
分配的对象怎么回收呢?一个个手动放回吗?那还是洗洗睡吧。干嘛要放回呢?我们是老生代对象,可以永远不回收的。配置数据就是全局数据,加载好了永远都不用释放。那就好处理了。
还有一堆新生代对象呢?好多临时对象,比如发到网络上的包,发完了就废弃了。简单,我直接把整个分配器一起扔了不就可以了,nice。
所以大概的思路就出来了,我就一直分配对象不回收,最后整个分配器一起丢掉。中间只有分配器占用了一个byte数组对象需要GC扫描,但是因为Go认为数组里面没有指针,所以就只标记数组头为可达,扫描就完成了。目标达成!
注:为什么byte数组化分出来的对象也是被指针引用,GC就不扫描呢,因为GO的GC是根据GO分配对象当时的对象信息来确定要不要扫描的,细节请看go的垃圾回收文档。虽然有指针引用我们分配的对象,但是从这个指针指向的地址去找heap中对应的内存块的时候,找到的是byte数组,他的对象信息里面是没有子对象的,因此此处的扫描结束。
实现
这一直分配的方式,熟悉的人一下就发现是线性分配器。搞C++的游戏开发不会不知道这些。在C++里面定制分配器简直就是家常便饭。终于发现C++搞多了还是有很多好处的。
文章有点长了,长话短说不卖关子了。要注意的几点:
- 线性分配器分配的对象对GC是透明的,那就不能把原生分配器分配的对象挂到上面,会被GC认为是无人引用的对象回收掉,所以需要能自动识别这种情况。方法就是分配器分配的对象保存下来,通过反射挨个递归地从每个对象遍历子对象,检查是否有不是分配器分配的内存。(用golang来实现的一个好处就是能用golang的反射,COG的方案就没有这个好处)
- 不能在分配器销毁后,继续使用分配器分配的对象。C++的内存诊断工具的做法是把对应的内存页设为不可读,读和写会触发硬件中断。不过这个实现起来有点复杂,而且内存开销非常大。简单点的方法,分配器返回id,使用的时候再去取对象,这个需要改变对象的使用方式,对老代码很不友好。其他简单实用的方法目前没有找到,如果在宏观层面能保证的话,问题也不大。
- 分配器不能分配失败。简单,buffer不够了再加就行了,然后用新buffer继续分配。绝对不能使用原生的分配器分配对象再挂到分配器上,因为挂载的过程不一定是gc原子性的(我发明的词汇,参考GC safe-point),坑跟data race一样隐蔽(不过data race有detector而已)。
- 分配器也可以重用,用完reset丢到池子里面,取出来又是一条好汉。免得频繁创建分配器,虽然大部分时候go的原生分配器很快,但是高峰的时候就不一定了。(而且有golang的gc兜底,忘了放回去也不会有大问题。COG的方案就没有这个好处)
大的问题都解决了,其他就是小细节了,可以参看具体实现:
仓库还在测试阶段不一定稳定。等上线了再反馈优化一波。但是请放心,上线的项目完全够压力。
好了,该睡觉了。仿佛梦见CPU再也不飚了,游戏再也不延迟了,完美。
参考:
go:notinheap
applies to type declarations. It indicates that a type must never be allocated from the GC'd heap or on the stack. Specifically, pointers to this type must always fail theruntime.inheap
check. The type may be used for global variables, or for objects in unmanaged memory (e.g., allocated withsysAlloc
,persistentalloc
,fixalloc
, or from a manually-managed span).
If need be, the Pacer slows down allocation while speeding up marking. At a high level the Pacer stops the Goroutine, which is doing a lot of the allocation, and puts it to work doing marking. The amount of work is proportional to the Goroutine’s allocation. This speeds up the garbage collector while slowing down the mutator.
Go has all goroutines reach a garbage collection safe point with a process called stop the world. This temporarily stops the program from running and turns a write barrieron to maintain data integrity on the heap. This allows for concurrency by allowing goroutines and the collector to run simultaneously.