[04] C# Alloc Free編程之實踐


C# Alloc Free編程之實踐

上一篇說了Alloc Free編程的基本理論. 這篇文章就說怎么具體做實踐.

常識

之所以說是常識, 那是因為我們在學任何一門語言的時候, 都能在各種書上看到各種各樣的best practice. 這些內容也確實是最佳實踐, 需要去遵守. 但是現實代碼里面看到, 大部分都沒有遵守這些簡單的約定.

這里列舉一些常識性的東西:

  • 字符串拼接用String.Format, $表達式, StringBuilder等

    尤其是StringBuilder, 在做一些長一點的字符串拼接, 很有優勢.

    某服務器里面的字符串是密集使用的. 經常會出現String當做Dictionary的Key(這個跟MongoDB有一點關系, MongoDB的dict不能以數字當Key), 然后代碼里面遍地是字符串的拼接(簡單的用+來做). 如果只是做一兩次實際上問題並不大, 但是很多時候是在每個玩家的Loop里面去做, 平白無故分配內存的系數多了幾十倍.

  • 頻繁的使用keys, values訪問容器

    var keys = dict.Keys; foreach(var key in keys) {  //xxx }

    Dictionary下訪問Keys, 和直接foreach差別不是很大. 只是會多new幾個小對象(其實也不應該).

    但是在ConcurrentDictionary下, 訪問成本就比較高了.

     private ReadOnlyCollection<TKey> GetKeys()  {  int toExclusive = 0;  ReadOnlyCollection<TKey> result;  try  {  this.AcquireAllLocks(ref toExclusive);  int countInternal = this.GetCountInternal();  if (countInternal < 0)  {  throw new OutOfMemoryException();  }  List<TKey> list = new List<TKey>(countInternal);  for (int i = 0; i < this.m_tables.m_buckets.Length; i++)  {  for (ConcurrentDictionary<TKey, TValue>.Node node = this.m_tables.m_buckets[i]; node != null; node = node.m_next)  {  list.Add(node.m_key);  }  }  result = new ReadOnlyCollection<TKey>(list);  }  finally  {  this.ReleaseLocks(0, toExclusive);  }  return result;  }

    ConcurrentDictionary訪問Keys會真的遍歷整個字典然后把所有key拷貝一遍. 這個成本就非常高了.

    之所以代碼這么寫, 是因為在項目早期, 出現了遍歷的過程中修改容器的操作, 所以C#會拋出一個異常(C#的迭代器和容器會有版本號, C++的沒有). 然后他們為了避免這個, 才想出這么一個歪門邪路. 正確的做法找到API設計缺陷的地方, 重新設計.

  • 盡量使用struct來保存小的對象

    C#的對象布局, 在class對象的頭部有兩個int64長度額外空間, 一個用來保存同步塊(和HashCode), 另外一個用來保存vtable. 然后才是對象的本身的數據. 所以如果對象的成員非常少(小), 就沒有必要使用class. 一來增加GC的負擔, 一來每次alloc還需要消耗25ns左右的時間.

    C#高版本也有提供ValueTuple這樣的類, 用來減少臨時類/小類產生的額外開銷. C#有值語義和引用語義兩種語義, 所以設計的時候需要考慮其開銷, 更方便的進行控制.

  • 避免裝箱拆箱

    裝箱是指把struct值類型對象, 放到堆上去的過程, 中間也會補齊同步塊和vtable; 拆箱又要把數據從堆上拷貝回來. 所以盡量避免使用System.Collection下面的容器, 而選擇泛型容器.

    這一點上, C#比Java就有一點優勢, 泛型容器的參數可以是值類型. 做深入的思考, Golang的interface對象, 實際上也是一個裝箱的對象, 因為每一個interface都是一個pair<data*, vtable>. 而不同的是, C#的裝箱把data和vtable合並成一個對象了, golang還是兩個對象.

  • 慎用MemoryStream等

    .NET Core內置的MemoryStream等雖然有Slice版本的重載, 但是內部還是會分配額外的數組, 並不是那么輕量級.

    而且MemoryStream繼承自IDisposable接口需要及時Dispose, 否則會有很多內存聲明周期被延后非常多的時間.

    這一點在某游戲服務器最開始的服務器版本內, 沒有考慮到, 最原始的編解碼器在大量使用MemoryStream. 正確的實踐應該是之前文章所提到的大量使用IByteBuffer而不是用Stream.

  • 深拷貝

    服務器或多或少會需要一些深拷貝. 很多程序員就到網上抄的那種JSON序列化然后再反序列化的版本, 只是負責跑通代碼邏輯, 而實際上代碼性能很差. 將JSON序列化換成例如, BSON, 或者.NET Core內置的序列化, 都是不行的.

    深拷貝如果手寫的話, 顯然是一件非常枯燥乏味的事情. 而所有枯燥乏味的事情都是可以通過編譯時期的代碼生成或者運行時的代碼生成來實現. 編譯時期的代碼生成就類似protobuf和protoc這個概念, 編輯好的proto文件重新編譯, 那生成的Message類是可以再clone的; 但是在C#這種具有一定動態性的語言里面, 是不需要這么搞.

    思路有兩種, 一種是運行時反射去遍歷對象的屬性和數據成員, 然后動態的去設置其值; 還有一種是動態的反射該類型的屬性和數據成員, 動態的生成一個函數, 去設置值. 后面這個做法可以做到非常高的性能.

    使用上例如DeepCloner, 就更為簡單:

    var copy = list.DeepClone(); //此處是一個擴展函數
  • protobuf repeated字段

    這邊單獨把Protobuf repeated字段列出來, 是因為在同步客戶端服務器信息的時候, 嚴重依賴repeated字段, 極端情況下甚至可能會出現幾百個元素的數組, 然后這些數組會不停的重新創建, 這一點對GC壓力非常大.

    修改的方式也比較簡單, 在每個Player或者Entity身上都掛在一個Message實例, 同步的時候使用這一個對象; 然后通過反射來修改這個Message上面的私有變量, 減少每次重新構造該Message時的成本.

  • Linq

    Linq對簡化編程有很大的幫助. 但是在高頻函數內濫用, 會導致極大的GC負擔.例如ToList可以將內容拷貝到另外一個長久持有的List里面去, 而不是每次都用完就釋放.

    Linq還有一個問題是很多傳參是需要傳入一個Func(閉包), 用來實現靈活性, 該閉包最終會在堆上, 會產生額外的開銷.

類似的這樣的實踐還有很多, 需要不斷的補充列表進行知識更新.

更進一步

上面只是說了不應該用什么, 或者怎么用, 下面將一些需要修改更多代碼才能實現的優化.

字符串的拼接和轉換

例如某服務器內有大量路徑的拼接, 或者Key的拼接, 但是文件路徑和Key又不會頻繁發生變化, 所以在服務器內部時時刻刻去拼接是恨不合算的事情.

那么對一個Item1, Item2和Item3三段拼成的一個完整的字符. 那么可以可以:

  1. 到全局的只讀Dictionary里面去查找, 找到了返回
  2. 沒找到, 則上lock, 到只寫的Dictionary里面去找, 找到了返回
  3. 沒找到, 給只寫的Dictionary內增加該元素, 然后生成一個拷貝給只讀的對象, 返回

通過很簡單的編程方式(封裝一次多處調用), 就可以大量減少字符串的拼接.

再例如XLua和Lua虛擬機交互的過程中, 因為C#內的String是UTF-16編碼的, 而Lua的String是ASCII兼容的(可以兼容UTF-8編碼), 那么傳遞的過程中必然要產生一次轉換. 對於低頻交互則不會產生問題, 但是高頻不行.

根據觀察發現, 大部分C#傳遞給Lua的字符串都是比較固定的, 所以當時做了一個LRU<String, byte[]>, 把字符串到byte[]的轉換這一步省下來了, 但是byte[]到Lua VM這一步還是沒有省下來.

物理引擎頻繁AllocArray

服務器內用VelcroPhysics來做運動的模擬(防止外掛和穿幫, 還有怪物的移動模擬, 還有少量的碰撞檢測). 在做profile的時候發現其中有一個對象, 在不停的New Array. 這個DistanceProxy對象會獲取物體的幾個點(組成的邊所表達的形狀), 然后在場景內跟不同的物體算距離(應該是做碰撞檢測類似的東西). 每個場景按照25幀的速度去模擬, 那么中間的計算量會產生很多的垃圾對象; 之前做過benchmark, 大概400個玩家的副本, 一分鍾的樣子產生了數十萬個垃圾對象.

所以后來經過仔細研究, 發現DistanceProxy所代表的的物體, 最多是6邊型(6個頂點), 最多的是4邊型. 然后使用的地方也只有兩處, 都是一次性的調用, 基本上就是new一個DistanceProxy對象, 算一下, 就扔掉了. 好在DistanceProxy對象本身是struct.

所以就只需要優化那個Array就行了. 那么可以在每個線程上弄一個Array的Pool, 這個Pool很小, 只需要有2個大小(實際里面塞了4個數組), 然后用的時候從Pool里面Get一個, 用完了歸還.

C#有一個概念叫IDisposable, 意思是有一些非托管資源, 可以用using語句括起來, 在scope結束之后, 語言會做確定性的釋放, 不會產生內存泄漏(不管有沒有發生異常).

所以可以讓這個DistanceProxy對象繼承自IDisposable, 然后調用的釋放就變成了:

DistanceInput input = new DistanceInput(); input.ProxyA = new DistanceProxy(shapeA, indexA); input.ProxyB = new DistanceProxy(shapeB, indexB); input.TransformA = xfA; input.TransformB = xfB; input.UseRadii = true;  using var _1 = input.ProxyA; //重點是這兩句 using var _2 = input.ProxyB;

具體問題具體分析, 找到問題的根本, 改起來實際上比較簡單的.

隱蔽的知識

上面說的那些知識, 是很容易能想到的, 不管是有意還是無意寫出來的. 但是C#還有一些隱性的Alloc, 會被忽視掉.

例如lambda表達式, 或者閉包.

我們在C++里面經常會寫到類似這樣的代碼:

template<typename F> void ForEach(F fn) {  for(const auto& item : vec)  fn(item); }  ForEach([=](const int& item) => {  std::cout << item << std::endl; });

例如這個ForEach的fn參數, 他是按照值來傳遞(最多會被move過去), 這種傳遞方式產生的消耗是很少的; 而且C++對lambda表達式還可以做inline. 最終整個代碼的效率是非常高的, 因為0抽象.

但是在C#里面, 情況就不一樣了.

//1 vec.ForEach((item) => Console.Write(item.ToString()));  //2 var fn = (item) => Console.Write(item.ToString()); Vec.ForEach(fn);

1里面每次代碼執行到ForEach的時候, 都會產生一個臨時的閉包對象, 該對象分配在堆上, 調用完畢就變成垃圾對象; 但是在2里面, 如果我們把fn對象的生命周期變長一點, 那么后面的ForEach調用就不會有額外的開銷.

某服務器內部在大量使用這種lambda表達式. 后來借助VS 2019的.NET 對象分配跟蹤這種優化手段, 找到了所有的高頻調用.

有一些高頻調用僅僅是為了遍歷某一個List或者Dictionary, 直接手動展開, 多寫兩三行代碼, 也不算是很難的事情.

如果.NET CLR逃逸分析的話, 整個問題就會變得簡單, 就不需要編寫這樣的代碼. 好消息是github已經有類似的issue, 而且官方已經在着手處理; 壞消息是不知道哪個版本會加進來.

工具以及優化思路

工具的選擇

工具的選擇很簡單, 只有宇宙第一IDE--VS2019. 然后具體的項是: 調試 -> 性能探查器 -> .NET對象分配跟蹤 -> 自定義100個對象采集一次. 每個對象都跟蹤的話, 服務器會跑的非常慢. 所以每100個采集一次就夠了.

然后開啟機器人, 跑具體的業務邏輯. 跑個一兩分鍾就可以停下來, 查看報告.

 

 

從這張圖里面可以看到某種類型的對象分配的次數, 和哪里分配的比較多. 重點找那些邏輯層里面導致的, 因為像MongoDB ClientDotNetty里面分配比較多的對象, 也沒辦法優化, 尤其是MongoDB Client.

優化思路

最開始對C#優化沒有重視Alloc這方面的優化, 以為ServerGC可以掌控一切, 實踐下來發現不是這樣. 所以對未來如果有C#寫服務器, 或者其他托管語言寫服務器的話, 優化的方式應該是:

  1. 開啟WorkStationsGC, 該模式對Alloc更為敏感
  2. 先優化Alloc次數, 盡可能修改掉高頻率Alloc對象的地方
  3. 然后再去優化算法
  4. 切換成ServerGC

在優化完Alloc之后, 整個服務器的運行速度有明顯的提升(高出一個到兩個數量級). 從最開始的OOM到后面5000人online只有15%的CPU占有率(騰訊雲SA2 32C64G雲主機).

Linux下sampling

服務器在Windows上面優化好了之后, Linux上還是要跑一下Sampling, 可以看看perf和flamegraph在linux下的使用, 文章參考處有列出.

參考:

  1. C# Emit
  2. DeepCloner
  3. .NET Inline Closure Call
  4. .NET Alloc On Stack
  5. .NET Profiling On Linux
  6. Flamegraph


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM