C#多線程邏輯編程
多線程編程以難著稱, 有很多人碰見多線程編程就會畏縮, 不敢前進, 言必稱死鎖
/卡死
. 但是合理編程是不會碰到死鎖這種問題.
對語言了解
工欲善其事必先利其器, 必須要對語言提供的同步機制和期擴展有所了解.
Linux系統(庫)提供的同步機制有:
- 鎖
- 原子操作
- 條件變量
其中原子操作對個人編程能力要求較高, 所以在編寫邏輯的時候, 一般不使用, 只是用來制作簡單的原子計數器; 鎖和條件變量在邏輯編程時使用的較多. 但是Linux pthread提供的mutex
並不是一個簡單實現的鎖, 而是帶有SpinLock
和Futex
多級緩沖的高效實現.
所以在Linux下使用Mutex編程, 一般不太會遇到嚴重的性能問題. Windows下就需要注意, Windows下也有類似的同步機制(畢竟操作系統原理是類似的), 只是Windows下的Mutex是一個系統調用
, 意味着任何粒度大小的Mutex調用都會陷入到內核. 本來你可能只是用來保護一個簡單的計數器, 但是顯然內核的話就要消耗微秒級別的時間, 顯然得不償失. 所以Windows上還有一種不跨進程的同步機制Critical Section
, 該API提供了一個Spin Count
的參數. Critical Section
提供了兩級緩沖, 在一定程度上實現了pthread mutex的功能和效率.
C#提供的鎖機制, 和Windows上的有一些類似, 不夠輕量級的鎖是通過lock
關鍵字來提供的, 背后的實現是Monitor.Enter
和Monitor.Exit
.
條件變量在多種語言的系統編程里面, 都是類似的. 一般用來實現即時喚醒(的生產者消費者模型). C#里面的實現是Monitor.Wait
和Monitor.Pulse
, 具體可以看看MSDN.
除了這些底層的接口, C#還提供了並發容器, 其中比較常用的是:
- ConcurrentDictionary
- ConcurrentQueue
其中Queue主要用來做線程間發送消息的容器, Dictionary用來放置線程間共享的數據.
多線程編程的最佳實踐
多線程編程需要注意的是鎖的粒度
和業務的抽象
.
一般來講, 鎖的效率是很高的. 上面我們提到pthread mutex
和Critical Section
都有多級緩沖機制, 其中最重要的一點就是每次去lock的時候, 鎖的實現都會先去嘗試着Spin一段時間, 拿不到鎖之后才會向下陷入, 直到內核. 所以, 在編寫多線程程序的時候, 至關重要的是減少臨界區的大小
.
可以看到上面這張圖, Monitor.Enter的成本是20ns左右, 實際上和CAS的時間消耗差不多(CAS是17ns左右).
所以不能在鎖內部做一些復雜的, 尤其是耗時比較長的操作. 只要做到這一點, 多線程的程序, 效率就可以簡單的做到最高. 而無鎖編程
, 本質上還是在使用CAS, 編程的難度指數級的提升, 所以不建議邏輯編程里面使用無鎖編程, 有興趣的話可以看多處理器編程的藝術.
多線程邏輯的正確性是最難保證的. 但是據我觀察下來, 之所以困難, 大多數是因為程序員對業務的理解程度和API設計的抽象程度較低造成的.
對一個所有變量都是public的類進行多線程操作, 難度必然非常大, 尤其是在MMOG服務器內有非常復雜的業務情況下, 更是難以做到正確性, 很有可能多個線程同時對一個容器做各種增刪改查操作. 這種無抽象的編程就是災難, 所以做到合理封裝, 對模型的抽象程度至關重要.
充血模型
上面說的無抽象的編程是災難, 面向對象里面把這種設計叫做貧血模型
, 只有數據沒有行為; 而我們做的MMOG服務器, 里面包含大量的業務, 即行為
. 這時候用貧血模型
做開發, 會導致業務處理的地方代碼寫的非常多, 而且難以重用, 外加上多線程引入新的復雜性, 導致編寫正確業務的多線程代碼難以實現. 所以需要在數據的層次上加上領域行為
, 即充血模型
.
充血模型
沒有統一的處理方式, 而是需要在業務的接觸上面不斷的提煉重構. 舉例來說, 我們有一個場景類Scene
, 玩家Player
可以加入到Scene里面來, 也可以移除, 那么就需要在Scene類上面增加AddPlayer
和RemovePlayer
. 而對於多線程交互, 只需要保證這些Scene上面的領域API線程安全性, 就可以最起碼保證Scene類內部的正確性; 外部的正確性, 例如過個API組合的正確, 是很難保證的. 當然這個例子只是一個簡單的例子, 實際的情況要通過策划的真實需求來設計和不斷重構.
這邊之所以把充血模型
提出來說, 是我發現大部分項目組里面實現的抽象級別都過低. 合理的抽象使代碼的規模減少, 普通人也能更容易維護.
並行容器的選擇
C#雖然提供了ConcurrentDictionary, 但是不代表任何場景下該容器都是適用的. 具體問題需要具體分析.
首先要看我們的臨界區是不是那么大, 如果臨界區很小, 而且訪問的頻率沒有那么高(即碰撞沒那么高). 那么是不需要適用ConcurrentDictionary.
例如游戲服務器, 每個玩家都在單獨的場景內, 他所訪問的對象, 大部分都是自己和周圍的人, 那么是不太會訪問到其他線程內的復雜對象. 那么就只需要用Dictionary, 最多用lock保護一下就行了.
只有真正需要在全局共享的容器, 還有很多線程高頻率的訪問, 才需要使用ConcurrentDictionary.
某游戲服務器里面有不少使用ConcurrentDictionary容器的代碼, 其中有一些非常沒有必要. 而且我們看代碼也會發現:
[__DynamicallyInvokable] public ConcurrentDictionary() : this(ConcurrentDictionary<TKey, TValue>.DefaultConcurrencyLevel, 31, true, EqualityComparer<TKey>.Default) { } private static int DefaultConcurrencyLevel { get { return PlatformHelper.ProcessorCount; } }
C#默認將ConcurrentDictionary的並發度設置成機器的CPU線程個數, 比如我是8核16線程的機器, 那么並發度是16.
某游戲服務器, 一個場景的線也就是四五十人, 大部分情況下都小於四五十人. 但是用16或者更高的並發度, 顯然是不太合適的. 一方面浪費內存, 另外一方面性能較差. 所以后面大部分ConcurrentDictionary並發度都改成了4左右.
多讀少寫場景下的優化
多寫場景下, 代碼實際上很那優化, 基本思路就是隊列. 因為你多個線程去競爭的寫, 鎖的碰撞會比較激烈, 所以最簡單的方式就是隊列(觀察者消費者
).
多讀場景下, 有辦法優化. 因為是多線程程序, 程序的一致性
是很難保證. 時時刻刻針對最新值編程是極其困難的, 所以可以退而求其次取最近值
, 讓程序達到最終一致性
.
每次數據發生變化的時候, 對其做一個拷貝, 做讀寫分離, 就可以很簡單的實現最終一致性. 而且讀取性能可以做到非常高.
private object mutex = new object() private readonly List<int> array = new List<int>(); private List<int> mirror = array.ToList(); public List<int> GetArray() { return mirror; } //這個只是示例代碼, 為了表達類似的意思 private void OnArrayChanged() { lock(mutex) mirror = array.ToList(); }
多線程檢測機制
某游戲服務器里面碰到一個非常棘手的問題, 就是多線程邏輯. 好的一點是副本地圖是分配到不同的線程內, 大部分的業務邏輯在地圖內執行, 但是因為某些原因寫了很多邏輯可能並沒有遵守約定, 導致交叉訪問, 進而產生風險. 解決這個問題, 思路也有很多, 最簡單的方式就是把服務器拆開, 讓服務器與服務器之間他通過網絡來通訊, 那么他們很顯然就訪問不到其他進程的領域獨享(非共享內存), 也就不會出現多線程的問題, 但是時間上不太允許這么干.
所以后來選擇了一條比較艱難的道路.
Rust語言有一種概念叫所有權Ownership
. 在rust內, 擁有對象生命周期的所有者把持, 其他對象不能對他進行寫操作, 因為寫操作需要所有權, 但是可以進行讀操作(類似於C++的const &
). 這種所有權的實現有兩種, 一種是編譯時期的靜態檢測, 一種是動態時期的檢測. 動態檢測是通過reference count
來實現.
而某游戲服務器內, 領域對象
實際上也有自己歸屬的線程(地圖). 所以我們可以在領域對象進入地圖的時候做標記, 出地圖的時候做比較, 然后在讀寫其屬性的時候, 就可以檢測出來, 是不是在訪問不屬於自己線程的對象. 進而實現跨線程對象檢測機制
.
具體代碼側, 在每次實現public屬性的時候, 看看是不是訪問了復雜容器, 如果訪問了, 插入檢測代碼, 就可以了. 最后就變成一個工作量問題.
//這是一個擴展方法, 檢測當前線程和含有CurrentThreadName對象的線程是不是相等 [MethodImpl(MethodImplOptions.AggressiveInlining)] public static void CheckThread(this ICurrentThreadName obj) { if (obj.CurrentThreadName == "IdleThread" || string.IsNullOrEmpty(Thread.CurrentThread.Name)) return; if (!string.IsNullOrEmpty(obj.CurrentThreadName) && obj.CurrentThreadName != Thread.CurrentThread.Name) { nlog.Error($"Thread:{Thread.CurrentThread.Name} Access Thread:{obj.CurrentThreadName}'s Object:{obj.GetType().FullName}, StackTrace:{Environment.NewLine}{LogTool.GetStackTrace()}"); var stackTrace = new StackTrace(); ReportThreadError.PostThreadError(Thread.CurrentThread.Name, obj.CurrentThreadName, obj.GetType().Name, stackTrace.ToString()); } } public CreDelayerContainer CreDelayerContainer { get { this.CheckThread(); return this.xxxx; } }
通過這種方式, 把服務器內上千處錯誤的調用找到並且修復掉. 讓服務器在多線程環境下變的穩定. 當然, 這個沒有解決本質問題, 本質問題就是多線程有狀態服務器非常難實現.
如果項目走到這個階段, 可以嘗試着使用這種方式搶救一下.
通過這種方式, 把服務器內上千處錯誤的調用找到並且修復掉. 讓服務器在多線程環境下變的穩定. 當然, 這個沒有解決本質問題, 本質問題就是多線程有狀態服務器非常難實現.
如果項目走到這個階段, 可以嘗試着使用這種方式搶救一下.
參考: