[04] C# Alloc Free程式設計之實踐

egmkang發表於2020-09-14

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

相關文章