[02] 多執行緒邏輯程式設計

egmkang發表於2020-09-10

C#多執行緒邏輯程式設計

多執行緒程式設計以難著稱, 有很多人碰見多執行緒程式設計就會畏縮, 不敢前進, 言必稱死鎖/卡死. 但是合理程式設計是不會碰到死鎖這種問題.

對語言瞭解

工欲善其事必先利其器, 必須要對語言提供的同步機制和期擴充套件有所瞭解.

Linux系統(庫)提供的同步機制有:

  • 原子操作
  • 條件變數

其中原子操作對個人程式設計能力要求較高, 所以在編寫邏輯的時候, 一般不使用, 只是用來製作簡單的原子計數器; 鎖和條件變數在邏輯程式設計時使用的較多. 但是Linux pthread提供的mutex並不是一個簡單實現的鎖, 而是帶有SpinLockFutex多級緩衝的高效實現.

所以在Linux下使用Mutex程式設計, 一般不太會遇到嚴重的效能問題. Windows下就需要注意, Windows下也有類似的同步機制(畢竟作業系統原理是類似的), 只是Windows下的Mutex是一個系統呼叫, 意味著任何粒度大小的Mutex呼叫都會陷入到核心. 本來你可能只是用來保護一個簡單的計數器, 但是顯然核心的話就要消耗微秒級別的時間, 顯然得不償失. 所以Windows上還有一種不跨程式的同步機制Critical Section, 該API提供了一個Spin Count的引數. Critical Section提供了兩級緩衝, 在一定程度上實現了pthread mutex的功能和效率.

C#提供的鎖機制, 和Windows上的有一些類似, 不夠輕量級的鎖是通過lock關鍵字來提供的, 背後的實現是Monitor.EnterMonitor.Exit.

條件變數在多種語言的系統程式設計裡面, 都是類似的. 一般用來實現即時喚醒(的生產者消費者模型). C#裡面的實現是Monitor.WaitMonitor.Pulse, 具體可以看看MSDN.

除了這些底層的介面, C#還提供了併發容器, 其中比較常用的是:

  • ConcurrentDictionary
  • ConcurrentQueue

其中Queue主要用來做執行緒間傳送訊息的容器, Dictionary用來放置執行緒間共享的資料.

多執行緒程式設計的最佳實踐

多執行緒程式設計需要注意的是鎖的粒度業務的抽象.

一般來講, 鎖的效率是很高的. 上面我們提到pthread mutexCritical Section都有多級緩衝機制, 其中最重要的一點就是每次去lock的時候, 鎖的實現都會先去嘗試著Spin一段時間, 拿不到鎖之後才會向下陷入, 直到核心. 所以, 在編寫多執行緒程式的時候, 至關重要的是減少臨界區的大小.

 

 

可以看到上面這張圖, Monitor.Enter的成本是20ns左右, 實際上和CAS的時間消耗差不多(CAS是17ns左右).

所以不能在鎖內部做一些複雜的, 尤其是耗時比較長的操作. 只要做到這一點, 多執行緒的程式, 效率就可以簡單的做到最高. 而無鎖程式設計, 本質上還是在使用CAS, 程式設計的難度指數級的提升, 所以不建議邏輯程式設計裡面使用無鎖程式設計, 有興趣的話可以看多處理器程式設計的藝術.

多執行緒邏輯的正確性是最難保證的. 但是據我觀察下來, 之所以困難, 大多數是因為程式設計師對業務的理解程度和API設計的抽象程度較低造成的.

對一個所有變數都是public的類進行多執行緒操作, 難度必然非常大, 尤其是在MMOG伺服器內有非常複雜的業務情況下, 更是難以做到正確性, 很有可能多個執行緒同時對一個容器做各種增刪改查操作. 這種無抽象的程式設計就是災難, 所以做到合理封裝, 對模型的抽象程度至關重要.

充血模型

上面說的無抽象的程式設計是災難, 物件導向裡面把這種設計叫做貧血模型, 只有資料沒有行為; 而我們做的MMOG伺服器, 裡面包含大量的業務, 即行為. 這時候用貧血模型做開發, 會導致業務處理的地方程式碼寫的非常多, 而且難以重用, 外加上多執行緒引入新的複雜性, 導致編寫正確業務的多執行緒程式碼難以實現. 所以需要在資料的層次上加上領域行為, 即充血模型.

充血模型沒有統一的處理方式, 而是需要在業務的接觸上面不斷的提煉重構. 舉例來說, 我們有一個場景類Scene, 玩家Player可以加入到Scene裡面來, 也可以移除, 那麼就需要在Scene類上面增加AddPlayerRemovePlayer. 而對於多執行緒互動, 只需要保證這些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;
    }
}

通過這種方式, 把伺服器內上千處錯誤的呼叫找到並且修復掉. 讓伺服器在多執行緒環境下變的穩定. 當然, 這個沒有解決本質問題, 本質問題就是多執行緒有狀態伺服器非常難實現.

如果專案走到這個階段, 可以嘗試著使用這種方式搶救一下.

 

通過這種方式, 把伺服器內上千處錯誤的呼叫找到並且修復掉. 讓伺服器在多執行緒環境下變的穩定. 當然, 這個沒有解決本質問題, 本質問題就是多執行緒有狀態伺服器非常難實現.

如果專案走到這個階段, 可以嘗試著使用這種方式搶救一下.

參考:

  1. Windows Mutex
  2. Windows Critical Section
  3. C# Monitor
  4. 多處理器程式設計的藝術
  5. Rust Ownership
  6. 充血模型

相關文章