在多線程編程中,如果每個線程的運行不是完全獨立的。那麼,一個線程執行到某個時刻需要知道其他線程發生了什麼。嗯,這就是所謂線程同步。同步事件對象(XXXEvent)有兩種行為: 1、等待。線程在此時會暫停運行,等待其他線程發出信號才繼續(等你約); 2、發出信號。當前線程發出信號,其他正在等待線程收到 ...
在多線程編程中,如果每個線程的運行不是完全獨立的。那麼,一個線程執行到某個時刻需要知道其他線程發生了什麼。嗯,這就是所謂線程同步。同步事件對象(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。
這樣就順暢很多了。