[03] C# Alloc Free程式設計

egmkang發表於2020-09-11

C# Alloc Free程式設計

首先Alloc Free這個詞是我自創的, 來源於Lock Free. Lock Free是說通過原子操作來避免鎖的使用, 從而來提高並行程式的效能; 與Lock Free類似, Alloc Free是說通過減少記憶體分配, 從而提高託管記憶體語言的效能.

基礎理論

對於一個遊戲伺服器來講, 玩家數量是一定的, 那麼這些玩家的輸入也就是一定的; 對於每一個輸入, 處理邏輯的時候, 必然會產生一些臨時物件, 那麼就需要Alloc(New)物件; 然後每次Alloc的時候, 都有可能會觸發GC的過程; GC又會將整個程式Stop一會兒(不管什麼GC, 都會Stop一會兒, 只是長短不一樣); 進而Stop又會影響到輸入處理的速度.

這個鏈式反應迴圈, 就是一個假設. 只要每個過程產生下一步, 足夠多(或者時間長了), 能夠維持鏈式反應. 那麼最終的表現就是系統過載. 消費速度越來越慢, 玩家的請求反應遲鈍, 程式的記憶體越來越多, 進而OOM.

 

 

如果每個訊息處理的耗時比較長, 那麼堆積在一起的是輸入; 如果每個訊息處理的Alloc比較多, 那麼堆積在一起的是GC. 這是兩個基本的觀點.

再回頭考慮我們所要解決的問題, 我們要解決一個程式處理5000玩家Online. 那這5000個人, 一秒所能生產的訊息數量也就是5000左右個訊息, 而我們程式設計面對的CPU, 一秒處理可是上萬甚至更高的數量級. 所以大概率不會堆積在輸入這邊.

但是Alloc就不一樣, 每個業務邏輯訊息, 都有其固然的複雜性, 很有可能一個訊息處理, 產生了10個小的臨時物件, 處理完成後就是垃圾物件. 那麼就有10倍的係數, 瞬間將數量級提高一倍. 如果問題再複雜一點呢, 是不是有可能再提高到一個數量級?

這是有可能的!

某遊戲伺服器內部有物理引擎, 有ARPG的戰鬥計算, 每個法球/子彈都是一個物件, 中間所能產生的垃圾物件是非常多的, 所以大一兩個數量級, 是很容易做到的.

最開始, 我在優化某遊戲伺服器的時候, 忽略了這一點, 花了很長時間才定位到真正的問題. 直到定位到問題, 可以解釋問題, 然後fix掉之後, 整個過程就變得很容易理解, 也很容易理解這個混沌系統為何執行的比較慢.

優化前後的對比

最開始在Windows上面編譯, 除錯和優化伺服器. 以為問題就這麼簡單, 但是實際上在Linux上面跑的時候, 還是碰到了一點問題.

這是伺服器最開始用WorkStationGC跑2500人時候的火焰圖, 最左面有很多一塊時間在跑SpinLock, 問了微軟的人, 微軟的人也不知道.

 

 

然後當時相同的版本在Intel和AMD CPU下面跑起來, 有截然不同的效果(AMD SA2效能要高一些, 價格要低一些). 以至於以為是Intel CPU的BUG, 或者是其他原因.

WorkStationGCServerGC切換貌似對伺服器效能影響也不是很大----都是過載, 機器人開了之後就無法正常的玩遊戲, 延遲會非常高.

巧遇XLua

伺服器內部有用XLua來封裝和呼叫Lua指令碼, 有很多指令碼都是策劃自己搞定的, 其中包括戰鬥公式和技能之類的.

我們都知道MMOG的戰鬥公式會很複雜, 可能一下砍怪, 會調獲取玩家和怪物的屬性幾十次(因為有很多種不同的戰鬥屬性). 然後又是一個無目標的ARPG, 加上物理之類的, 一次砍殺可能會呼叫十幾次戰鬥公式, 所以數量級會有提升.

XLua在做FFI的時候, 會將物件的輸入輸出保留在自己的XLua.ObjectTranslator物件上, 以至於該物件的字典裡面包含了數百萬個元素. 所以呼叫會變得非常慢, 然後記憶體佔用也會比較高. 這是其一.

