[02] 多線程邏輯編程

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. 充血模型
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章