2019年11月24日 星期日

.NET C# 單執行緒 同步 多執行緒 非同步 執行緒同步 Synchronization 邏輯處理器數量 設計探討 : Part 2 在多執行緒下,非同步執行加一與減一方法,造成執行緒不安全的現象

.NET C# 單執行緒 同步 多執行緒 非同步 執行緒同步 Synchronization 邏輯處理器數量 設計探討 : Part 2 在多執行緒下,非同步執行加一與減一方法,造成執行緒不安全的現象

  1. .NET C# 單執行緒 同步 多執行緒 非同步 執行緒同步 Synchronization 邏輯處理器數量 設計探討 : Part 1 在單一執行緒下,同步執行加一與減一方法
  2. .NET C# 單執行緒 同步 多執行緒 非同步 執行緒同步 Synchronization 邏輯處理器數量 設計探討 : Part 2 在多執行緒下,非同步執行加一與減一方法,造成執行緒不安全的現象
  3. .NET C# 單執行緒 同步 多執行緒 非同步 執行緒同步 Synchronization 邏輯處理器數量 設計探討 : Part 3 在多執行緒下,使用 lock 關鍵字來做到執行緒安全
  4. .NET C# 單執行緒 同步 多執行緒 非同步 執行緒同步 Synchronization 邏輯處理器數量 設計探討 : Part 4 在多執行緒下,使用 Interlocked 來做到執行緒安全
  5. .NET C# 單執行緒 同步 多執行緒 非同步 執行緒同步 Synchronization 邏輯處理器數量 設計探討 : Part 5 在多執行緒下,不要全部都使用執行緒同步機制 來做到執行緒安全

    從上一篇文章中,.NET C# 單執行緒 同步 多執行緒 非同步 執行緒同步 Synchronization 邏輯處理器數量 設計探討 : Part 1 在單一執行緒下,同步執行加一與減一方法 中,將會看到若使用同步程式設計方式所建置出來的程式,設計上相單的簡單,而且不容易出錯,可以,卻無法充分的善用電腦上的處理器數量,達到並行處理的效果,也就是可以提升整體應用程式的執行下能;在加一與減一的程式碼中,大約需要花費 7.5 秒的時間才能夠完成。
    那麼,要怎麼才能夠設計出可以具備平行處理的程式碼,充分善用這些處理器來同時執行所設計的程式碼呢?在 .NET / C# 程式語言中可以透過 Thread 這個類別,建立起額外的執行緒,讓這些執行緒可以具備並行執行能力。
    對於要使用執行緒類別可以說相當的容易,建立一個 Thread 執行個體,在建構函式內指派一個委派方法,接著呼叫該 Thread 執行緒執行個體的 Start() 方法就可以做到;可是,若沒有真正理解到執行緒背後的技術原理與可能造成的問題,將會帶來所設計的程式碼不是很容易偵錯,因為,在多執行緒執行環境下,想要知道多執行緒程式碼所造成的問題,是無法很容易地在設計階段輕鬆地看出問題端倪,必須要等到程式執行的時候,才能夠看到問題發生;另外就是,每增加一個執行緒,將會需要額外的成本花費,例如:建置一個執行緒需要額外的記憶體與建置時間、過多的執行緒存在於系統上的時候,將會有許多 CPU 處理時間花費在 內容交換 Context Switch 上,也就是說,在設計多執行緒程式碼的時候,不是一昧的產生出很多的執行緒就可以提稱整體執行效能,畢竟,這台電腦上有幾個處理器核心,同一個時間就僅能夠執行這些處理器數量的執行緒程式碼,其他過多的執行緒就僅能夠在排程器 Scheduler 的佇列 Queue 中進行等待,所以,對於多執行緒的程式設計,需要有較多的關注與設計經驗。
    在此文章中,將會把加一與減一這兩個方法,設計在不同的執行緒下來執行,而且每次迴圈的執行結果,還是會更新到共用靜態變數上;最後,將來看看這樣的設計方式是否可以達到正確的執行結果與提升執行效能的好處。
    在這篇文章所提到的專案原始碼,可以從 GitHub 下載

    進行多執行緒的測試,但是不具備執行緒安全特性

    在這裡將會設計一個新的方法, AsyncAddSub , 在這個方法內將會建立兩個執行緒,底下為這個新方法的程式碼列表。
    C Sharp / C#
    static void AsyncAddSub(AddSubAction addSubAction)
    {
         WaitHandle[] waitHandles = new WaitHandle[]
        {
            new AutoResetEvent(false),
            new AutoResetEvent(false)
        };
       AddSub addSub = new AddSub();
        Thread thread1 = new Thread(x =>
        {
            addSub.Adds(addSubAction);
            (waitHandles[0] as AutoResetEvent).Set();
        });
        Thread thread2 = new Thread(x =>
        {
            addSub.Subs(addSubAction);
            (waitHandles[1] as AutoResetEvent).Set();
        });
    
        Stopwatch stopwatch = new Stopwatch();
        stopwatch.Start();
        thread1.Start();
        thread2.Start();
    
        WaitHandle.WaitAll(waitHandles);
        stopwatch.Stop();
        Console.WriteLine($"Counter={AddSub.counter}, {stopwatch.ElapsedMilliseconds:N0}ms");
    }
    
    在 AsyncAddSub 方法內,將會使用使用 Thread thread1 = new Thread(x => {...}); 這樣的敘述建立起兩個執行緒,其中這兩個執行緒要執行的委派方法將會使用 Lambda 匿名委派方法來宣告,對於 thread1 這個執行緒,將會呼叫 AddSub 類別所建立的執行個體內的 Adds 方法,而對於 thread2 這個執行緒,將會呼叫 AddSub 類別所建立的執行個體內的 Subs 方法。
    接著,將會使用 thread1.Start(); & thread2.Start(); 這兩個敘述,分別來啟動這兩個執行。傳統上,要知道執行緒是否執行完成,可以執行 thread1.Join(); 或者 thread2.Join(); 方法,不過,當執行 Thread.Join 方法的時候,將會造成當前執行緒進入 Block 封鎖階段,直到該執行緒執行完成之後,才會繼續執行底下的程式碼;然而,在多執行緒執行環境下,開發者是無法確認哪一個執行緒一定會先執行完畢,而就使用該執行緒的 Join 方法來等待,就算先啟動執行的執行緒,也並不代表這個執行緒會先執行完畢。
    現在產生一個問題,要如何知道這兩個執行緒都已經完成了,這裡將會引進了執行緒同步 Synchronizaiton Constructor ,這裡將會使用執行緒同步的 WaitHandl 陣列來儲存兩個 AutoResetEvent 物件,而對於預設的 AutoResetEvent 物件將會預設設定其為 false,表示初始狀態設定為未收到信號。
    當加一或者減一執行緒執行完畢之後,將會呼叫 AutoResetEvent.Set() 方法,將事件的狀態設定為收到信號,在這裡將會表示該執行緒已經執行完成的訊號通知;而在啟動這兩個執行緒之後的程式碼,將會使用 WaitHandle.WaitAll(waitHandles); 敘述來等到這兩個執行緒都已經全部完成,如此,就會接下來執行底下的程式碼。
    如同上面的程式碼,在此設計了一個類別 AddSub ,該類別提供了兩個執行個體方法與一個靜態變數,第一個物件方法 Adds 會執行一個迴圈,將會跑 int.MaxValue 次,每次回圈內將會執行 counter++ 這個表示式,這裡的 counter 是一個靜態變數,但是 counter 的數值異動卻是由執行個體方法來去變更的;第二個物件方法 Subs 會執行一個迴圈,將會跑 int.MaxValue 次,每次回圈內將會執行 counter-- 這個表示式。也就是說 Adds() 這個方法將會再回圈內每次都進行加一的動作,而對於 Subs() 這個方法將會在回圈內每次都進行減一的動作。
    現在,已經完成了兩個執行緒的程式碼設計,而且可以計算這兩個執行緒都全部執行完畢之後所需要的時間,這裡就可以使用這個敘述 ThreadSynchronization yes NoLock 10000000 ,在第一個引數中使用 yes 文字,指定測試程式要使用多執行緒方式來執行,並且不要使用任何的執行緒同步建構子 Synchronizaiton Constructor 來保護要存取共用的靜態變數程式碼。
    底下將會指定在多執行緒下,非同步執行加一與減一方法,並且指定在不同的邏輯處理器數量下的執行結果。
    ThreadSynchronization yes NoLock 10000000
    Counter=0, 7,417ms
    
    ThreadSynchronization yes NoLock 10100000
    Counter=878337449, 7,105ms
    
    ThreadSynchronization yes NoLock 10101000
    Counter=66089567, 14,481ms
    
    ThreadSynchronization yes NoLock 11000000
    Counter=896913837, 6,765ms
    
    ThreadSynchronization yes NoLock 11100000
    Counter=-13693011, 9,710ms
    
    ThreadSynchronization yes NoLock 11110000
    Counter=-6874487, 9,235ms
    
    ThreadSynchronization yes NoLock 11111111
    Counter=-66814967, 9,337ms
    

    發現問題1 : 僅有一個結果是正確的

    從上面的執行結果可以看滿詭異的現象,除了第一個測試 ThreadSynchronization yes NoLock 10000000 所顯示的 Counter 靜態變數數值為 0,其他的都不是為 0,這也就表示了僅有第一個測試方法 ThreadSynchronization yes NoLock 10000000 可以得到正確且預期的執行結果,這裡指定僅使用單一一個邏輯處理器來進行這一的程式碼執行;而對於指定使用多個邏輯處理器的執行結果,都是不正確的。
    ThreadSynchronization yes NoLock 10000000
    Counter=0, 7,456ms
    
    ThreadSynchronization yes NoLock 00100000
    Counter=0, 7,478ms
    
    ThreadSynchronization yes NoLock 00001000
    Counter=0, 7,525ms
    
    ThreadSynchronization yes NoLock 00000010
    Counter=0, 7,592ms
    
    ThreadSynchronization yes NoLock 01000000
    Counter=0, 7,608ms
    
    ThreadSynchronization yes NoLock 00010000
    Counter=0, 7,462ms
    
    ThreadSynchronization yes NoLock 00000100
    Counter=0, 7,521ms
    
    ThreadSynchronization yes NoLock 00000001
    Counter=0, 7,781ms
    
    在這裡,將會重複執行 ThreadSynchronization yes NoLock 10000000 這樣的測試,並且只會指定一個邏輯處理器來同時使用並行方式來執行這兩個執行緒,發現到結果都是相同的,執行結果是正確的,而且,所花費的時間與使用單一執行緒的同步執行方式差不多,大約要花費 7.5秒的時間。
    +

    不過,雖然這裡使用了多執行緒方式的方式來執行,可以卻因為僅有一個處理器可以使用,雖然執行結果是正確的,發現到是沒有任何可以提升執行效能的幫助。

    發現問題2 : 多邏輯處理器會發生執行結果是錯的

    當指定了兩個以上的邏輯處理器來執行這個加一與減一的多執行緒程式,其共通的結果就是,最終的計算結果數值是錯誤的,因此,只要結果是不正確的,就算程式跑得再快,也是沒有用的。
    不過,從上面的執行結果發現到,在指定使用 10101000 3 個邏輯處理器的時候,竟然會比使用 10100000 邏輯處理器所花費的時間更多,竟然花費多達一倍以上的時間,不過,當使用了 11000000 這樣的兩個邏輯處理器的時候,卻得到僅需要 6.7 秒的時間,雖然這樣的執行效能比起同步執行結果少了 1 秒鐘,但是,因為結果是不正確的,跑得再快也沒用。
    另外,也發現到同樣的指定 3 個邏輯處理器 ,這裡使用 11100000 比起使用 10101000 明顯快多了(但是,他們的執行結果都是不正確的),這樣產生的問題,歡迎大家可以在留言版中,留下各位的看法,進行討論看看。
    最後,就算使用了所有的 8 個邏輯處理器,11111111,執行速度也沒有比起同步單一執行緒的執行速度快,而且這樣的結果也是不正確的。



    沒有留言:

    張貼留言