[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