第二就是, 每個引數pass的時候, 可能都會產生new/delete. 因為伺服器這邊字串傳參用的非常多, 所以每次引數傳遞, 可能都會對Lua VM或者CLR產生額外的壓力.

基於這兩點原因, 我把戰鬥公式從Lua內挪到C#內, 然後對Lua GC引數做了相應的調整. 然後發現有明顯的提升.

後來的事情

後來的事情就比較簡單了, 因為發現減少這次大量的Alloc, 會極大的提高程式的效能. 所以後續的工作重點就放在了減少Alloc上, 然後火焰圖上會有明顯的對比差別.

這是中間一個版本, 左邊pthread mutex的佔比少了一些.

 

 

這是4月優化後的版本, pthread mutex佔比已經小於10%, 可能在5%以內.

 

 

而伺服器目前的版本, pthread mutex佔比已經小於2%. 幾乎沒有高頻的記憶體分配.

這就是我說的Alloc Free.

現象, 解釋和最優化程式設計

繼續回到最開始的那個圖, 如果不砍斷Alloc, 那麼就會GC Stop, 進而就會影響到處理速度.

這是C#在Programming Language Benchmark Game上的測試, 可以看到C#單純討論計算效能, 和C++的差距已經不是很大.

而某遊戲伺服器內, 數百人跑在一個Server程式內, 都會都會出現處理速度不足, 猜想起來核心的問題就在GC Stop. 這是一個業務內找到AllocateString耗時的細節, 其中大部分在做WKS::gc_heap::garbage_collect. 這種情況在WorkStationGC下面比較突出, ServerGC下面也會有明顯的問題. 核心的矛盾還是要減少不必要的記憶體分配, 降到CLR的負載.

 

 

當然這個例子比較極端, 從優化過程的經驗來看, 10%的Alloc大概有5%的GC消耗. 當一個伺服器程式有30%+的Alloc時, 伺服器的效能無論如何也上不去.

這是最核心的矛盾. 只有CPU大部分時間都在處理業務邏輯, 才能儘可能的消費更多的訊息, 進而系統才不會出現過載現象, 文章最開始說的鏈式反應也就不會發生.

C#效能的最優化程式設計

實際上就變成了怎麼減少記憶體分配的次數. 這裡面就需要知道一些最基本的最佳實踐, 例如優先使用struct, 少裝箱拆箱, 不要拼接字串(而是使用StringBuilder)等等等等.

但是單單有這些還是不夠的, 還需要解決複雜業務邏輯內部產生的垃圾物件, 還需要不影響正常業務邏輯的開發. 關於這部分, 在後面一文中會詳細討論, 此處就不做展開.

非託管記憶體

C#程式記憶體的分配, 實際上還包含Native部分alloc的記憶體, 這一點是比較隱性的. 而且由於Windows libc的記憶體分配器和Linux記憶體分配器的差異性, 會導致一些不同.

我們在使用dotMemory軟體獲取程式Snapshot的時候, 可以獲得完整託管物件的個數, 資料, 以及統計資訊; 但是對非託管記憶體的統計資訊缺沒有. 由於伺服器在Windows Server上面經過長時間的測試, 例如開4000個機器人跑幾天, 記憶體都沒有明顯的上漲, 那麼可以大概判斷出來大部分邏輯是沒有記憶體洩漏的.

Linux上應用和Windows上不一樣的, 還有glog的日誌上報, 但是關閉測試之後發現也沒有影響. 所以問題就回到了, Windows和Linux有什麼差異?

帶著這個問題搜尋了一番, 發現Java程式有類似的問題. Java程式也會因為Linux記憶體分配器而導致非託管堆變大的問題, 具體可以看Java堆外記憶體增長問題排查Case.

後來將Linux的啟動命令改成:

LD_PRELOAD=/usr/lib/libjemalloc.so $(pwd)/GameServer

之後, 跑了一晚上發現記憶體佔用穩定. 基本上就可以斷定該問題和Java在Linux上碰到的問題一樣.

後來經過搜尋, 發現大部分託管記憶體語言在Linux都有類似的優化技巧. 包括.net core github內某些issue提到的. 這一點可以為公司後續用Lua做邏輯開發的專案提供一點經驗, 而不必再走一次彎路.

參考:

  1. GC Issue
  2. C# Benchmark Game
  3. Java堆外記憶體增長問題排查

相關文章