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
12、避免装箱:慎用object形参、多用泛型版本(如List
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函数如下:
1 public override void Write(object value, ProtoWriter dest) 2 { 3 Helpers.DebugAssert(value != null); 4 value = property.GetValue(value, null); 5 if(value != null) Tail.Write(value, dest); 6 }
可以看到这里MonoProperty.GetValue产生GC的原因是因为反射的使用;而ListDecorator.Write对应于代码Tail.Write,继续往下看:
找到对应源代码:
1 public override void Write(object value, ProtoWriter dest) 2 { 3 SubItemToken token; 4 bool writePacked = WritePacked; 5 if (writePacked) 6 { 7 ProtoWriter.WriteFieldHeader(fieldNumber, WireType.String, dest); 8 token = ProtoWriter.StartSubItem(value, dest); 9 ProtoWriter.SetPackedField(fieldNumber, dest); 10 } 11 else 12 { 13 token = new SubItemToken(); // default 14 } 15 bool checkForNull = !SupportNull; 16 foreach (object subItem in (IEnumerable)value) 17 { 18 if (checkForNull && subItem == null) { throw new NullReferenceException(); } 19 Tail.Write(subItem, dest); 20 } 21 if (writePacked) 22 { 23 ProtoWriter.EndSubItem(token, dest); 24 } 25 }
可以看到这里的GC是由list遍历的foreach引起的。继续往内展开,产生GC的点全部是这两个原因上。
找到第一个产生GC的分支:
同上述分析,MonoProperty.GetValue、MonoProperty.SetValue产生GC原因是反射。而Int32Serializer.Read()代码如下:
1 public object Read(object value, ProtoReader source) 2 { 3 Helpers.DebugAssert(value == null); // since replaces 4 return source.ReadInt32(); 5 }
可见产生GC的原因是因为装箱。继续往下展开ListDecorateor.Read函数:
由Activator.CreateInstance得出这里产生GC的原因是实例的创建。继续往下展开:
GC的产生发生在List.Add的GrowIfNeeded,可见是列表扩容。这里本质上是因为上一步创建了新对象,如果不创建新对象,那么这里的list可以用Clear而无须新建,那么就不会有扩容的问题。继续往下面追:
反射和装箱产生GC上面已经提到,看ProtoReader.AppendBytes代码:
1 public static byte[] AppendBytes(byte[] value, ProtoReader reader) 2 { 3 if (reader == null) throw new ArgumentNullException("reader"); 4 switch (reader.wireType) 5 { 6 case WireType.String: 7 int len = (int)reader.ReadUInt32Variant(false); 8 reader.wireType = WireType.None; 9 if (len == 0) return value == null ? EmptyBlob : value; 10 int offset; 11 if (value == null || value.Length == 0) 12 { 13 offset = 0; 14 value = new byte[len]; 15 } 16 else 17 { 18 offset = value.Length; 19 byte[] tmp = new byte[value.Length + len]; 20 Helpers.BlockCopy(value, 0, tmp, 0, value.Length); 21 value = tmp; 22 } 23 // value is now sized with the final length, and (if necessary) 24 // contains the old data up to "offset" 25 reader.position += len; // assume success 26 while (len > reader.available) 27 { 28 if (reader.available > 0) 29 { 30 // copy what we *do* have 31 Helpers.BlockCopy(reader.ioBuffer, reader.ioIndex, value, offset, reader.available); 32 len -= reader.available; 33 offset += reader.available; 34 reader.ioIndex = reader.available = 0; // we've drained the buffer 35 } 36 // now refill the buffer (without overflowing it) 37 int count = len > reader.ioBuffer.Length ? reader.ioBuffer.Length : len; 38 if (count > 0) reader.Ensure(count, true); 39 } 40 // at this point, we know that len <= available 41 if (len > 0) 42 { // still need data, but we have enough buffered 43 Helpers.BlockCopy(reader.ioBuffer, reader.ioIndex, value, offset, len); 44 reader.ioIndex += len; 45 reader.available -= len; 46 } 47 return value; 48 default: 49 throw reader.CreateWireTypeException(); 50 } 51 }
可见,这里产生GC的原因是因为new byte[]操作。
protobuf-net在本次协议测试中GC产生的原因总结如下:
1、反射
2、forearch
3、装箱
4、创建新的pb对象
5、创建新的字节数组
下面对症下药。
用过lua的人都知道,不管是tolua还是xlua,去反射的方式是生成wrap文件,这里去反射可以借鉴同样的思想。
1 using CustomDataStruct; 2 using ProtoBuf.Serializers; 3 4 namespace battle 5 { 6 public sealed class NtfBattleFrameDataDecorator : ICustomProtoSerializer 7 { 8 public void SetValue(object target, object value, int fieldNumber) 9 { 10 ntf_battle_frame_data data = target as ntf_battle_frame_data; 11 if (data == null) 12 { 13 return; 14 } 15 16 switch (fieldNumber) 17 { 18 case 1: 19 data.time = ValueObject.Value(value); 20 break; 21 case 3: 22 data.slot_list.Add((ntf_battle_frame_data.one_slot)value); 23 break; 24 case 5: 25 data.server_from_slot = ValueObject.Value (value); 26 break; 27 case 6: 28 data.server_to_slot = ValueObject.Value (value); 29 break; 30 case 7: 31 data.server_curr_frame = ValueObject.Value (value); 32 break; 33 case 8: 34 data.is_check_frame = ValueObject.Value (value); 35 break; 36 default: 37 break; 38 } 39 } 40 41 public object GetValue(object target, int fieldNumber) 42 { 43 ntf_battle_frame_data data = target as ntf_battle_frame_data; 44 if (data == null) 45 { 46 return null; 47 } 48 49 switch (fieldNumber) 50 { 51 case 1: 52 return ValueObject.Get(data.time); 53 case 3: 54 return data.slot_list; 55 case 5: 56 return ValueObject.Get(data.server_from_slot); 57 case 6: 58 return ValueObject.Get(data.server_to_slot); 59 case 7: 60 return ValueObject.Get(data.server_curr_frame); 61 } 62 63 return null; 64 } 65 } 66 }
反射产生的地方在protobuf-net的装饰类中,具体是PropertyDecorator,我这里并没有去写工具自动生成Wrap文件,而是对指定的协议进行了Hook。
foreach对列表来说改写遍历方式就好了,我这里没有对它进行优化,因为Unity5.5以后版本这个问题就不存在了。篇首优化后的效果图中还有一点残留就是因为这里捣鬼。
要消除这里的装箱操作,需要重构代码,而protobuf-net内部大量使用了object进行参数传递,这使得用泛型编程来消除GC变得不太现实。我这里是自己实现了一个无GC版本的装箱拆箱类ValueObject,使用方式十分简单,类似:
1 public object Read(object value, ProtoReader source) 2 { 3 Helpers.DebugAssert(value == null); // since replaces 4 return ValueObject.Get(source.ReadInt32()); 5 } 6 public void Write(object value, ProtoWriter dest) 7 { 8 ProtoWriter.WriteInt32(ValueObject.Value(value), dest); 9 }
其中ValueObject.Get是装箱,而ValueObject.Value
对于protobuf-net反序列化的时候会创建pb对象这一点,最合理的方式是使用对象池,Hook住protobuf-net创建对象的地方,从对象池中取对象,而不是新建对象,用完以后再执行回收。池接口如下:
1 ///2 /// 说明:proto网络数据缓存池需要实现的接口 3 /// 4 /// @by wsh 2017-07-01 5 /// 6 7 public interface IProtoPool 8 { 9 // 获取数据 10 object Get(); 11 12 // 回收数据 13 void Recycle(object data); 14 15 // 清除指定数据 16 void ClearData(object data); 17 18 // 深拷贝指定数据 19 object DeepCopy(object data); 20 21 // 释放缓存池 22 void Dispose(); 23 }
对于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 using battle; 2 using CustomDataStruct; 3 using ProtoBuf.Serializers; 4 using System.IO; 5 6 ///7 /// 说明:ProtoBuf初始化、缓存等管理;序列化、反序列化等封装 8 /// 9 /// @by wsh 2017-07-01 10 /// 11 12 public class ProtoBufSerializer : Singleton13 { 14 ProtoBuf.Meta.RuntimeTypeModel model; 15 16 public override void Init() 17 { 18 base.Init(); 19 20 model = ProtoBuf.Meta.RuntimeTypeModel.Default; 21 AddCustomSerializer(); 22 AddProtoPool(); 23 model.netDataPoolDelegate = ProtoFactory.Get; 24 model.bufferPoolDelegate = StreamBufferPool.GetBuffer; 25 } 26 27 public override void Dispose() 28 { 29 model = null; 30 ClearCustomSerializer(); 31 ClearProtoPool(); 32 } 33 34 static public void Serialize(Stream dest, object instance) 35 { 36 ProtoBufSerializer.instance.model.Serialize(dest, instance); 37 } 38 39 static public object Deserialize(Stream source, System.Type type, int length = -1) 40 { 41 return ProtoBufSerializer.instance.model.Deserialize(source, null, type, length, null); 42 } 43 44 void AddCustomSerializer() 45 { 46 // 自定义Serializer以避免ProtoBuf反射 47 CustomSetting.AddCustomSerializer(typeof(ntf_battle_frame_data), new NtfBattleFrameDataDecorator()); 48 CustomSetting.AddCustomSerializer(typeof(ntf_battle_frame_data.one_slot), new OneSlotDecorator()); 49 CustomSetting.AddCustomSerializer(typeof(ntf_battle_frame_data.cmd_with_frame), new CmdWithFrameDecorator()); 50 CustomSetting.AddCustomSerializer(typeof(one_cmd), new OneCmdDecorator()); 51 } 52 53 void ClearCustomSerializer() 54 { 55 CustomSetting.CrearCustomSerializer(); 56 } 57 58 59 void AddProtoPool() 60 { 61 // 自定义缓存池以避免ProtoBuf创建实例 62 ProtoFactory.AddProtoPool(typeof(ntf_battle_frame_data), new NtfBattleFrameDataPool()); 63 ProtoFactory.AddProtoPool(typeof(ntf_battle_frame_data.one_slot), new OneSlotPool()); 64 ProtoFactory.AddProtoPool(typeof(ntf_battle_frame_data.cmd_with_frame), new CmdWithFramePool()); 65 ProtoFactory.AddProtoPool(typeof(one_cmd), new OneCmdPool()); 66 } 67 68 void ClearProtoPool() 69 { 70 ProtoFactory.ClearProtoPool(); 71 } 72 }
其中:
1、AddCustomSerializer:用于添加自定义的装饰器到protobuf-net
2、AddProtoPool:用于添加自定义对象池到protobuf-net
3、Serialize:提供给逻辑层使用的序列化接口
4、Deserialize:提供给逻辑层使用的反序列化接口
使用示例:
1 const int SENF_BUFFER_LEN = 64 * 1024; 2 const int REVIVE_BUFFER_LEN = 128 * 1024; 3 MemoryStream msSend = new MemoryStream(sendBuffer, 0, SENF_BUFFER_LEN, true, true);; 4 MemoryStream msRecive = new MemoryStream(reciveBuffer, 0, REVIVE_BUFFER_LEN, true, true);; 5 6 msSend.SetLength(SENF_BUFFER_LEN); 7 msSend.Seek(0, SeekOrigin.Begin); 8 9 ntf_battle_frame_data dataTmp = ProtoFactory.Get(); 10 ntf_battle_frame_data.one_slot oneSlot = ProtoFactory.Get (); 11 ntf_battle_frame_data.cmd_with_frame cmdWithFrame = ProtoFactory.Get (); 12 one_cmd oneCmd = ProtoFactory.Get (); 13 cmdWithFrame.cmd = oneCmd; 14 oneSlot.cmd_list.Add(cmdWithFrame); 15 dataTmp.slot_list.Add(oneSlot); 16 DeepCopyData(data, dataTmp); 17 ProtoBufSerializer.Serialize(msSend, dataTmp); 18 ProtoFactory.Recycle(dataTmp);//*************回收,很重要 19 20 msSend.SetLength(msSend.Position);//长度一定要设置对 21 msSend.Seek(0, SeekOrigin.Begin);//指针一定要复位 22 //msRecive.SetLength(msSend.Length);//同理,但是如果Deserialize指定长度,则不需要设置流长度 23 msRecive.Seek(0, SeekOrigin.Begin);//同理 24 25 Buffer.BlockCopy(msSend.GetBuffer(), 0, msRecive.GetBuffer(), 0, (int)msSend.Length); 26 27 dataTmp = ProtoBufSerializer.Deserialize(msRecive, typeof(ntf_battle_frame_data), (int)msSend.Length) as ntf_battle_frame_data; 28 29 PrintData(dataTmp); 30 ProtoFactory.Recycle(dataTmp);//*************回收,很重要
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
View Code
2、所有委托必须缓存,产生GC的测试一律是因为每次调用都生成了一个新的委托
View Code
3、List
其它的测试自行参考源代码。
gitbub地址为:https://github.com/smilehao/protobuf-net-gc-optimization。