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下的使用, 文章參考處有列出.
參考: