一、多執行緒程式設計的基本概念
下面的一些基本概念可能和.NET的聯絡並不大,但對於掌握.NET中的多執行緒開發來說卻十分重要。我們在開始嘗試多執行緒開發前,應該對這些基礎知識有所掌握,並且能夠在作業系統層面理解多執行緒的執行方式。
1.1 作業系統層面的程式和執行緒
(1)程式
程式代表了作業系統上執行著的一個應用程式。程式擁有自己的程式塊,擁有獨佔的資源和資料,並且可以被作業系統排程。But,即使是同一個應用程式,當被強制啟動多次時,也會被安放到不同的程式之中單獨執行。
直觀地理解程式最好的方式就是通過程式管理器瀏覽,其中每條記錄就代表了一個活動著的程式:
(2)執行緒
執行緒有時候也被稱為輕量級程式,它的概念和程式十分相似,是一個可以被排程的單元,並且維護自己的堆疊和上下文環境。執行緒是附屬於程式的,一個程式可以包含1個或多個執行緒,並且同一程式內的多個執行緒共享一塊記憶體塊和資源。
由此看來,一個執行緒是一個作業系統可排程的基本單元,但是它的排程受限於該執行緒所屬的程式,也就是說作業系統首先決定執行下一個執行的程式,進而才會排程該程式內的執行緒。一個執行緒的基本生命週期如下圖所示:
(3)程式和執行緒的區別
最大的區別在於隔離性,每個程式都會被單獨隔離(程式擁有自己的記憶體、資源和執行資料,一個程式的崩潰不會影響到其他程式,因此程式間的互動也相對困難),而同一程式內的所有執行緒則共享記憶體和資源,並且一個執行緒可以訪問和結束同一程式內的其他執行緒。
1.2 多執行緒程式在作業系統中是並行執行的嗎?
(1)執行緒的排程
在計算機系統發展的早期,作業系統層面不存在並行的概念,所有的應用程式都在排隊等候一個單執行緒的佇列之中,每個程式都必須等到前面的程式都安全執行完畢之後才能獲得執行的權利,一個小小的錯誤將會導致作業系統上的所有程式的阻塞。在後來的作業系統中,逐漸產生了分時和程式、執行緒的概念。
多個執行緒由作業系統進行排程控制,決定何時執行哪個執行緒。所謂執行緒排程,是指作業系統決定如何安排執行緒執行順序的演算法。按常規分類,執行緒排程可以分為以下兩種:
①搶佔式排程
搶佔式排程是指每個執行緒都只有極少的執行時間(在Windows NT核心模式下這個時間不會超過20ms),而當時間片用完時該執行緒就會被強制暫停,儲存上下文並把執行權利交給下一個執行緒。這樣排程的結果就是:所有的執行緒都在被不停地快速切換執行,使得使用者感覺所有的執行緒都在並行執行。
②非搶佔式排程
非搶佔式排程是指某個執行緒在執行時不會被作業系統強制暫停,它可以持續地執行直到執行告一段落並主動交出執行權。在這樣的排程方式之下,執行緒的執行就是單佇列的,並且可能產生惡意程式長期霸佔執行權的情況。
PS:現在很多的作業系統(包括Windows在內),都同時採用了搶佔式和非搶佔式模式。對於那些優先順序較高的執行緒,OS採用非搶佔式來給予充分的時間執行,而對於普通的執行緒,則採用搶佔式模式來快速地切換執行。
(2)執行緒的並行問題
在單核單CPU的硬體架構上,執行緒的並行執行完全是使用者的主觀體驗。事實上,在任一時刻只可能存在一個處於執行狀態的執行緒。但在多CPU或多核的架構上,情況則略有不同。多CPU多核的架構則允許系統完全並行地執行兩個或多個無其他資源爭用的執行緒,理論上這樣的架構可以使執行效能整數倍地提高。
PS:微軟公司曾經提出超執行緒技術,簡單說來這是一種邏輯上模擬多CPU的技術,但實際上它們卻共享物理處理器和快取,超執行緒對效能的提高相當有限。
1.3 神馬是纖程?
(1)纖程的概念
纖程是微軟公司在Windows上提出的一個概念,其設計目的是用來方便地移植其他作業系統上的應用程式。一個執行緒可以擁有0個或多個纖程,一個纖程可以視為一個輕量級的執行緒,它擁有自己的棧和上下文狀態。But,纖程的排程是由程式設計師編碼控制的,當一個纖程所線上程得到執行時,程式設計師需要手動地決定執行哪一個纖程。
PS:事實上,Windows作業系統核心是不知道纖程的存在的,它只負責排程所有的執行緒,而纖程之所以成為作業系統的概念,是因為Windows提供了關於執行緒操作的Win32函式,能夠方便地幫助程式設計師進行執行緒程式設計。
(2)纖程和執行緒的區別
纖程和執行緒最大的區別在於:執行緒的排程受作業系統的管理,程式設計師無法進行完全乾涉。但纖程卻完全受控於程式設計師本身,允許程式設計師對多工進行自定義的排程和控制,因此纖程帶給程式設計師很大的靈活性。
下圖展示了程式、執行緒以及纖程三者之間的關係:
(3)纖程在.NET中的地位
需要謹記是的一點是:.NET執行框架沒有做出關於執行緒真實性的保證!也就是說,我們在.NET程式中新建的執行緒並不一定是作業系統層面上產生的一個真正執行緒。在.NET框架寄宿的情況下,一個程式中的執行緒很可能對應某個纖程。
PS:所謂CLR寄宿,就是指CLR執行在某個應用程式而非作業系統內。常見的寄宿例子是微軟公司的SQL Server 2005。
二、.NET中的多執行緒程式設計
.NET為多執行緒程式設計提供了豐富的型別和機制,程式設計師需要做的就是掌握這些型別和機制的使用方法和執行原理。
2.1 如何在.NET程式中手動控制多個執行緒?
.NET中提供了多種實現多執行緒程式的方法,但最直接且靈活性最大的,莫過於主動建立、執行、結束所有執行緒。
(1)第一個多執行緒程式
.NET提供了非常直接的控制執行緒型別的型別:System.Threading.Thread類。使用該型別可以直觀地建立、控制和結束執行緒。下面是一個簡單的多執行緒程式:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
class Program { static void Main(string[] args) { Console.WriteLine("進入多執行緒工作模式:"); for (int i = 0; i < 10; i++) { Thread newThread = new Thread(Work); // 開啟新執行緒 newThread.Start(); } Console.ReadKey(); } static void Work() { Console.WriteLine("執行緒開始"); // 模擬做了一些工作,耗費1s時間 Thread.Sleep(1000); Console.WriteLine("執行緒結束"); } } |
在主執行緒中,該程式碼建立了10個新的執行緒,這個10個執行緒的工作互不干擾,巨集觀上來看它們應該是並行執行的,執行的結果也證實了這一點:
PS:這裡再次強調一點,當new了一個Thread型別物件並不意味著生成了一個執行緒,事實上執行緒的生成是在呼叫Thread的Start方法的時候。另外在之前的介紹中,這裡的執行緒並不一定是作業系統層面上產生的一個真正執行緒!
(2)控制執行緒的狀態
很多時候,我們需要主動關心執行緒當前所處的狀態。在任意時刻,.NET中的執行緒都會處於如下圖所示的幾個狀態中的某一個狀態上,該圖也直觀地展示了一個執行緒可能經過的狀態轉換過程(該圖並沒有列出所有的狀態轉換途徑/原因):
下面的示例程式碼則展示了我們如何手動地檢視和控制一個執行緒的狀態:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 |
class Program { static void Main(string[] args) { Console.WriteLine("開始測試執行緒1"); // 初始化一個執行緒 thread1 Thread thread1 = new Thread(Work1); // 這時狀態:UnStarted PrintState(thread1); // 啟動執行緒 Console.WriteLine("現在啟動執行緒"); thread1.Start(); // 這時狀態:Running PrintState(thread1); // 讓執行緒飛一會 3s Thread.Sleep(3 * 1000); // 讓執行緒掛起 Console.WriteLine("現在掛起執行緒"); thread1.Suspend(); // 給執行緒足夠的時間來掛起,否則狀態可能是SuspendRequested Thread.Sleep(1000); // 這時狀態:Suspend PrintState(thread1); // 繼續執行緒 Console.WriteLine("現在繼續執行緒"); thread1.Resume(); // 這時狀態:Running PrintState(thread1); // 停止執行緒 Console.WriteLine("現在停止執行緒"); thread1.Abort(); // 給執行緒足夠的時間來終止,否則的話可能是AbortRequested Thread.Sleep(1000); // 這時狀態:Stopped PrintState(thread1); Console.WriteLine("------------------------------"); Console.WriteLine("開始測試執行緒2"); // 初始化一個執行緒 thread2 Thread thread2 = new Thread(Work2); // 這時狀態:UnStarted PrintState(thread2); // 啟動執行緒 thread2.Start(); Thread.Sleep(2 * 1000); // 這時狀態:WaitSleepJoin PrintState(thread2); // 給執行緒足夠的時間結束 Thread.Sleep(10 * 1000); // 這時狀態:Stopped PrintState(thread2); Console.ReadKey(); } // 普通執行緒方法:一直在執行從未被超越 private static void Work1() { Console.WriteLine("執行緒執行中..."); // 模擬執行緒執行,但不改變執行緒狀態 // 採用忙等狀態 while (true) { } } // 文藝執行緒方法:執行10s就結束 private static void Work2() { Console.WriteLine("執行緒開始睡眠:"); // 睡眠10s Thread.Sleep(10 * 1000); Console.WriteLine("執行緒恢復執行"); } // 列印執行緒的狀態 private static void PrintState(Thread thread) { Console.WriteLine("執行緒的狀態是:{0}", thread.ThreadState.ToString()); } } |
上述程式碼的執行結果如下圖所示:
PS:為了演示方便,上述程式碼刻意地使執行緒處於各個狀態並列印出來。在.NET Framework 4.0 及之後的版本中,已經不再鼓勵使用執行緒的掛起狀態,以及Suspend和Resume方法了。
2.2 如何使用.NET中的執行緒池?
(1).NET中的執行緒池是神馬
我們都知道,執行緒的建立和銷燬需要很大的效能開銷,在Windows NT核心的作業系統中,每個程式都會包含一個執行緒池。而在.NET中呢,也有自己的執行緒池,它是由CLR負責管理的。
執行緒池相當於一個快取的概念,在該池中已經存在了一些沒有被銷燬的執行緒,而當應用程式需要一個新的執行緒時,就可以從執行緒池中直接獲取一個已經存在的執行緒。相對應的,當一個執行緒被使用完畢後並不會立刻被銷燬,而是放入執行緒池中等待下一次使用。
.NET中的執行緒池由CLR管理,管理的策略是靈活可變的,因此執行緒池中的執行緒數量也是可變的,使用者只需向執行緒池提交需求即可,下圖則直觀地展示了CLR是如何處理執行緒池需求的:
PS:執行緒池中執行的執行緒均為後臺執行緒(即執行緒的 IsBackground 屬性被設為true),所謂的後臺執行緒是指這些執行緒的執行不會阻礙應用程式的結束。相反的,應用程式的結束則必須等待所有前臺執行緒結束後才能退出。
(2)在.NET中使用執行緒池
在.NET中通過 System.Threading.ThreadPool 型別來提供關於執行緒池的操作,ThreadPool 型別提供了幾個靜態方法,來允許使用者插入一個工作執行緒的需求。常用的有以下三個靜態方法:
① static bool QueueUserWorkItem(WaitCallback callback)
② static bool QueueUserWorkItem(WaitCallback callback, Object state)
③ static bool UnsafeQueueUserWorkItem(WaitCallback callback, Object state)
有了這幾個方法,我們只需要將執行緒要處理的方法作為引數傳入上述方法即可,隨後的工作都由CLR的執行緒池管理程式來完成。其中,WaitCallback 是一個委託型別,該委託方法接受一個Object型別的引數,並且沒有返回值。下面的程式碼展示瞭如何使用執行緒池來編寫多執行緒的程式:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
class Program { static void Main(string[] args) { string taskInfo = "執行10秒"; // 插入一個新的請求到執行緒池 bool result = ThreadPool.QueueUserWorkItem(DoWork, taskInfo); // 分配執行緒有可能會失敗 if (!result) { Console.WriteLine("分配執行緒失敗"); } else { Console.WriteLine("按Enter鍵結束程式"); } Console.ReadKey(); } private static void DoWork(object state) { // 模擬做了一些操作,耗時10s for (int i = 0; i < 10; i++) { Console.WriteLine("工作者執行緒的任務是:{0}", state); Thread.Sleep(1000); } } } |
上述程式碼執行後,如果不輸入任何字元,那麼會得到如下圖所示的執行結果:
PS:事實上,UnsafeQueueWorkItem方法實現了完全相同的功能,二者的差別在於UnsafeQueueWorkItem方法不會將呼叫執行緒的堆疊傳遞給輔助執行緒,這就意味著主執行緒的許可權限制不會傳遞給輔助執行緒。UnsafeQueueWorkItem由於不進行這樣的傳遞,因此會得到更高的執行效率,但是潛在地提升了輔助執行緒的許可權,也就有可能會成為一個潛在的安全漏洞。
2.3 如何檢視和設定執行緒池的上下限?
執行緒池的執行緒數是有限制的,通常情況下,我們無需修改預設的配置。但在一些場合,我們可能需要了解執行緒池的上下限和剩餘的執行緒數。執行緒池作為一個緩衝池,有著其上下限。在通常情況下,當執行緒池中的執行緒數小於執行緒池設定的下限時,執行緒池會設法建立新的執行緒,而當執行緒池中的執行緒數大於執行緒池設定的上限時,執行緒池將銷燬多餘的執行緒。
PS:在.NET Framework 4.0中,每個CPU預設的工作者執行緒數量最大值為250個,最小值為2個。而IO執行緒的預設最大值為1000個,最小值為2個。
在.NET中,通過 ThreadPool 型別提供的5個靜態方法可以獲取和設定執行緒池的上限和下限,同時它還額外地提供了一個方法來讓程式設計師獲知當前可用的執行緒數量,下面是這五個方法的簽名:
① static void GetMaxThreads(out int workerThreads, out int completionPortThreads)
② static void GetMinThreads(out int workerThreads, out int completionPortThreads)
③ static bool SetMaxThreads(int workerThreads, int completionPortThreads)
④ static bool SetMinThreads(int workerThreads, int completionPortThreads)
⑤ static void GetAvailableThreads(out int workerThreads, out int completionPortThreads)
下面的程式碼示例演示瞭如何查詢執行緒池的上下限閾值和可用執行緒數量:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 |
class Program { static void Main(string[] args) { // 列印閾值和可用數量 GetLimitation(); GetAvailable(); // 使用掉其中三個執行緒 Console.WriteLine("此處申請使用3個執行緒..."); ThreadPool.QueueUserWorkItem(Work); ThreadPool.QueueUserWorkItem(Work); ThreadPool.QueueUserWorkItem(Work); Thread.Sleep(1000); // 列印閾值和可用數量 GetLimitation(); GetAvailable(); // 設定最小值 Console.WriteLine("此處修改了執行緒池的最小執行緒數量"); ThreadPool.SetMinThreads(10, 10); // 列印閾值 GetLimitation(); Console.ReadKey(); } // 執行10s的方法 private static void Work(object o) { Thread.Sleep(10 * 1000); } // 列印執行緒池的上下限閾值 private static void GetLimitation() { int maxWork, minWork, maxIO, minIO; // 得到閾值上限 ThreadPool.GetMaxThreads(out maxWork, out maxIO); // 得到閾值下限 ThreadPool.GetMinThreads(out minWork, out minIO); // 列印閾值上限 Console.WriteLine("執行緒池最多有{0}個工作者執行緒,{1}個IO執行緒", maxWork.ToString(), maxIO.ToString()); // 列印閾值下限 Console.WriteLine("執行緒池最少有{0}個工作者執行緒,{1}個IO執行緒", minWork.ToString(), minIO.ToString()); Console.WriteLine("------------------------------------"); } // 列印可用執行緒數量 private static void GetAvailable() { int remainWork, remainIO; // 得到當前可用執行緒數量 ThreadPool.GetAvailableThreads(out remainWork, out remainIO); // 列印可用執行緒數量 Console.WriteLine("執行緒池中當前有{0}個工作者執行緒可用,{1}個IO執行緒可用", remainWork.ToString(), remainIO.ToString()); Console.WriteLine("------------------------------------"); } } |
該例項的執行結果如下圖所示:
PS:上面程式碼示例在不同的計算機上執行可能會得到不同的結果,執行緒池中的可用數碼不會再初始時達到最大值,事實上CLR會嘗試以一定的時間間隔來逐一地建立新執行緒,但這個時間間隔非常短。
2.4 如何定義執行緒獨享的全域性資料?
執行緒和程式最大的一個區別就在於執行緒間可以共享資料和資源,而程式則充分地隔離。在很多場合,即使同一程式的多個執行緒之間擁有相同的記憶體空間,也需要在邏輯上為某些執行緒分配獨享的資料。例如,在實際開發中往往會針對一些ORM如EF一類的上下文實體做執行緒內唯一例項的設定,這時就需要用到下面提到的技術。
(1)執行緒本地儲存(Thread Local Storage,TLS)
很多時候,程式設計師可能會希望擁有執行緒內可見的變數,而不希望其他執行緒對其進行訪問和修改(傳統方式中的靜態變數是對整個應用程式域可見的),這就需要用到TLS的概念。所謂的執行緒本地儲存(TLS)是指儲存線上程環境塊內的一個結構,用來存放該執行緒內獨享的資料。程式內的執行緒不能訪問不屬於自己的TLS,這就保證了TLS內的資料線上程內是全域性共享的,而對於執行緒外確實不可見的。
(2)定義和使用TLS變數
在.NET中提供了下列連個方法來存取執行緒獨享的資料,它們都定義在System.Threading.Thread型別中:
① object GetData(LocalDataStoreSlot slot)
② void SetData(LocalDataStoreSlot slot, object data)
下面的程式碼示例則展示了這個機制的使用方法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 |
class Program { static void Main(string[] args) { Console.WriteLine("開始測試資料插槽:"); // 建立五個執行緒來同時執行,但是這裡不適合用執行緒池, // 因為執行緒池內的執行緒會被反覆使用導致執行緒ID一致 for (int i = 0; i < 5; i++) { Thread thread = new Thread(ThreadDataSlot.Work); thread.Start(); } Console.ReadKey(); } } /// <summary> /// 包含執行緒方法和資料插槽 /// </summary> public class ThreadDataSlot { // 分配一個資料插槽,注意插槽本身是全域性可見的,因為這裡的分配是在所有執行緒 // 的TLS內建立資料塊 private static LocalDataStoreSlot localSlot = Thread.AllocateDataSlot(); // 執行緒要執行的方法,運算元據插槽來存放資料 public static void Work() { // 將執行緒ID註冊到資料插槽中,一個應用程式內執行緒ID不會重複 Thread.SetData(localSlot, Thread.CurrentThread.ManagedThreadId); // 檢視一下剛剛插入的資料 Console.WriteLine("執行緒{0}內的資料是:{1}",Thread.CurrentThread.ManagedThreadId.ToString(),Thread.GetData(localSlot).ToString()); // 這裡執行緒休眠1秒 Thread.Sleep(1000); // 檢視其他執行緒的執行是否干擾了當前執行緒資料插槽內的資料 Console.WriteLine("執行緒{0}內的資料是:{1}", Thread.CurrentThread.ManagedThreadId.ToString(), Thread.GetData(localSlot).ToString()); } } |
該例項的執行結果如下圖所示,從下圖可以看出多執行緒的並行執行並沒有破壞每個執行緒插槽內的資料,這就是TLS所提供的功能。
PS:LocalDataStoreSlot物件本身並不是執行緒共享的,初始化一個LocalDataStoreSlot物件意味著在應用程式域內的每個執行緒上都分配了一個資料插槽。
(3)ThreadStaticAttribute特性的使用
除了使用上面說到的資料槽之外,我們還有另一種方式,即ThreadStaticAttribute特性。申明瞭該特性的變數,會被.NET作為執行緒獨享的資料來使用。我們可以將其理解為一種被.NET封裝了的TLS機制,本質上,它仍然使用了執行緒環境塊來存放資料。
下面的示例程式碼展示了ThreadStaticAttribute特性的使用:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 |
class Program { static void Main(string[] args) { Console.WriteLine("開始測試資料插槽:"); // 建立五個執行緒來同時執行,但是這裡不適合用執行緒池, // 因為執行緒池內的執行緒會被反覆使用導致執行緒ID一致 for (int i = 0; i < 5; i++) { Thread thread = new Thread(ThreadStatic.Work); thread.Start(); } Console.ReadKey(); } } /// <summary> /// 包含執行緒靜態資料 /// </summary> public class ThreadStatic { // 值型別的執行緒靜態資料 [ThreadStatic] private static int threadId = 0; // 引用型別的執行緒靜態資料 private static Ref refThreadId = new Ref(); /// <summary> /// 執行緒執行的方法,操作執行緒靜態資料 /// </summary> public static void Work() { // 儲存執行緒ID,一個應用程式域內執行緒ID不會重複 threadId = Thread.CurrentThread.ManagedThreadId; refThreadId.Id = Thread.CurrentThread.ManagedThreadId; // 檢視一下剛剛插入的資料 Console.WriteLine("[執行緒{0}]:執行緒靜態值變數:{1},執行緒靜態引用變數:{2}", Thread.CurrentThread.ManagedThreadId.ToString(), threadId, refThreadId.Id.ToString()); // 睡眠1s Thread.Sleep(1000); // 檢視其他執行緒的執行是否干擾了當前執行緒靜態資料 Console.WriteLine("[執行緒{0}]:執行緒靜態值變數:{1},執行緒靜態引用變數:{2}", Thread.CurrentThread.ManagedThreadId.ToString(), threadId, refThreadId.Id.ToString()); } } /// <summary> /// 簡單引用型別 /// </summary> public class Ref { private int id; public int Id { get { return id; } set { id = value; } } } |
該例項的執行結果如下圖所示,正如我們所看到的,對於使用了ThreadStatic特性的欄位,.NET會將其作為執行緒獨享的資料來處理,當某個執行緒對一個使用了ThreadStatic特性的欄位進行賦值後,這個值只有這個執行緒自己可以看到並訪問修改,該值對於其他執行緒時不可見的。相反,沒有標記該特性的,則會被多個執行緒所共享。
2.5 如何使用非同步模式讀取一個檔案?
非同步模式是在處理流型別時經常採用的一種方式,其應用的領域相當廣闊,包括讀寫檔案、網路傳輸、讀寫資料庫,甚至可以採用非同步模式來做任何計算工作。相對於手動編寫執行緒程式碼,非同步模式是一個高效的程式設計模式。
(1)所謂非同步模式是個什麼鬼?
所謂的非同步模式,是指在啟動一個操作之後可以繼續執行其他工作而不會發生阻塞。以讀取檔案為例,在同步模式下,當程式執行到Read方法時,需要等到讀取動作結束後才能繼續往下執行。而非同步模式則可以簡單地通知開始讀取任務之後,繼續其他的操作。 非同步模式的優點就在於不需要使當前執行緒等待,而可以充分地利用CPU時間。
PS:非同步模式區別於執行緒池機制的地方在於其允許程式檢視操作的執行狀態,而如果利用執行緒池的後臺執行緒,則無法確切地知道操作的進行狀態以及其是否已經結束。
使用非同步模式可以通過一些非同步聚集技巧來檢視非同步操作的結果,所謂的聚集技巧是指檢視操作是否結束的方法,常用的方式是:在呼叫BeingXXX方法時傳入操作結束後需要執行的方法(又稱為回撥方法),同時把執行非同步操作的物件傳入以便執行EndXXX方法。
(2)使用非同步模式讀取一個檔案
下面的示例程式碼中:
① 主執行緒中負責開始非同步讀取並傳入聚集時需要使用的方法和狀態物件:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 |
partial class Program { // 測試檔案 private const string testFile = @"C:\AsyncReadTest.txt"; private const int bufferSize = 1024; static void Main(string[] args) { // 刪除已存在檔案 if (File.Exists(testFile)) { File.Delete(testFile); } // 寫入一些東西以便後面讀取 using (FileStream stream = File.Create(testFile)) { string content = "我是檔案具體內容,我是不是帥得掉渣?"; byte[] contentByte = Encoding.UTF8.GetBytes(content); stream.Write(contentByte, 0, contentByte.Length); } // 開始非同步讀取檔案具體內容 using (FileStream stream = new FileStream(testFile, FileMode.Open, FileAccess.Read, FileShare.Read, bufferSize, FileOptions.Asynchronous)) { byte[] data = new byte[bufferSize]; // 將自定義型別物件例項作為引數 ReadFileClass rfc = new ReadFileClass(stream, data); // 開始非同步讀取 IAsyncResult result = stream.BeginRead(data, 0, data.Length, FinshCallBack, rfc); // 模擬做了一些其他的操作 Thread.Sleep(3 * 1000); Console.WriteLine("主執行緒執行完畢,按Enter鍵退出程式"); } Console.ReadKey(); } } |
② 定義了完成非同步操作讀取之後需要呼叫的方法,其邏輯是簡單地列印出檔案的內容:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
partial class Program { /// <summary> /// 完成非同步操作後的回撥方法 /// </summary> /// <param name="result">狀態物件</param> private static void FinshCallBack(IAsyncResult result) { ReadFileClass rfc = result.AsyncState as ReadFileClass; if (rfc != null) { // 必須的步驟:讓非同步讀取佔用的資源被釋放掉 int length = rfc.stream.EndRead(result); // 獲取讀取到的檔案內容 byte[] fileData = new byte[length]; Array.Copy(rfc.data, 0, fileData, 0, fileData.Length); string content = Encoding.UTF8.GetString(fileData); // 列印讀取到的檔案基本資訊 Console.WriteLine("讀取檔案結束:檔案長度為[{0}],檔案內容為[{1}]", length.ToString(), content); } } } |
③ 定義了作為狀態物件傳遞的型別,這個型別對所有需要傳遞的資料包進行打包:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
/// <summary> /// 傳遞給非同步操作的回撥方法 /// </summary> public class ReadFileClass { // 以便回撥方法中釋放非同步讀取的檔案流 public FileStream stream; // 檔案內容 public byte[] data; public ReadFileClass(FileStream stream,byte[] data) { this.stream = stream; this.data = data; } } |
下圖展示了該例項的執行結果:
如上面的例項,使用回撥方法的非同步模式需要花費一點額外的程式碼量,因為它需要將非同步操作的物件及操作的結果資料都打包到一個型別裡以便能夠傳遞迴給回撥的委託方法,這樣在委託方法中才能夠有機會處理操作的結果,並且呼叫EndXXX方法以釋放資源。
2.6 如何阻止執行緒執行上下文的傳遞?
(1)何為執行緒的執行上下文
在.NET中,每一個執行緒都會包含一個執行上下文,執行上下文是指執行緒執行中某時刻的上下文概念,類似於一個動態過程的快照(SnapShot)。在.NET中,System.Threading中的ExecutionContext型別代表了一個執行上下文,該執行上下文會包含:安全上下文、呼叫上下文、本地化上下文、事務上下文和CLR宿主上下文等等。通常情況下,我們將所有這些綜合成為執行緒的上下文。
(2)執行上下文的流動
當程式中新建一個執行緒時,執行上下文會自動地從當前執行緒流入到新建的執行緒之中,這樣做可以保證新建的執行緒天生就就有和主執行緒相同的安全設定和文化等設定。下面的示例程式碼通過修改安全上下文來展示執行緒上下文的流動性,主要使用到ExecutionContext類的Capture方法來捕獲當前想成的執行上下文。
① 首先定義一些輔助犯法,封裝了檔案的建立、刪除和檔案訪問許可權檢查:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 |
partial class Program { private static void CreateTestFile() { if (!File.Exists(testFile)) { FileStream stream = File.Create(testFile); stream.Dispose(); } } private static void DeleteTestFile() { if (File.Exists(testFile)) { File.Delete(testFile); } } // 嘗試訪問測試檔案來測試安全上下文 private static void JudgePermission(object state) { try { // 嘗試訪問檔案 File.GetCreationTime(testFile); // 如果沒有異常則測試通過 Console.WriteLine("許可權測試通過"); } catch (SecurityException) { // 如果出現異常則測試通過 Console.WriteLine("許可權測試沒有通過"); } finally { Console.WriteLine("------------------------"); } } } |
② 其次在入口方法中使主執行緒和建立的子執行緒訪問指定檔案來檢視許可權上下文流動到子執行緒中的情況:(這裡需要注意的是由於在.NET 4.0及以上版本中FileIOPermission的Deny方法已過時,為了方便測試,將程式的.NET版本調整為了3.5)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 |
partial class Program { private const string testFile = @"C:\TestContext.txt"; static void Main(string[] args) { try { CreateTestFile(); // 測試當前執行緒的安全上下文 Console.WriteLine("主執行緒許可權測試:"); JudgePermission(null); // 建立一個子執行緒 subThread1 Console.WriteLine("子執行緒許可權測試:"); Thread subThread1 = new Thread(JudgePermission); subThread1.Start(); subThread1.Join(); // 現在修改安全上下文,阻止檔案訪問 FileIOPermission fip = new FileIOPermission(FileIOPermissionAccess.AllAccess, testFile); fip.Deny(); Console.WriteLine("已成功阻止檔案訪問"); // 測試當前執行緒的安全上下文 Console.WriteLine("主執行緒許可權測試:"); JudgePermission(null); // 建立一個子執行緒 subThread2 Console.WriteLine("子執行緒許可權測試:"); Thread subThread2 = new Thread(JudgePermission); subThread2.Start(); subThread2.Join(); // 現在修改安全上下文,允許檔案訪問 SecurityPermission.RevertDeny(); Console.WriteLine("已成功恢復檔案訪問"); // 測試當前執行緒安全上下文 Console.WriteLine("主執行緒許可權測試:"); JudgePermission(null); // 建立一個子執行緒 subThread3 Console.WriteLine("子執行緒許可權測試:"); Thread subThread3 = new Thread(JudgePermission); subThread3.Start(); subThread3.Join(); Console.ReadKey(); } finally { DeleteTestFile(); } } } |
該例項的執行結果如下圖所示,從圖中可以看出程式中通過FileIOPermission物件來控制對主執行緒對檔案的訪問許可權,並且通過新建子執行緒來檢視主執行緒的安全上下文的改變是否會影響到子執行緒。
正如剛剛說到,主執行緒的安全上下文將作為執行上下文的一部分由主執行緒傳遞給子執行緒。
(3)阻止上下文的流動
有的時候,系統需要子執行緒擁有新的上下文。拋開功能上的需求,執行上下文的流動確實使得程式的執行效率下降很多,執行緒上下文的包裝是一個成本較高的工作,而有的時候這樣的包裝並不是必須的。在這種情況下,我們如果需要手動地防止執行緒上下文的流動,常用的有下列兩種方法:
① System.Threading.ThreadPool類中的UnsafeQueueUserWorkItem方法
② ExecutionContext類中的SuppressFlow方法
下面的程式碼示例展示瞭如何使用上面兩種方法阻止執行上下文的流動:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 |
partial class Program { private const string testFile = @"C:\TestContext.txt"; static void Main(string[] args) { try { CreateTestFile(); // 現在修改安全上下文,阻止檔案訪問 FileIOPermission fip = new FileIOPermission(FileIOPermissionAccess.AllAccess, testFile); fip.Deny(); Console.WriteLine("已成功阻止檔案訪問"); // 主執行緒許可權測試 Console.WriteLine("主執行緒許可權測試:"); JudgePermission(null); // 使用UnsafeQueueUserWorkItem方法建立一個子執行緒 Console.WriteLine("子執行緒許可權測試:"); ThreadPool.UnsafeQueueUserWorkItem(JudgePermission, null); Thread.Sleep(1000); // 使用SuppressFlow方法 using (var afc = ExecutionContext.SuppressFlow()) { // 測試當前執行緒安全上下文 Console.WriteLine("主執行緒許可權測試:"); JudgePermission(null); // 建立一個子執行緒 subThread1 Console.WriteLine("子執行緒許可權測試:"); Thread subThread1 = new Thread(JudgePermission); subThread1.Start(); subThread1.Join(); } // 現在修改安全上下文,允許檔案訪問 SecurityPermission.RevertDeny(); Console.WriteLine("已成功恢復檔案訪問"); // 測試當前執行緒安全上下文 Console.WriteLine("主執行緒許可權測試:"); JudgePermission(null); // 建立一個子執行緒 subThread2 Console.WriteLine("子執行緒許可權測試:"); Thread subThread2 = new Thread(JudgePermission); subThread2.Start(); subThread2.Join(); Console.ReadKey(); } finally { DeleteTestFile(); } } } |
該例項的執行結果如下圖所示,可以看出,通過前面的兩種方式有效地阻止了主執行緒的執行上下文流動到新建的執行緒之中,這樣的機制對於效能的提高有一定的幫助。
三、多執行緒程式設計中的執行緒同步
3.1 理解同步塊和同步塊索引
同步塊是.NET中解決物件同步問題的基本機制,該機制為每個堆內的物件(即引用型別物件例項)分配一個同步索引,該索引中只儲存一個表明陣列內索引的整數。具體過程是:.NET在載入時就會新建一個同步塊陣列,當某個物件需要被同步時,.NET會為其分配一個同步塊,並且把該同步塊在同步塊陣列中的索引加入該物件的同步塊索引中。下圖展現了這一機制的實現:
同步塊機制包含以下幾點:
① 在.NET被載入時初始化同步塊陣列;
② 每一個被分配在堆上的物件都會包含兩個額外的欄位,其中一個儲存型別指標,而另外一個就是同步塊索引,初始時被賦值為-1;
③ 當一個執行緒試圖使用該物件進入同步時,會檢查該物件的同步索引:
如果同步索引為負數,則會在同步塊陣列中新建一個同步塊,並且將該同步塊的索引值寫入該物件的同步索引中;
如果同步索引不為負數,則找到該物件的同步塊並檢查是否有其他執行緒在使用該同步塊,如果有則進入等待狀態,如果沒有則申明使用該同步塊;
④ 當一個物件退出同步時,該物件的同步索引被修改為-1,並且相應的同步塊陣列中的同步塊被視為不再使用。
3.2 C#中的lock關鍵字有啥作用?
lock關鍵字可能是我們在遇到執行緒同步的需求時最常用的方式,但lock只是一個語法糖,為什麼這麼說呢,下面慢慢道來。
(1)lock的等效程式碼其實是Monitor類的Enter和Exit兩個方法
1 2 3 4 5 6 7 8 |
private object locker = new object(); public void Work() { lock (locker) { // 做一些需要執行緒同步的工作 } } |
事實上,lock關鍵字時一個方便程式設計師使用的語法糖,它等效於安全地使用System.Threading.Monitor型別,它直接等效於下面的程式碼:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
private object locker = new object(); public void Work() { // 避免直接使用私有成員locker(直接使用有可能會導致執行緒不安全) object temp = locker; Monitor.Enter(temp); try { // 做一些需要執行緒同步的工作 } finally { Monitor.Exit(temp); } } |
(2)System.Threading.Monitor型別的作用和使用
Monitor型別的Enter和Exit方法用來實現進入和退出物件的同步,當Enter方法被呼叫時,物件的同步索引將被檢查,並且.NET將負責一系列的後續工作來保證物件訪問時的執行緒同步,而Exit方法的呼叫則保證了當前執行緒釋放該物件的同步塊。
下面的程式碼示例演示瞭如何使用lock關鍵字來實現執行緒同步:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 |
class Program { static void Main(string[] args) { // 多執行緒測試靜態方法的同步 Console.WriteLine("開始測試靜態方法的同步:"); for (int i = 0; i < 5; i++) { Thread thread = new Thread(Lock.StaticIncrement); thread.Start(); } // 這裡等待執行緒執行結束 Thread.Sleep(5 * 1000); Console.WriteLine("-------------------------------"); // 多執行緒測試例項方法的同步 Console.WriteLine("開始測試例項方法的同步:"); Lock l = new Lock(); for (int i = 0; i < 6; i++) { Thread thread = new Thread(l.InstanceIncrement); thread.Start(); } Console.ReadKey(); } } public class Lock { // 靜態方法同步鎖 private static object staticLocker = new object(); // 例項方法同步鎖 private object instanceLocker = new object(); // 成員變數 private static int staticNumber = 0; private int instanceNumber = 0; // 測試靜態方法的同步 public static void StaticIncrement(object state) { lock (staticLocker) { Console.WriteLine("當前執行緒ID:{0}", Thread.CurrentThread.ManagedThreadId.ToString()); Console.WriteLine("staticNumber的值為:{0}", staticNumber.ToString()); // 這裡可以製造執行緒並行執行的機會,來檢查同步的功能 Thread.Sleep(200); staticNumber++; Console.WriteLine("staticNumber自增後為:{0}", staticNumber.ToString()); } } // 測試例項方法的同步 public void InstanceIncrement(object state) { lock (instanceLocker) { Console.WriteLine("當前執行緒ID:{0}",Thread.CurrentThread.ManagedThreadId.ToString()); Console.WriteLine("instanceNumber的值為:{0}", instanceNumber.ToString()); // 這裡可以製造執行緒並行執行的機會,來檢查同步的功能 Thread.Sleep(200); instanceNumber++; Console.WriteLine("instanceNumber自增後為:{0}", instanceNumber.ToString()); } } } |
下圖是該例項的執行結果:
PS:執行緒同步本身違反了多執行緒並行執行的原則,所以我們在使用執行緒同步時應該儘量做到將lock加在最小的程式塊上。對於靜態方法的同步,一般採用靜態私有的引用物件成員,而對於例項方法的同步,一般採用私有的引用物件成員。
3.3 可否使用值型別物件來實現執行緒同步嗎?
前面已經說到,在.NET中每個堆內的物件都會有一個同步索引欄位,用以指向同步塊的位置。但是,對於值型別來說,它們的物件是分配在堆疊上的,也就是說值型別是沒有同步索引這一欄位的,所以直接使用值型別物件無法實現執行緒同步。
如果在程式中對於lock關鍵字使用了值型別物件,會直接導致一個編譯錯誤:
3.4 可否使用引用型別物件自身進行同步?
引用型別的物件是分配在堆上的,必然會包含同步索引,也可以分配同步塊,所以原則上可以在物件的方法內對自身進行同步。而事實上,這樣的程式碼也確實能有效地保證執行緒同步。But,這樣的程式碼健壯性存在一定問題。
(1)lock(this)
回顧lock(this)的設計,就可以看出問題來:this代表了執行程式碼的當前物件,可以預見該物件可以被任何使用者訪問,這就導致了不僅物件內部的程式碼在爭用同步塊,連型別的使用者也可以有意無意地進入到爭用的隊伍中→這顯然不符合設計意圖。
下面通過一個程式碼示例展示了一個惡意的使用者是如何導致型別死鎖的:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 |
class Program { static void Main(string[] args) { Console.WriteLine("開始使用"); SynchroThis st = new SynchroThis(); // 模擬惡意的使用者 Monitor.Enter(st); // 正常的使用者會收到惡意使用者的影響 // 下面的程式碼完全正確,但卻被死鎖 Thread thread = new Thread(st.Work); thread.Start(); thread.Join(); // 程式不會執行到這裡 Console.WriteLine("使用結束"); Console.ReadKey(); } } public class SynchroThis { private int number = 0; public void Work(object state) { lock (this) { Console.WriteLine("number現在的值為:{0}", number.ToString()); number++; // 模擬做了其他工作 Thread.Sleep(200); Console.WriteLine("number自增後值為:{0}", number.ToString()); } } } |
執行這個示例,我們發現程式完全被死鎖,這是因為一個惡意的使用者在使用了同步塊之後卻沒有對其進行釋放,導致了SynchroThis型別的方法被組織。
(2)lock(typeof(型別名))
這樣的設計有時候會被用來在靜態方法中實現執行緒同步,因為靜態方法的訪問需要通過型別來進行,但它也和lock(this)一樣,缺乏健壯性。下面展示了常見的錯誤使用程式碼示例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 |
class Program { static void Main(string[] args) { Console.WriteLine("開始使用"); SynchroThis st = new SynchroThis(); // 模擬惡意的使用者 Monitor.Enter(typeof(SynchroThis)); // 正常的使用者會收到惡意使用者的影響 // 下面的程式碼完全正確,但卻被死鎖 Thread thread = new Thread(SynchroThis.Work); thread.Start(); thread.Join(); // 程式不會執行到這裡 Console.WriteLine("使用結束"); Console.ReadKey(); } } public class SynchroThis { private static int number = 0; public static void Work(object state) { lock (typeof(SynchroThis)) { Console.WriteLine("number現在的值為:{0}", number.ToString()); number++; // 模擬做了其他工作 Thread.Sleep(200); Console.WriteLine("number自增後值為:{0}", number.ToString()); } } } |
可以發現,當一個惡意的使用者對type物件進行同步時,也會造成所有的使用者被死鎖。
PS:應該完全避免使用this物件和當前型別物件作為同步物件,而應該在型別中定義私有的同步物件,同時應該使用lock而不是Monitor型別,這樣可以有效地減少同步塊不被釋放的情況。
3.5 互斥體是個什麼鬼?Mutex和Monitor兩個型別的功能有啥區別?
(1)什麼是互斥體?
在作業系統中,互斥體(Mutex)是指某些程式碼片段在任意時間內只允許一個執行緒進入。例如,正在進行一盤棋,任意時刻只允許一個棋手往棋盤上落子,這和執行緒同步的概念基本一致。
(2).NET中的互斥體
Mutex類是.NET中為我們封裝的一個互斥體型別,和Mutex類似的還有Semaphore(訊號量)等型別。下面的示例程式碼展示了Mutext型別的使用:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 |
class Program { const string testFile = "C:\\TestMutex.txt"; /// <summary> /// 這個互斥體保證所有的程式都能得到同步 /// </summary> static Mutex mutex = new Mutex(false, "TestMutex"); static void Main(string[] args) { //留出時間來啟動其他程式 Thread.Sleep(3000); DoWork(); mutex.Close(); Console.ReadKey(); } /// <summary> /// 往檔案裡寫連續的內容 /// </summary> static void DoWork() { long d1 = DateTime.Now.Ticks; mutex.WaitOne(); long d2 = DateTime.Now.Ticks; Console.WriteLine("經過了{0}個Tick後程式{1}得到互斥體,進入臨界區程式碼。", (d2 - d1).ToString(), Process.GetCurrentProcess().Id.ToString()); try { if (!File.Exists(testFile)) { FileStream fs = File.Create(testFile); fs.Dispose(); } for (int i = 0; i < 5; i++) { // 每次都保證檔案被關閉再重新開啟 // 確定有mutex來同步,而不是IO機制 using (FileStream fs = File.Open(testFile, FileMode.Append)) { string content = "【程式" + Process.GetCurrentProcess().Id.ToString() + "】:" + i.ToString() + "\r\n"; Byte[] data = Encoding.Default.GetBytes(content); fs.Write(data, 0, data.Length); } // 模擬做了其他工作 Thread.Sleep(300); } } finally { mutex.ReleaseMutex(); } } } |
模擬多個使用者,執行上述程式碼,下圖就是在我的計算機上的執行結果:
現在開啟C盤目錄下的TestMutext.txt檔案,將看到如下圖所示的結果:
(3)Mutex和Monitor的區別
這兩者雖然都用來進行同步的功能,但實現方法不同,其最顯著的兩個差別如下:
① Mutex使用的是作業系統的核心物件,而Monitor型別的同步機制則完全在.NET框架之下實現,這就導致了Mutext型別的效率要比Monitor型別要低很多;
② Monitor型別只能同步同一應用程式域中的執行緒,而Mutex型別卻可以跨越應用程式域和程式。
3.6 如何使用訊號量Semaphore?
這裡首先借用阮一峰的《程式與執行緒的一個簡單解釋》中的介紹來說一下Mutex和Semaphore:
一個防止他人進入的簡單方法,就是門口加一把鎖。先到的人鎖上門,後到的人看到上鎖,就在門口排隊,等鎖開啟再進去。這就叫”互斥鎖”(Mutual exclusion,縮寫 Mutex),防止多個執行緒同時讀寫某一塊記憶體區域。
還有些房間,可以同時容納n個人,比如廚房。也就是說,如果人數大於n,多出來的人只能在外面等著。這好比某些記憶體區域,只能供給固定數目的執行緒使用。
這時的解決方法,就是在門口掛n把鑰匙。進去的人就取一把鑰匙,出來時再把鑰匙掛回原處。後到的人發現鑰匙架空了,就知道必須在門口排隊等著了。這種做法叫做”訊號量”(Semaphore),用來保證多個執行緒不會互相沖突。
不難看出,mutex是semaphore的一種特殊情況(n=1時)。也就是說,完全可以用後者替代前者。但是,因為mutex較為簡單,且效率高,所以在必須保證資源獨佔的情況下,還是採用這種設計。
現在我們知道了Semaphore是幹啥的了,再把目光放到.NET中的Sempaphore上。Semaphore 繼承自WaitHandle(Mutex也繼承自WaitHandle),它用於鎖機制,與Mutex不同的是,它允許指定數量的執行緒同時訪問資源,線上程超過數量以後,則進行排隊等待,直到之前的執行緒退出。Semaphore很適合應用於Web伺服器這樣的高併發場景,可以限制對資源訪問的執行緒數。此外,Sempaphore不需要一個鎖的持有者,通常也將Sempaphore宣告為靜態的。
下面的示例程式碼演示了4條執行緒想要同時執行ThreadEntry()方法,但同時只允許2條執行緒進入:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 |
class Program { // 第一個引數指定當前有多少個“空位”(允許多少條執行緒進入) // 第二個引數指定一共有多少個“座位”(最多允許多少個執行緒同時進入) static Semaphore sem = new Semaphore(2, 2); const int threadSize = 4; static void Main(string[] args) { for (int i = 0; i < threadSize; i++) { Thread thread = new Thread(ThreadEntry); thread.Start(i + 1); } Console.ReadKey(); } static void ThreadEntry(object id) { Console.WriteLine("執行緒{0}申請進入本方法", id); // WaitOne:如果還有“空位”,則佔位,如果沒有空位,則等待; sem.WaitOne(); Console.WriteLine("執行緒{0}成功進入本方法", id); // 模擬執行緒執行了一些操作 Thread.Sleep(100); Console.WriteLine("執行緒{0}執行完畢離開了", id); // Release:釋放一個“空位” sem.Release(); } } |
上面示例的執行結果如下圖所示:
如果將資源比作“座位”,Semaphore接收的兩個引數中:第一個引數指定當前有多少個“空位”(允許多少條執行緒進入),第二個引數則指定一共有多少個“座位”(最多允許多少個執行緒同時進入)。WaitOne()方法則表示如果還有“空位”,則佔位,如果沒有空位,則等待;Release()方法則表示釋放一個“空位”。
感嘆一下:人生中有很多人在你的城堡中進進出出,城中的人想出去,城外的人想衝進來。But,一個人身邊的位置只有那麼多,你能給的也只有那麼多,在這個狹小的圈子裡,有些人要進來,就有一些人不得不離開。
參考資料
(1)朱毅,《進入IT企業必讀的200個.NET面試題》
(2)張子陽,《.NET之美:.NET關鍵技術深入解析》
(3)王濤,《你必須知道的.NET》
(4)阮一峰,《程式與執行緒的一個簡單解釋》