多執行緒合集(一)---訊號量,鎖,以及併發程式設計,自定義任務排程和awaiter

陳顯達發表於2021-11-21

引言

       在後端開發中,多執行緒技術總是後端開發中常用到的技術,那什麼是多執行緒呢,在作業系統中,程式執行的最小單位是程式,那執行緒則是程式裡面的最小單位,關係是一對多的關係,而執行緒的排程,是由作業系統的時間片演算法進行排程的,即在某一個時間段內只有一個執行緒去進行計算,其他的則在等待,這涉及的系統方面的知識,我也是一知半解,本文主要是講解c#中多執行緒的常用操作,以及根據微軟提供的抽象類和介面去實現自定義的一些擴充,多執行緒方面會有至少兩篇文章,第一篇也就是本文,著重講解程式碼片段,後面會講解async和await的原理,以及執行時自定義狀態機的IL程式碼轉為c#程式碼,並且講解 他的執行順序。如有疑問,敬請提出,大家一起學習。

訊號量

       在c#中訊號量,可以用執行緒之間的通訊,主要用來某一執行緒阻塞,然後在由另一執行緒去進行發出訊號,讓阻塞的執行緒有訊號量,從而繼續執行,其中c#訊號量主要分為AutoResetEvent,ManualResetEvent,CountdownEvent,EventWaitHandle,Semaphore。其中第一個和第二個有些類似,第一個是線上程收到訊號然後釋放後,自動的設定為無訊號狀態,等待下一次的釋放,第二個是需要手動reset這是這兩個的區別,這兩個的建構函式中有個bool引數,意思是,true情況下是終止狀態,即可以理解為設定為true的情況下預設是有訊號的,那麼下方Wait呼叫中不會阻塞會直接執行,False的情況下是預設沒有訊號,需要程式碼中Set釋放訊號,即遇到Wait程式碼段會阻塞,等待其他執行緒進行set釋放訊號,第三個則是一個反向計數的一個訊號量,具體是在建立物件的時候設定一個初始值,然後執行期間執行到Wait方法執行緒會阻塞,等待這個物件呼叫Signal方法的時候計數器會-1,妹呼叫一次就-1,直到歸0時,阻塞執行緒繼續執行,這個是很有意思的一個訊號量,這也包含一些方法即AddCount方法可以每次新增一個 可以新增固定的數量,也可以reset初始值,也可以reset到自定義的一個值為初始值。第四個EventWaitHandle實際上是一個結合第一個和第二個的一個訊號量,在建立物件的時候可以指定是手動還是自動,第一個bool引數和第一個第二個的bool引數意義一樣,第三個和第四個引數是這個訊號量的名稱,以及是否重新建立的,如果引數out值是true說明是重新建立,否則是存在的訊號量。最後一個是限制同時進入執行緒數量,建構函式的第一個引數是可以授予訊號的初始數量,第二個引數為可以授予訊號量的最大數量,即初始的時候可以有多少個被授予可以進入執行緒資源的數量,第二個是併發情況下最大可以有多少個執行緒去獲取到訊號量,第一個和第二個可以一樣可以不一樣,但是第一個不能小於第二個。

       其實在c#訊號量中,以及部分c#鎖都是基於一個基類去實現的就是WaitHandle,這個類是一個抽象類,提供了一些靜態方法,所以訊號量和鎖中很多都是基於這個實現的,在這個類中,包括了等待的方法,可以等待所有,可以等待某一個或者,一批,還有一個比較有意思的方法SignalAndWait是給某個訊號量傳送訊號,讓阻塞的子執行緒繼續執行,然後將某個訊號量Wait中斷阻塞,言簡意賅就是,這個方法有兩個引數,第一個引數的意思就是需要傳送訊號的訊號量,第二個引數是需要中斷等待的訊號量。接下來,讓我們在程式碼中去實際看一下這些個訊號量。

AutoResetEvent

 private static AutoResetEvent auto = new AutoResetEvent(false);
  Thread thread = new Thread(AutoReset);//定義執行緒去執行AutoReset方法
                thread.Start();//開始執行緒
                Thread.Sleep(5000);//休眠5s
                auto.Set();
static void AutoReset()
        {
            auto.WaitOne();//阻塞執行緒,等待釋放訊號從而繼續執行下面的程式碼,一直等待
            //auto.WaitOne(2000);//等待兩秒,如果沒有收到訊號,則繼續執行
            Console.WriteLine("Wait 5s,Begin Run AutoReset");
        }

       在上述程式碼中,我們定義了一個AutoResetEvent的訊號量,並且將它設定為無訊號未終止狀態,然後我們啟動執行緒去執行AutoReset方法,在執行這個方法的時候到了執行的時候,我們呼叫WaitOne方法將子執行緒阻塞,等待主執行緒釋放訊號,在繼續執行,然後輸出我們想要的資訊,那實際的意思是我們主執行緒等待5s之後去釋放訊號,在主執行緒Set方法呼叫之後,子執行緒的WaitOne方法在收到訊號之後會去繼續執行下面的程式碼,輸出資訊。

ManualResetEvent

private static ManualResetEvent manualResetEvent = new ManualResetEvent(false);
{
                Thread thread = new Thread(ManualResetEvent);//定義執行緒去執行AutoReset方法
                thread.Start();//開始執行緒
                Thread.Sleep(5000);//休眠5s
                manualResetEvent.Set();
                Thread.Sleep(5000);
                manualResetEvent.Reset();//和AutoResetEvent區別在於 AutoResetEvent會自動重置狀態,ManualResetEvent需要手動Reset為無訊號狀態,否則二次或者多次waitone無效
            }
  static void ManualResetEvent()
        {
            manualResetEvent.WaitOne();//阻塞執行緒,等待釋放訊號從而繼續執行下面的程式碼,一直等待
            //auto.WaitOne(2000);//等待兩秒,如果沒有收到訊號,則繼續執行
            Console.WriteLine("Wait 5s,Begin Run manualResetEvent");
        }

       我們定義了一個ManualResetEvent變數,設定為無訊號終止狀態,然後啟動執行緒去執行ManualResetEvent方法,這個方法進入之後會阻塞子執行緒,等待收到訊號繼續執行,在主執行緒休眠5s之後釋放訊號,子執行緒收到訊號繼續執行,同AutoResetEvent一樣的使用,只是最後我們加了一個Reset方法,會重新將這個設定為無訊號狀態,這樣如果二次呼叫WaitOne的時候 還是需要等待子執行緒進行Set否則不會等待,直接執行。Reset實際上就是如果我們多次呼叫了WaitOne方法,那第一個執行緒執行後,如果不Reset,那麼第二個或者後面的WaitOne都會立即執行不會等待,因為Reset是將訊號重新設定為無訊號狀態。

CountdownEvent  

 var CountdownEvent = new CountdownEvent(1000);
                //CountdownEvent.CurrentCount//當前總數
                //CountdownEvent.AddCount()//新增1
                //CountdownEvent.AddCount(10);//新增指定數量
                //CountdownEvent.InitialCount//總數
                //CountdownEvent.Reset()//設定為InitialCount初始值
                //CountdownEvent.Reset(100)//設定為指定初始值
                Task.Run(() =>
                {
                    for (int i = 0; i < 1000; i++)
                    {
                        Task.Delay(100);
                        CountdownEvent.Signal();//代表計數器-1
                        Console.WriteLine(CountdownEvent.CurrentCount);
                    }
                });
                CountdownEvent.Wait();//等待計數器歸0
                Console.WriteLine("結束");

       我們定義了一個CountdownEvent變數,將初始值設定為1000,然後我們啟動執行緒去迴圈1000,然後讓計數器逐漸-1,同時主執行緒會呼叫Wait方法,這個方法會等待子執行緒Signal方法逐漸遞減為0的似乎繼續執行,即每次呼叫Signal方法,CurrentCount都會-1直到為0,主執行緒繼續執行輸出結束。

EventWaitHandle

  #region 同AutoResetEvent
                Thread thread = new Thread(EventWaitHandleAutoReset);//定義執行緒去執行AutoReset方法
                thread.Start();//開始執行緒
                Thread.Sleep(5000);//休眠5s
                eventWaitHandle.Set();//如果下方呼叫SignalAndWait則可以此處註釋掉
                #endregion
                #region ManualResetEvent

                thread = new Thread(EventWaitHandleManualReset);//定義執行緒去執行AutoReset方法
                thread.Start();//開始執行緒
                //Thread.Sleep(5000);//休眠5s
                //eventWaitHandleManualReset.Set();同ManualReset一樣 下方方法之所以Set  因為下面發了一個訊號,並且等待了一個執行緒,
                WaitHandle.SignalAndWait(eventWaitHandle, eventWaitHandleManualReset);//eventWaitHandle發出訊號Set,eventWaitHandleManualReset阻塞執行緒等待訊號,EventWaitHandleManualReset發出訊號後可以執行Console。WriteLine,否則一直阻塞
                Console.WriteLine();
                #endregion
  static void EventWaitHandleAutoReset()
        {
            eventWaitHandle.WaitOne();//阻塞執行緒,等待釋放訊號從而繼續執行下面的程式碼,一直等待
            //auto.WaitOne(2000);//等待兩秒,如果沒有收到訊號,則繼續執行
            Console.WriteLine("Wait 5s,Begin Run EventWaitHandle AutoReset");
            Thread.Sleep(5000);
        }
        static void EventWaitHandleManualReset()
        {
            Thread.Sleep(5000);//休眠5s等待SignalAndWait阻塞執行緒,此處釋放
            eventWaitHandleManualReset.Set();
            //eventWaitHandleManualReset.WaitOne();//阻塞執行緒,等待釋放訊號從而繼續執行下面的程式碼,一直等待
            //auto.WaitOne(2000);//等待兩秒,如果沒有收到訊號,則繼續執行
            Console.WriteLine("Wait 5s,Begin Run EventWaitHandle ManualReset");
        }

       這裡實際上我們不做過多的講解,因為大多數都是和AutoResetEvent還有ManuallResetEvent一樣,著重說一下SignalAndWait方法,可以看到在最開始的時候我們呼叫了EventWaitHandleAutoReset方法,但是我們的主執行緒是沒有釋放訊號的,那他一直在哪裡中斷阻塞,在最後的程式碼中,我們又去EventWaitHandleManualReset呼叫了這個方法,在這個方法中我們用eventWaitHandleManualReset發出了訊號呼叫了Set方法,在第一段的程式碼最後我們呼叫可SignalAndWait方法,傳入了EventWaitHandle以eventWaitHandleManualReset

在這個方法中,會將第一個訊號量釋放訊號,從而EventWaitHandleAutoReset方法收到訊號後繼續執行,那然後我們又阻塞了主執行緒,在子執行緒eventWaitHandleManualReset方法中我們又呼叫了Set方法釋放訊號,從而主執行緒繼續執行,那如果我們在這裡不呼叫Set方法那實際上主執行緒將會一直阻塞,當然這個方法中還有其他引數設定超時,以及是否退出上下文,這裡不做過多的講解,還需要各位去自己手動體驗一下。

Semaphore 

private static Semaphore Semaphore = new Semaphore(3, 3);
for (int i = 0; i <= 10; i++)
                {
                    Thread thread = new Thread(new ParameterizedThreadStart(SemaphoreTest));
                    thread.Start(i);
                }
 Semaphore.WaitOne();//阻塞執行緒,等待計數器小於設定的初始值後可以進入
            Console.WriteLine(state + "進入了資源");
            Thread.Sleep((int)state * 1000);
            Semaphore.Release();//釋放訊號,計數器+1
            Console.WriteLine(state + "離開了了資源"

 

 

 

 

   

   接下來是訊號量中的最後一個,Semaphore,可以看到,主執行緒中我們是啟動了十個執行緒去進行執行方法,但是我們定義中只設定了剛開始只能有三個進入並且在最大隻有三個,可以在結果的控制檯輸出中看到,我們最後的結果輸出圖中,每次可以進入這個方法中執行的只會有三個執行緒,同時最大也只有三個執行緒,3,2,5進入之後,2然後離開,0進入,那裡面是0,3,5,然後0離開之後1進入了,裡面就是1,3,5,然後3離開之後4進入了,裡面就是1,4,5,然後1離開之後6進入了,裡面就是4,5,6,然後5離開7進入,就是4,6,7,然後4離開8進入裡面就是6,7,8,然後6離開9進入了,裡面就是7,8,9,然後7離開之後10進入裡面就是8,9,10,然後8,9,10離開,可以看到訊號量子執行緒中最多是隻有三個執行緒可以獲取到資源,其他執行緒需要等待進入的執行緒釋放訊號量然後在進入,就是呼叫了Release方法讓計數器+1;這裡面的輸出可能有的同學會覺得不準確是因為離開資源的輸出是在釋放之後進行的,所以會出現這種情況。具體使用還是希望大家能夠自己模擬,結合具體場景使用。

多執行緒鎖

       在c#中,多執行緒方面的鎖分為Monitor,以及Mutex,讀寫鎖ReaderWriterLockSlim以及自旋鎖SpinLock,其中Lock關鍵字是根據Monitor裡面的兩個方法進行封裝的,是Enter方法和Exit方法,Mutex是一個可以跨程式的一個同步基元,讀寫鎖是一寫多讀,即一瞬間只允許一個執行緒去進行寫操作,多個執行緒去進行讀,當然還包括了讀鎖升級為寫鎖,自旋鎖則是使用方式是和Lock可以說很像,但Lock是執行緒阻塞的情況下去讓佔用執行緒去執行程式碼段的,而自旋鎖是加入有執行緒已經獲取到了鎖,那其他執行緒需要獲取鎖不是像Lock那樣去進行阻塞等待,而是在重複的迴圈中去獲取鎖,直到獲取到了鎖,Lock簡單就是阻塞等待,SpinLock是迴圈等待,SpinLock不適用阻塞時間長的業務場景,因為過多的旋轉會出現效能方面的問題,同時也不建議是同意上下文中存在多個自旋鎖,具體的優缺點可以著重看一下官方文件,傳送門:https://docs.microsoft.com/zh-cn/dotnet/api/system.threading.spinlock?view=net-6.0。

Monitor

 var monti = new MonitorTest(5, 10);
                foreach (var item in Enumerable.Range(0, 10))
                {
                    Task.Run(() =>
                    {
                        Monitor.Enter(monti);//lock關鍵字是根據Enter 和Exit進行封裝的
                        monti.Add();
                        Monitor.Exit(monti);
                    });
                }
                foreach (var item in Enumerable.Range(0, 10))
                {
                    Thread thread = new Thread(() =>
                    {
                        var IsGetLock = false;
                        Monitor.TryEnter(monti, ref IsGetLock);//在指定的毫秒內去獲取排他鎖,超過則不獲取,ref 引數代表是否獲取到排他鎖
                        while (!IsGetLock)
                        {
                            Monitor.TryEnter(monti, ref IsGetLock);//在指定的毫秒內去獲取排他鎖,超過則不獲取,ref 引數代表是否獲取到排他鎖
                        }
                        Console.WriteLine(Thread.CurrentThread.Name + "獲取");
                        monti.Add();//此方法內部sleep 9000  故是可以獲取到
                        Monitor.Exit(monti);//讓出鎖
                    });
                    thread.Name = item.ToString();
                    thread.Start();
                }
                //TryEnter另一種寫法判斷是否獲取到鎖
                foreach (var item in Enumerable.Range(0, 10))
                {
                    Thread thread = new Thread(() =>
                    {
                        if (Monitor.TryEnter(monti))//在指定的毫秒內去獲取排他鎖,超過則不獲取,ref 引數代表是否獲取到排他鎖
                        {
                            Console.WriteLine(Thread.CurrentThread.Name + "獲取");
                            monti.Add();//此方法內部sleep 9000  故是可以獲取到
                            Monitor.Exit(monti);//讓出鎖
                        }
                        else
                        {
                            while (!Monitor.TryEnter(monti))
                            {
                                Monitor.TryEnter(monti);//在指定的毫秒內去獲取排他鎖,超過則不獲取,ref 引數代表是否獲取到排他鎖
                            }
                            Console.WriteLine(Thread.CurrentThread.Name + "獲取");
                            monti.Add();//此方法內部sleep 9000  故是可以獲取到
                            Monitor.Exit(monti);//讓出鎖
                        }
                    });
                    thread.Name = item.ToString();
                    thread.Start();
                }
  public class MonitorTest
    {
        public MonitorTest(int i, int j)
        {
            I = i;
            J = j;
        }

        private int I { get; set; }
        private int J { get; set; }
        public int Add()
        {
            Thread.Sleep(9000);
            Console.WriteLine(Thread.CurrentThread.Name + " Add");
            I = I + J;
            J = I * J;
            return I + J;
        }
        public int Sub()
        {
            Thread.Sleep(5000);
            Monitor.Pulse(this);
            Console.WriteLine(Thread.CurrentThread.Name + "Pulse 鎖");
            return 0;
        }
        public void WaitTest()
        {
            Console.WriteLine(Thread.CurrentThread.Name + "進入了WaitTest");
            Monitor.Wait(this);
            Console.WriteLine(Thread.CurrentThread.Name + "結束了WaitTest");
        }
        public void PulseTest()
        {
            Console.WriteLine(Thread.CurrentThread.Name + "進入了PulseTest");
            Monitor.Pulse(this);
            Console.WriteLine(Thread.CurrentThread.Name + "結束了PulseTest");
        }
    }

 

       眾所周知,Lock關鍵字是根據Monitor靜態類去進行實現的,Enter方法和Exit方法,在Enter的時候會鎖住物件,由一個執行緒持有,這個物件的控制權,在Exit方法在釋放物件的控制權,那實際上Monitor還提供了多種獲取鎖的方式,嘗試獲取鎖如果獲取到了則去進行下一步的邏輯,TryEnter方法是用來判斷是否獲取到了鎖,裡面的引數以及返回值都可以判斷是否獲取到了鎖。實際上Enter和Exit只是Moniter的比較常用的倆個方法,實際上還有兩個比較有意思的方法Wait和Pulse方法,這兩個方法同樣也是需要傳入等待的物件或者釋放的物件,第一個方法是將當前執行緒阻塞起來,放到等待佇列中去,第二個方法是將當前物件的持有執行緒並且在等待佇列中的放到就緒佇列中等待繼續執行,但是呼叫這個方法之後不是立即去執行接下來的操作的,因為是按照就緒佇列中第一個的位置去進行執行的,釋放掉的會在最後一個等待著就緒佇列的順序執行。

  foreach (var item in Enumerable.Range(0, 10))
                {
                    Thread thread = new Thread(() =>
                    {
                        Monitor.Enter(monti);
                        if (item < 5)
                        {
                            monti.WaitTest();//這邊將執行緒放入等待佇列
                        }
                        else
                        {
                            monti.PulseTest(); // 這裡將執行緒放入就緒佇列,然後繼續執行,
                        }
                        Monitor.Exit(monti);//讓出鎖0,5    1,6  2,7,  3,8  4,9
                    });
                    thread.Name = item.ToString();
                    thread.Start();
                }

       我們可以看到在上面的方法中我們呼叫了WaitTest和PulseTest的兩個方法,並且開啟了是個執行緒,其中小於5的即0,1,2,3,4,這四個執行緒會被放入等待佇列,等待釋放繼續執行,在5,6,7,8,9,我們又把等待佇列中的釋放移到就緒佇列中讓子執行緒繼續執行,接下來我們看看輸出的結果。

 

 

        可以看到0,1,2,3,4執行緒阻塞都沒有輸出結束了WaitTest,那接下來PulseTest方法執行後,5釋放了0,從而0繼續執行輸出了結束了WaitTest,然後6進入PulseTest方法釋放了1,從而輸出結果,然後是7和2,8和3,9和4。Wait和Pulse方法還是比較有意思的方法,雖然我們平常中基本上很少用到,但是我覺得至少有個知識儲備,我覺得從程式設計師就應該有追根朔源的能力,並且就是我可以不用,但必須得會哈哈哈,這是我的一個想法。言歸正傳,這兩個方法,可以具體的結合自身場景去使用。

Mutex

       Mutex是一個可以跨程式的一個同步基元,建構函式有最多有三個引數,第一個參數列示當前執行緒是否具有Mutex初始所有權,第二個為同步基元的名稱,第三個引數為Out引數,代表是否是新建的,false為系統已經存在同名的同步基元,true為是新建的,false情況下可以使用OpenExisting方法來獲取同名的同步基元,這個方法是一個靜態方法,當然還有一個TryOpenExisting來獲取。這個鎖中主要用的兩個方法控制執行緒訪問的有WaitOne方法以及ReleaseMutex方法來釋放控制權。

                foreach (var item in Enumerable.Range(0, 10))
                {
                    Thread thread = new Thread(() =>
                    {
                        Console.WriteLine(Thread.CurrentThread.Name + "執行了執行緒");
                        mutex.WaitOne();//獲取鎖的所有權即等待物件被釋放,
                        Console.WriteLine(Thread.CurrentThread.Name + "獲得了互斥鎖");
                        string ss = "";
                        mutex.ReleaseMutex(); //釋放鎖的所有權,釋放對程式碼塊物件的所有權

                        Console.WriteLine(Thread.CurrentThread.Name + "釋放了互斥鎖");

                    });
                    thread.Name = item.ToString();
                    thread.Start();
                }
                Thread.Sleep(10000);

在上面的程式碼中,我們可以看到開啟十個執行緒,在呼叫了Wait方法後,獲取鎖然後執行下面的程式碼,未獲取的會繼續等待,在下方呼叫了Release方法釋放鎖,等待下一個執行緒進入。

 

 

 

從我們的結果可以看到,獲取的只會有一個獲取到,其他需要等到釋放之後才能繼續獲取。這個類實際上還有很多功能,待你們一一探索,跨程式這裡不做程式碼解釋,之前又在專案中用到過。同時還有其他的用處,這裡需要看官去結合自身場景實現自身的業務功能。

ReadWriteLock

        讀寫鎖的應用場景可能類似與多執行緒情況下,某種資料的執行緒安全場景下使用,一寫多讀,並且讀鎖可以升級寫鎖,這裡讀寫鎖程式碼因為有些長,文末我會附上Gitee地址,大家可以下載看看,我是寫了一個連結串列去進行一寫多讀,

 private readonly ReaderWriterLockSlim readerWriterLockSlim = new ReaderWriterLockSlim();//有參建構函式為支援遞迴的方式還是不支援遞迴的方式,用於指定鎖定遞迴策略。

這樣我們便構造了一個讀寫鎖的例項,引數是使用什麼策略去初始化讀寫鎖,對於遞迴策略,在升級鎖中是不建議使用遞迴的方式,因為可能會造成死鎖的問題,如果是讀取過程中使用遞迴的方式可能會造成LockRecursionException 異常;

對此,官網給出的解釋是:

  • 處於讀取模式的執行緒可以以遞迴方式進入讀取模式,但不能進入寫入模式或可升級模式。 如果嘗試執行此操作,則 LockRecursionException 會引發。 進入讀取模式,然後進入寫入模式或可升級模式是一種具有極大的死鎖概率的模式,因此不允許這樣做。 如前文所述,可升級模式適用於需要升級鎖定的情況。

  • 處於可升級模式的執行緒可以進入寫入模式和/或讀取模式,並且可以遞迴輸入三種模式中的任何一種。 但是,如果有其他執行緒處於讀取模式,則嘗試進入寫入模式塊。

  • 處於寫入模式的執行緒可以進入讀取模式和/或可升級模式,並且可以遞迴輸入三種模式中的任何一種。

  • 未進入鎖定狀態的執行緒可以進入任何模式。 嘗試輸入非遞迴鎖的原因與此嘗試相同。

而且,每次進入鎖的時候在程式碼的最後都要去進行退出鎖。

SpinLock

       自旋鎖,實際上我的理解可能也不夠深,只是看官網的解釋是不適用於阻塞的情況下,以及分配記憶體等,實際上按照理解,執行緒不會阻塞而是一直在通過迴圈旋轉去嘗試獲取鎖,那實際上效能方面如果時間長情況下會出現問題,所以並不適用於阻塞的情況使用,

SpinLock 和Lock相比,SpinLock 更適合共享資源的非耗時操作,如果耗時,並且阻塞的情況下會導致無法進行自旋,造成死鎖,並且鎖內部最好別造成阻塞,造成阻塞效能會劣於Lock,詳細檢視MSDN。

  var sl = new SpinLock(false);
                foreach (var item in Enumerable.Range(0, 100))
                {
                    Thread thread = new Thread(() =>
                    {
                        Stopwatch stopwatch = new Stopwatch();
                        var isEnter = false;
                        sl.TryEnter(ref isEnter);
                        if (isEnter)
                        {
                            stopwatch.Start();
                            var i = item;
                            Queue.Enqueue(i);
                            sl.Exit(false);
                            stopwatch.Stop();
                            Console.WriteLine("SpinLock:" + stopwatch.ElapsedMilliseconds);
                        }
                    });
                    thread.Name = item.ToString();
                    thread.Start();
                }
                foreach (var item in Enumerable.Range(0, 100))
                {
                    Thread thread = new Thread(() =>
                    {
                        lock (_lock)
                        {
                            Stopwatch stopwatch = new Stopwatch();
                            stopwatch.Start();
                            var i = item;
                            Queue.Enqueue(i);
                            stopwatch.Stop();
                            Console.WriteLine("Lock:" + stopwatch.ElapsedMilliseconds);
                        }
                    });
                    thread.Name = item.ToString();
                    thread.Start

    此處的併發場景是多個執行緒去執行業務邏輯時,步驟可能一樣的情況下,在每一步每一步都完成之後在繼續開始執行下一步,即在賽跑的場景下,我們可以分為跑,以及頒獎兩個步驟,那頒獎必須在所有運動員都完成跑步的情況下才會進行頒獎,那此處的場景就是都結束跑步才去進行下一步的操作。c#中有一個專門用來控制這樣場景的類叫Barrier,官網給出的解釋我覺得可能更貼切,使多個任務能夠採用並行方式依據某種演算法在多個階段中協同工作。它的建構函式有兩個引數一個是參與者的數量以及每一個在到達某一步驟之後需要進行的委託,傳入的是Barrier例項。

  //在定義三個屏障參與者之後,待所有參與者都到達屏障,繼續執行後續操作,
                var barrier = new Barrier(3, s =>
                {
                    Console.WriteLine("這是第" + s.CurrentPhaseNumber + "");
                });//類似 三個任務 每個任務有三個階段,也可以3個任務有三個階段,也可以三個任務都只有一個階段
                var test = new BarrTest();
                var random = new Random();
                barrier.AddParticipant();//新增一個參與者
                Action action = () =>
                {
                    Console.WriteLine("第一階段開始");//第一階段開始
                    barrier.SignalAndWait();//第一階段到達屏障
                    Console.WriteLine("第一階段完成");//三個第一階段完成之後才可以執行這一句
                    #region 多工可以有一個階段,也可以多工多階段
                    Console.WriteLine("第二階段開始");//第二階段開始
                    barrier.SignalAndWait();// 第二階段到達屏障
                    Console.WriteLine("第二階段完成");//三個第二階段完成之後才可以執行這一句
                    Console.WriteLine("第三階段開始");//第三階段開始
                    barrier.SignalAndWait();// 第三階段到達屏障
                    Console.WriteLine("第三階段完成");//三個第三階段完成之後才可以執行這一句
                    #endregion
                };
                for (int i = 0; i < 4; i++)
                {
                    Task.Run(action);//三個任務去執行某一個任務,任務有三個階段,每個階段在上一階段完成之後才可以繼續執行
                }

     可以看到我們定義了三個參與者,那每一個參與者在完成之後都要向Barrier發出訊號告知我們完成了這一步驟,在步驟完成之後我們在進行下一步驟,這裡的操作實際上是4*4,就是我們啟動了四個執行緒,每個執行緒執行的部分有包括了四個階段,當然沒我們也可以1*4,

在這個程式碼中將Region部分註釋掉,即23階段註釋掉,然後我們將4改為1,然後Task.run(Action)分別run4次,這樣我們實現了步驟場景下控制步驟執行的先後順序並且,要求每一步到達之後才能繼續下一步。

自定義任務排程

       接下來我認為是到了重頭戲哈哈哈,眾所周知,c#執行緒的發展歷程是thread,threadpool,然後是task,那實際上task也是基於執行緒池實現的排程,對執行緒池的資源有個合理的安排和排程使用,並且線上程控制以及回撥方面都有一個很好的封裝,實際上task都是基於taskschduler的抽象類去進行排程的,這個類是一個抽象類,c#中預設的實現的排程是threadpoolscheduler類去進行執行task方面的排程和執行,這個類裡面有三個抽象方法,分別是獲取佇列的task,以及移除task新增task還有是否可以同步執行task的方法。

接下來我們看自己實現的任務排程以及如何使用

var scheduler = new TaskCustomScheduler();
                var factory = new TaskFactory(scheduler);
                foreach (var item in Enumerable.Range(0, 10))
                {
                    factory.StartNew(() =>
                    {
                        var i = item;
                        Console.WriteLine(i);
                    });

                }

可以看到我們在初始化TaskFactory的時候需要傳入自定義的排程,然後factorystartnew的時候這實際上就是一個task,他會執行到QueueTask方法中將Task新增進去,然後我們會使用ThreadPool去執行這個task,在執行結束之後我們又將這個Task移除掉,實際上自定義排程我們還可以控制實現一個限制數量的一個任務排程。

 

 public class TaskCustomScheduler : TaskScheduler   {
        private SpinLock SpinLock = new SpinLock();
        public TaskCustomScheduler()
        {

        }
        private ConcurrentQueue<Task> Tasks = new ConcurrentQueue<Task>();
        protected override IEnumerable<Task> GetScheduledTasks()
        {
            return Tasks.ToList();
        }

        protected override void QueueTask(Task task)
        {
            Tasks.Enqueue(task);
            RunWork();
        }

        protected override bool TryDequeue(Task task)
        {
           return Tasks.TryDequeue(out task);
        }
        protected override bool TryExecuteTaskInline(Task task, bool taskWasPreviouslyQueued)
        {
            return TryExecuteTask(task);
        }
        private void RunWork()
        {
            ThreadPool.UnsafeQueueUserWorkItem(_ =>
            {
                try
                {
                    foreach (var item in Tasks)
                    {
                        var task = item;
                        var isEnter = false;
                        SpinLock.TryEnter(ref isEnter);
                        TryExecuteTask(task);
                        if (isEnter)
                        {
                            TryDequeue( task);
                            SpinLock.Exit(false);
                        }
                    }
                }
                finally {  }
            }, null);
        }
    }

自定義Awaiter

       在本文中我們會對await關鍵字做講解,async和await的我們會放到下一篇進行詳細講解,包括自定義狀態機,以及IL程式碼的狀態機轉為c#程式碼是什麼樣子,實際上細心的同學會發現,await關鍵字之所以可以await  是因為有TaskAwaiter這個結構體,那對應yield也有一個YieldAwaiter的結構體,他們都有一個共同點就是實現了兩個介面,分別是ICriticalNotifyCompletion, INotifyCompletion這兩個介面一個是安全一個是非安全的,第一個介面又繼承於第二個介面,他只有一個OnCompleted方法傳入一個委託型別的引數,至於這個委託指向那個方法,可能很多人知道這個方法傳入的是什麼,這裡留給大家一個疑問,下一篇關於多執行緒非同步的我們會做講解,

 

 public class CustomAwaiter : ICriticalNotifyCompletion, INotifyCompletion
    {
        public CustomAwaiter(Func<int, int, string> obj)
        {
            Obj = obj;
        }
        private bool bIsFinesh;
        private Timer Timer { get; set; }
        public bool IsCompleted
        {
            get { return bIsFinesh; }
        }
        private SpinLock SpinLock = new SpinLock();
        private string Result { get; set; }
        public Func<int, int, string> Obj { get; }

        public void OnCompleted(Action continuation)
        {
            Timer = new Timer(s => {
                var action = s as Action;
                var bIsEnter = false;
                SpinLock.TryEnter(ref bIsEnter);
                if (bIsEnter)
                {
                    Result = Obj.Invoke(5, 10);
                    SpinLock.Exit(false);
                }
                Thread.Sleep(5000);
                action?.Invoke();
                bIsFinesh = true;

            }, continuation, 5000, int.MaxValue);
        }

        public void UnsafeOnCompleted(Action continuation)
        {
            Timer = new Timer(s => {
                var action = s as Action;
                var bIsEnter = false;
                SpinLock.TryEnter(ref bIsEnter);
                if (bIsEnter)
                {
                    Result = Obj.Invoke(5, 10);
                    SpinLock.Exit(false);
                }
                Thread.Sleep(5000);
                action?.Invoke();
                bIsFinesh = true;
            }, continuation, 5000, int.MaxValue);
        }
        public string GetResult()
        {
            return Result;
        }
    }

       可以看到,我們實現了這兩個介面,可能大家奇怪GetResult是什麼意思,大家都知道TaskAwaiter是有getResult方法的,那YieldAwaiter實際上也有這個方法,這個方法實際上代表你的task執行結束之後的一個結果,但是你整合這兩個介面的時候 是不會自動有這個方法的 需要你們自己去寫一個GetResult方法,除此之外,TaskAwaiter和Yield的也有一個GetAwaiter方法,他們內部的這個方法不是一個靜態方法,但是如果我們實現自定義的情況下是需要有一個 擴充方法叫GetAwaiter方法,返回我們自定義的Awaiter。

public static CustomAwaiter GetAwaiter(this Func<int, int, string> obj)
        {
            return new CustomAwaiter(obj);
        }
foreach (var item in Enumerable.Range(0, 5))
                {
                    Task.Run(async () =>
                    {
                        var i = item;
                        var ts = new Func<int, int, string>((s, b) =>
                        {
                            return Guid.NewGuid().ToString();
                        });
                        //var t= await ts;
                        var tash = new TaskCustomScheduler();
                        var factory = new TaskFactory(tash);
                        await factory.StartNew(async () =>
                         {
                             var t = await ts;
                             Console.WriteLine(t);
                         });
                    });
                }

可以看到,上面的程式碼我們使用自定義的任務排程以及自定義的await去等待Func型別的,獲取到結果然後我們去進行輸出。

 

總結

       對於多執行緒這裡,我也只是淺顯的入門,很多地方我也有點糊塗,所以有不對的地方,希望各位能夠指正,多執行緒方面的程式碼和表示式程式碼我已上傳到網盤,有需要的可以下載,如果有疑問的可以在各個net群裡看有沒有叫四川觀察的,那個就是我,或者加群6406277也可以找到我,

 

 

      連結:https://pan.baidu.com/s/1XdDRkOCDP0mETMYp5d_-xg
      提取碼:1234

 

相關文章