一 protobuf-net優化效果圖
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游戲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
三 protobuf-net GC分析
3.1 protobuf-net序列化
先分析下序列化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的點全部是這兩個原因上。
3.2 protobuf-net反序列化
找到第一個產生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優化方案
protobuf-net在本次協議測試中GC產生的原因總結如下:
1、反射
2、forearch
3、裝箱
4、創建新的pb對象
5、創建新的字節數組
下面對症下葯。
4.1 去反射
用過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<int>(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<int>(value); 26 break; 27 case 6: 28 data.server_to_slot = ValueObject.Value<int>(value); 29 break; 30 case 7: 31 data.server_curr_frame = ValueObject.Value<int>(value); 32 break; 33 case 8: 34 data.is_check_frame = ValueObject.Value<int>(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。
4.2 foreach
foreach對列表來說改寫遍歷方式就好了,我這里沒有對它進行優化,因為Unity5.5以后版本這個問題就不存在了。篇首優化后的效果圖中還有一點殘留就是因為這里搗鬼。
4.3 無GC裝箱
要消除這里的裝箱操作,需要重構代碼,而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<int>(value), dest); 9 }
其中ValueObject.Get是裝箱,而ValueObject.Value<T>是拆箱,裝箱和拆箱的步驟必須一一對應。
4.4 使用對象池
對於protobuf-net反序列化的時候會創建pb對象這一點,最合理的方式是使用對象池,Hook住protobuf-net創建對象的地方,從對象池中取對象,而不是新建對象,用完以后再執行回收。池接口如下:

1 /// <summary> 2 /// 說明:proto網絡數據緩存池需要實現的接口 3 /// 4 /// @by wsh 2017-07-01 5 /// </summary> 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 }
4.5 使用字節緩存池
對於new byte[]操作的GC優化也是一樣的,只不過這里使用的緩存池是針對字節數組而非pb對象,我這里是自己實現了一套通用的字節流與字節buffer緩存池StreamBufferPool,每次需要字節buffer時從中取,用完以后放回。
五 protobuf-net GC優化實踐
以上關鍵的優化方案都已經有了,具體怎么部署到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 /// <summary> 7 /// 說明:ProtoBuf初始化、緩存等管理;序列化、反序列化等封裝 8 /// 9 /// @by wsh 2017-07-01 10 /// </summary> 11 12 public class ProtoBufSerializer : Singleton<ProtoBufSerializer> 13 { 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<ntf_battle_frame_data>(); 10 ntf_battle_frame_data.one_slot oneSlot = ProtoFactory.Get<ntf_battle_frame_data.one_slot>(); 11 ntf_battle_frame_data.cmd_with_frame cmdWithFrame = ProtoFactory.Get<ntf_battle_frame_data.cmd_with_frame>(); 12 one_cmd oneCmd = ProtoFactory.Get<one_cmd>(); 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);//*************回收,很重要
六 Unity3D游戲GC優化實踐
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產生

1 // 重要:對於自定義結構一定要繼承IEquatable<T>接口並實現它 2 // 此外:對於Sort,實現IComparable<T>接口,則在傳入委托的時候可以和系統簡單值類型一樣 3 public struct CustomStruct : IEquatable<CustomStruct>, IComparable<CustomStruct> 4 { 5 public int a; 6 public string b; 7 8 public CustomStruct(int a, string b) 9 { 10 this.a = a; 11 this.b = b; 12 } 13 14 public bool Equals(CustomStruct other) 15 { 16 return a == other.a && b == other.b; 17 } 18 19 public int CompareTo(CustomStruct other) 20 { 21 if (a != other.a) 22 { 23 return a.CompareTo(other.a); 24 } 25 26 if (b != other.b) 27 { 28 return b.CompareTo(other.b); 29 } 30 31 return 0; 32 } 33 34 // 說明:測試正確性用的,不是必須 35 public override string ToString() 36 { 37 return string.Format("<a = {0}, b = {1}>", a, b); 38 } 39 }
2、所有委托必須緩存,產生GC的測試一律是因為每次調用都生成了一個新的委托

