[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三段拼成的一個完整的字元. 那麼可以可以:
- 到全局的只讀Dictionary裡面去查找, 找到了返回
- 沒找到, 則上
lock
, 到只寫的Dictionary裡面去找, 找到了返回 - 沒找到, 給只寫的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 Client
和DotNetty
裡面分配比較多的對象, 也沒辦法優化, 尤其是MongoDB Client
.
優化思路
最開始對C#優化沒有重視Alloc這方面的優化, 以為ServerGC可以掌控一切, 實踐下來發現不是這樣. 所以對未來如果有C#寫伺服器, 或者其他託管語言寫伺服器的話, 優化的方式應該是:
- 開啟WorkStationsGC, 該模式對Alloc更為敏感
- 先優化Alloc次數, 儘可能修改掉高頻率Alloc對象的地方
- 然後再去優化演算法
- 切換成ServerGC
在優化完Alloc之後, 整個伺服器的運行速度有明顯的提升(高出一個到兩個數量級). 從最開始的OOM到後面5000人online只有15%的CPU佔有率(騰訊雲SA2 32C64G雲主機).
Linux下sampling
伺服器在Windows上面優化好了之後, Linux上還是要跑一下Sampling, 可以看看perf和flamegraph在linux下的使用, 文章參考處有列出.
參考: