protobuf-net是Unity3D游戏开发中被广泛使用的Google Protocol Buffer库的c#版本,之所以c#版本被广泛使用,是因为c++版本的源代码不支持Unity3D游戏在各个平台上的动态库构建。它是一个网络传输层协议,对应的lua版本有两个可用的库:一个是proto-gen-lua,由tolua作者开发,另外一个是protoc,由云风开发。protobuf-net在GC上有很大的问题,在一个高频率网络通讯的状态同步游戏中使用发现GC过高,所以对它进行了一次比较彻底的GC优化。下面是优化前后的对比图:
protobuf-net优化前GC和性能效果图
protobuf-net优化后GC和性能效果图
有关Unity3D垃圾回收的基本概念和优化策略Unity官网有发布过文章:Optimizing garbage collection in Unity games。这篇文章讲述了Unity3D垃圾回收机制,和一些简单的优化策略,讨论的不是特别深入,但是广度基本上算是够了。我罗列一下这篇文章的一些要点,如果你对其中的一些点不太熟悉,建议仔细阅读下这篇文章:
1、C#变量分为两种类型:值类型和引用类型,值类型分配在栈区,引用类型分配在堆区,GC关注引用类型
2、GC卡顿原因:堆内存垃圾回收,向系统申请新的堆内存
3、GC触发条件:堆内存分配而当内存不足时、按频率自动触发、手动强行触发(一般用在场景切换)
4、GC负面效果:内存碎片(导致内存变大,GC触发更加频繁)、游戏顿卡
5、GC优化方向:减少GC次数、降低单次GC运行时间、场景切换时主动GC
6、GC优化策略:减少对内存分配次数和引用次数、降低堆内存分配和回收频率
7、善用缓存:对有堆内存分配的函数,缓存其调用结果,不要反复去调用
8、清除列表:而不要每次都去new一个新的列表
9、用对象池:必用
10、慎用串拼接:缓存、Text组件拆分、使用StringBuild、Debug.Log接口封装(打Conditional标签)
11、警惕Unity函数调用:GameObject.name、GameObject.tag、FindObjectsOfType<T>()等众多函数都有堆内存分配,实测为准
12、避免装箱:慎用object形参、多用泛型版本(如List<T>)等,这里的细节问题很多,实测为准
13、警惕协程:StartCoroutine有GC、yield return带返回值有GC、yield return new xxx有GC(最好自己做一套协程管理)
14、foreach:unity5.5之前版本有GC,使用for循环或者获取迭代器
15、减少引用:建立管理类统一管理,使用ID作为访问token
16、慎用LINQ:这东西最好不用,GC很高
17、结构体数组:如果结构体中含有引用类型变量,对结构体数组进行拆分,避免GC时遍历所有结构体成员
18、在游戏空闲(如场景切换时)强制执行GC
先分析下序列化GC,deep profile如下:
打开PropertyDecorator.cs脚本,找到Write函数如下:
可以看到这里MonoProperty.GetValue产生GC的原因是因为反射的使用;而ListDecorator.Write对应于代码Tail.Write,继续往下看:
找到对应源代码:
可以看到这里的GC是由list遍历的foreach引起的。继续往内展开,产生GC的点全部是这两个原因上。
找到第一个产生GC的分支:
同上述分析,MonoProperty.GetValue、MonoProperty.SetValue产生GC原因是反射。而Int32Serializer.Read()代码如下:
可见产生GC的原因是因为装箱。继续往下展开ListDecorateor.Read函数:
由Activator.CreateInstance得出这里产生GC的原因是实例的创建。继续往下展开:
GC的产生发生在List.Add的GrowIfNeeded,可见是列表扩容。这里本质上是因为上一步创建了新对象,如果不创建新对象,那么这里的list可以用Clear而无须新建,那么就不会有扩容的问题。继续往下面追:
反射和装箱产生GC上面已经提到,看ProtoReader.AppendBytes代码:
可见,这里产生GC的原因是因为new byte[]操作。
protobuf-net在本次协议测试中GC产生的原因总结如下:
1、反射
2、forearch
3、装箱
4、创建新的pb对象
5、创建新的字节数组
下面对症下药。
用过lua的人都知道,不管是tolua还是xlua,去反射的方式是生成wrap文件,这里去反射可以借鉴同样的思想。
反射产生的地方在protobuf-net的装饰类中,具体是PropertyDecorator,我这里并没有去写工具自动生成Wrap文件,而是对指定的协议进行了Hook。
foreach对列表来说改写遍历方式就好了,我这里没有对它进行优化,因为Unity5.5以后版本这个问题就不存在了。篇首优化后的效果图中还有一点残留就是因为这里捣鬼。
要消除这里的装箱操作,需要重构代码,而protobuf-net内部大量使用了object进行参数传递,这使得用泛型编程来消除GC变得不太现实。我这里是自己实现了一个无GC版本的装箱拆箱类ValueObject,使用方式十分简单,类似:
其中ValueObject.Get是装箱,而ValueObject.Value<T>是拆箱,装箱和拆箱的步骤必须一一对应。
对于protobuf-net反序列化的时候会创建pb对象这一点,最合理的方式是使用对象池,Hook住protobuf-net创建对象的地方,从对象池中取对象,而不是新建对象,用完以后再执行回收。池接口如下:
对于new byte[]操作的GC优化也是一样的,只不过这里使用的缓存池是针对字节数组而非pb对象,我这里是自己实现了一套通用的字节流与字节buffer缓存池StreamBufferPool,每次需要字节buffer时从中取,用完以后放回。
以上关键的优化方案都已经有了,具体怎么部署到protobuf-net的细节问题这里不再多说,有兴趣的朋友自己去看下源代码。这里就优化以后的protobuf-net使用方式做下介绍,首先是目录结构:
protobuf-net-gc-optimization工程结构
1、CustomDatastruct:自定义的数据结构
2、Protobuf-extension/Protocol:测试协议
3、Protobuf-extension/ProtoFactory:包含两个部分,其中ProtoPool是pb对象池,而ProtoSerializer是对protobuf-net装饰器的扩展,用于特定协议的去反射
4、ProtoBufSerializer:Protobuf-net对外接口的封装。
主要看下ProtoBufSerializer脚本:
其中:
1、AddCustomSerializer:用于添加自定义的装饰器到protobuf-net
2、AddProtoPool:用于添加自定义对象池到protobuf-net
3、Serialize:提供给逻辑层使用的序列化接口
4、Deserialize:提供给逻辑层使用的反序列化接口
使用示例:
protobuf-net的GC优化实践要说的就这么多,其实做GC优化的大概步骤就是这些:GC分析,优化方案,最后再重构代码。这里再补充一些其它的内容,CustomDatastruct中包含了:
1、BetterDelegate:泛型委托包装类,针对深层函数调用树中使用泛型委托作为函数参数进行传递时代码编写困难的问题。
2、BetterLinkedList:无GC链表
3、BetterStringBuilder:无GC版StrigBuilder
4、StreamBufferPool:字节流与字节buffer缓存池
5、ValueObject:无GC装箱拆箱
6、ObjPool:通用对象池
其中protobuf-net的无GC优化用到了StreamBufferPool、ValueObject与ObjPool,主要是对象池和免GC装箱,其它的在源代码中有详细注释。TestScenes下包含了各种测试场景:
测试场景
这里对其中关键的几个结论给下说明:
1、LinkedList当自定义结构做链表节点,必须实现IEquatable<T>、IComparable<T>接口,否则Roemove、Cotains、Find、FindLast每次都有GC产生
2、所有委托必须缓存,产生GC的测试一律是因为每次调用都生成了一个新的委托
3、List<T>对于自定义结构做列表项,必须实现IEquatable<T>、IComparable<T>接口,否则Roemove、Cotains、IndexOf、sort每次都有GC产生;对于Sort,需要传递一个委托。这两点的实践上面都已经说明。
其它的测试自行参考源代码。
gitbub地址为:https://github.com/smilehao/protobuf-net-gc-optimization。
联系客服