1 public class TestDelegateGC : MonoBehaviour 2 { 3 public delegate void TestDelegate(GameObject go, string str, int num); 4 public delegate void TestTDelegate<T,U,V>(T go, U str, V num); 5 6 Delegate mDelegate1; 7 Delegate mDelegate2; 8 TestDelegate mDelegate3; 9 TestTDelegate<GameObject, string, int> mDelegate4; 10 TestDelegate mDelegate5; 11 Comparison<int> mDelegate6; 12 Comparison<int> mDelegate7; 13 14 int mTestPriviteData = 100; 15 List<int> mTestList = new List<int>(); 16 17 // Use this for initialization 18 void Start () { 19 mDelegate1 = (TestDelegate)DelegateFun; 20 mDelegate2 = Delegate.CreateDelegate(typeof(TestDelegate), this, "DelegateFun"); 21 mDelegate3 = DelegateFun; 22 mDelegate4 = TDelegateFun; 23 24 //static 25 mDelegate5 = new TestDelegate(StaticDelegateFun); 26 mDelegate6 = SortByXXX; 27 mDelegate7 = TSortByXXX<int>; 28 29 mTestList.Add(1); 30 mTestList.Add(2); 31 mTestList.Add(3); 32 } 33 34 // Update is called once per frame 35 void Update () { 36 // 不使用泛型 37 TestFun(DelegateFun); 38 TestFun(mDelegate1 as TestDelegate); //無GC 39 TestFun(mDelegate2 as TestDelegate); //無GC 40 TestFun(mDelegate3); //無GC,推薦 41 TestFun(mDelegate5); //無GC 42 // 使用泛型,更加通用 43 TestTFun(TDelegateFun, gameObject, "test", 1000);//每次調用產生104B垃圾 44 TestTFun(mDelegate4, gameObject, "test", 1000);// 無GC,更通用,極力推薦*********** 45 // Sort測試 46 mTestList.Sort();//無GC 47 TestSort(SortByXXX);//每次調用產生104B垃圾 48 TestSort(mDelegate6);//無GC 49 TestSort(TSortByXXX);//每次調用產生104B垃圾 50 TestSort(TSortByXXX);//每次調用產生104B垃圾 51 TestSort(mDelegate7);//無GC 52 } 53 54 private void TestFun(TestDelegate de) 55 { 56 de(gameObject, "test", 1000); 57 } 58 59 private void TestTFun<T, U, V>(TestTDelegate<T, U, V> de, T arg0, U arg1, V arg2) 60 { 61 de(arg0, arg1, arg2); 62 } 63 64 private void TestSort<T>(List<T> list, Comparison<T> sortFunc) 65 { 66 list.Sort(sortFunc); 67 } 68 69 private void TestSort(Comparison<int> sortFunc) 70 { 71 mTestList.Sort(sortFunc); 72 } 73 74 private void DelegateFun(GameObject go, string str, int num) 75 { 76 } 77 78 private void TDelegateFun<T, U, V>(T go, U str, V num) 79 { 80 } 81 82 private static void StaticDelegateFun(GameObject go, string str, int num) 83 { 84 } 85 86 private int SortByXXX(int x, int y) 87 { 88 return x.CompareTo(y); 89 } 90 91 private int TSortByXXX<T>(T x, T y) where T : IComparable<T> 92 { 93 return x.CompareTo(y); 94 } 95 }
3、List<T>對於自定義結構做列表項,必須實現IEquatable<T>、IComparable<T>接口,否則Roemove、Cotains、IndexOf、sort每次都有GC產生;對於Sort,需要傳遞一個委托。這兩點的實踐上面都已經說明。
其它的測試自行參考源代碼。
七 項目工程地址
gitbub地址為:https://github.com/smilehao/protobuf-net-gc-optimization。