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, 或者是其他原因.
WorkStationGC
和ServerGC
切換貌似對服務器性能影響也不是很大----都是過載, 機器人開了之后就無法正常的玩游戲, 延遲會非常高.
巧遇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做邏輯開發的項目提供一點經驗, 而不必再走一次彎路.
參考: