【.NET】多執行緒:自動重置事件與手動重置事件的區別

東邪獨孤發表於2023-11-11

在多執行緒程式設計中,如果每個執行緒的執行不是完全獨立的。那麼,一個執行緒執行到某個時刻需要知道其他執行緒發生了什麼。嗯,這就是所謂執行緒同步。同步事件物件(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。

這樣就順暢很多了。

 

相關文章