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;
}
}
通過這種方式, 把伺服器內上千處錯誤的呼叫找到並且修復掉. 讓伺服器在多執行緒環境下變的穩定. 當然, 這個沒有解決本質問題, 本質問題就是多執行緒有狀態伺服器非常難實現.
如果專案走到這個階段, 可以嘗試著使用這種方式搶救一下.
通過這種方式, 把伺服器內上千處錯誤的呼叫找到並且修復掉. 讓伺服器在多執行緒環境下變的穩定. 當然, 這個沒有解決本質問題, 本質問題就是多執行緒有狀態伺服器非常難實現.
如果專案走到這個階段, 可以嘗試著使用這種方式搶救一下.
參考: