【.NET】多線程:自動重置事件與手動重置事件的區別

在多線程編程中,如果每個線程的運行不是完全獨立的。那麼,一個線程執行到某個時刻需要知道其他線程發生了什麼。嗯,這就是所謂線程同步。同步事件對象(XXXEvent)有兩種行爲:

1、等待。線程在此時會暫停運行,等待其他線程發出信號才繼續(等你約);

2、發出信號。當前線程發出信號,其他正在等待線程收到信號後繼續運行(我約你)。

從前,小明、小偉、小更、小紅、小黃計劃到野外去烤魚喫。但他們只確定市郊東南方向的一片區域,並不能保證具體哪個地點適合燒烤。於是,他們商量好,大家同時從家裏出發。小明離那裏比較近,他先去考察一下;其他人到了東南郊後集合,等小明的消息。小明考察完畢,向大家羣發消息說明選定的地點是F。最後大家繼續前行,奔向F。

等待事件有好幾個:

1、Mutex:互斥體。一次只能有一個線程獲取到互斥體,其他線程只能等。佔用互斥體的線程釋放後,其他線程繼續搶 Mutex。然後只有一個線程能搶到,其他線程繼續等……

2、AutoResetEvent:自動事件,發出信號後立刻重置。

3、ManualResetEvent:手動事件,發出信號後不會立刻重置,得手動重置。

4、CountdownEvent:這個和上面兩個差不多。但它會設定一個計數,線程發出信號時會減少計數。被阻止的線程要等到計數 <= 0 時才獲得信號。

 

本次咱們討論的重點是看看自動重置信號和手動重置信號之間有什麼區別。

 先看看自動重置的。

internal class Program
{

    static AutoResetEvent theEvent = new(false);

    static void Main(string[] args)
    {
        // 啓動三個線程
        ThreadPool.QueueUserWorkItem(DoWorking, "A");
        ThreadPool.QueueUserWorkItem(DoWorking, "B");
        ThreadPool.QueueUserWorkItem(DoWorking, "C");
        // 主線程監聽鍵盤消息
        while(true)
        {
            var keyInfo = Console.ReadKey(true);
            // 看看是不是Y鍵
            if(keyInfo.Key == ConsoleKey.Y)
            {
                // 點亮信號
                theEvent.Set();
            }
            // 輸出一行,方便判斷一個循環
            Console.WriteLine("------------------------------");
        }
    }

    static void DoWorking(object? state)
    {
        while(true)
        {
            // 等待主線程的信號
            // 此線程會暫停
            theEvent.WaitOne();
            // 得到信號了,繼續運行
            Console.WriteLine("{0}已收到通知", state);
        }
    }
}

這個例子創建了三個線程,這裏我用的是線程池,把一個WaitCallback委託傳給 QueueUserWorkItem 方法就可以在線程池中運行新線程。上面示例中綁定的方法是 DoWorking。

AutoResetEvent 類的構造函數傳了一個 bool 值,它的作用是設置等待事件的初始狀態:

1、如果爲 true,表示事件初始狀態爲打開信號,這會使正在等的線程馬上得到信號;

2、如果爲 false,表示事件的初始狀態爲沒有信號,正在等待的線程繼續等。

按照咱們這個例子的實際情況,我們一開始應該讓事件無狀態,讓後臺的三個線程等待。主線程讀取按鍵信息,如果按的是【Y】鍵,那麼事件調用 Set 方法,打開信號。此時,等得花兒都謝了的三個線程會繼續。我們運行一下,看看能否符合預期。

經測試,我們會發現:每次按【Y】後,三個線程中只有一個獲得信號並繼續,其他兩個還在高速上堵車。 AutoResetEvent 的自動重置就是打開信號後又立馬關閉,每次只讓一個線程收到信號。所以,當咱們按一次【Y】鍵後,主線程發出了信號,又馬上關閉。三個後臺線程相互競爭,隨機獲得機會,結束等待並繼續運行。

 

手動重置事件在打開信號後,信號會持續有效,直到調用 Reset 方法手動關閉信號。手動重置信號能讓多個線程有足夠的時間收到信號。

下面咱們把上面的示例改爲使用 ManualResetEvent 類。

internal class Program
{
    static ManualResetEvent theEvent = new(false);

    static void Main(string[] args)
    {
        // 啓動三個線程
        ThreadPool.QueueUserWorkItem(DoWorking, "A");
        ThreadPool.QueueUserWorkItem(DoWorking, "B");
        ThreadPool.QueueUserWorkItem(DoWorking, "C");
        // 主線程監聽鍵盤消息
        while(true)
        {
            var keyInfo = Console.ReadKey(true);
            // 看看是不是Y鍵
            if(keyInfo.Key == ConsoleKey.Y)
            {
                // 點亮信號
                theEvent.Set();

                // 持續一段時間後關閉信號
                Thread.Sleep(3);
                theEvent.Reset();
            }
            // 輸出一行,方便判斷一個循環
            Console.WriteLine("------------------------------");
        }
    }

    static void DoWorking(object? state)
    {
        while(true)
        {
            // 等待主線程的信號
            // 此線程會暫停
            theEvent.WaitOne();
            // 得到信號了,繼續運行
            Console.WriteLine("{0}已收到通知", state);
        }
    }
}

然後運行程序,這一次按下【Y】鍵後,三個線程都能收到信號通知了。

你會發現,有些線程重複了多次,那是因爲 DoWorking 方法裏面是個死循環。當信號持續打開期間,三個線程都有機會收到信號,甚至會重複收到。

上面的東東純屬演示,實際使用的話不會這樣設計。最好的方法是建一個列表對象,主線程接收到的按鍵字符存放到一個列表中,然後,後臺線程不斷地從列表中取出元素來處理。這樣設計程序會更流暢。

internal class Program
{
    #region 字段區域
    static Queue<char> keyChars = new();
    #endregion

    static void Main(string[] args)
    {
        // 啓動三個線程
        ThreadPool.QueueUserWorkItem(DoSomething, "A");
        ThreadPool.QueueUserWorkItem(DoSomething, "B");
        ThreadPool.QueueUserWorkItem(DoSomething, "C");

        while(true)
        {
            // 讀取鍵盤字符
            ConsoleKeyInfo info = Console.ReadKey(true);
            // 將字符放入隊列
            keyChars.Enqueue(info.KeyChar);
        }
    }

    static void DoSomething(object? state)
    {
        while(true)
        {
            // 鎖定
            Monitor.Enter(keyChars);
            if (keyChars.Count > 0)
            {
                // 取掉一個元素
                char c = keyChars.Dequeue();
                Console.WriteLine($"線程【{state}】獲得字符:{c}");
            }
            // 解鎖
            Monitor.Exit(keyChars);
        }
    }
}

這裏我用泛型隊列 Queue<T> 來存放鍵盤敲入的字符,DoSomething 方法將放入線程池中運行。在從隊列中取出元素並處理時,一定要記得上鎖。我用的是 Monitor 對象的靜態方法來上鎖和解鎖,當然你可以用 lock 語句塊。

lock(keyChars)
{
    ……
}

如果不上鎖,線程間在搶佔資源時會導致不一致的狀態。當A線程訪問 keyChars.Count 屬性時得到 1,還是 > 0 的,但在取出最後一個元素前,偏偏B線程動作快把最後一個元素拿走了。當A線程執行到 keyChars.Dequeue() 一句時,keyChars 隊列中已經沒有元素了,會發生錯誤。

主線程在 Enqueue 時並不需要鎖定,因爲元素送入隊列只有一個線程在做,沒人跟他搶資源,可以不鎖定。

運行程序後,可以按字母、數字等按鍵來測試。畢竟像【F3】、【Ctrl】等按鍵獲取到的是空白 char。

這樣就順暢很多了。

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章