使用線程
Greg Ewing
Clarity Consulting Inc.
2002 年 3 月
摘要:本文論述了各種模式的線程(單線程、單元線程和自由線程)以及每種模式的使用方法。同時,還提供了一個使用線程的 C# 語言程式碼範例,以協助您編寫使用線程的應用程式。本文還討論了多線程代碼中的一些重要問題。
下載(英文)樣本檔案。(請注意,在樣本檔案中,程式員的注釋使用的是英文,本文中將其譯為中文是為了便於讀者進行理解。)
目錄
簡介
線程背景
應用程式範例
多線程代碼問題
總結
簡介
編寫多線程 Microsoft 訊息佇列 (MSMQ)
觸發器應用程式向來是一件讓人畏懼的事情。不過,.NET 架構線程和訊息類的出現使這項工作變得比以前容易了。這些類允許您使用任何適用於 .NET
架構的語言來編寫多線程應用程式。以前,像 Microsoft Visual Basic 之類的工具對線程的支援十分有限。因此不得不使用
C++ 來編寫多線程代碼,通過 Visual Basic 構建由多個過程或 ActiveX DLL
組成的解決方案(這種解決方案一點也不理想),或者乾脆完全放棄多線程。使用 .NET
架構,您可以構建各種多線程應用程式,而不用考慮選擇使用哪種語言。
本文將逐步介紹構建偵聽並處理來自 Microsoft 訊息佇列的多線程應用程式的過程。本文將著重討論兩個名稱空間 System.Threading 和 System.Messaging。範例程式碼是用 C# 語言編寫的,但您可以輕鬆地將其轉換為您所使用的語言。
線程背景
在 Win32 環境中,線程有三種基本模式:單線程、單元線程和自由線程。
單線程
您最初編寫的某些應用程式很可能是單線程應用程式,僅包含與應用程式進程對應的線程。進程可以被定義為應用程式的執行個體,擁有該應用程式的記憶體空間。大多數 Windows 應用程式都是單線程的,即用一個線程完成所有工作。
單元線程
單元線程是一種稍微複雜的線程模式。標記用於單元線程的代碼可以在其自己的線程中執行,並限制在自己的單元
中。線程可以被定義為進程所擁有的實體。處理時將調度該進程。在單元線程模式中,所有線程都在主應用程式記憶體中各自的子段範圍內運行。此模式允許多個代碼
執行個體同時但獨立地運行。例如,在 .NET 之前,Visual Basic 僅限於建立Apartment 執行緒元件和應用程式。
自由線程
自由線程是最複雜的線程模式。在自由線程模式中,多個線程可以同時調用相同的方法和組件。與單元線程不同,自
由線程不會被限制在獨立的記憶體空間。當應用程式必須進行大量相似而又獨立的數學計算時,您可能需要使用自由線程。在這種情況下,您需要產生多個線程使用相
同的程式碼範例來執行計算。可能 C++ 開發人員是僅有的編寫過自由線程應用程式的應用程式開發人員,因為像 Visual Basic 6.0
這樣的語言幾乎不可能編寫自由線程應用程式。
使用線程模式
為了使您對線程模式有一定的概念,我們可以將其想象為從一所屋子搬到另一所屋子。如果您採用單線程方法,則需
要您自己完成從打包到扛箱子再到拆包的所有工作。如果您使用單元線程模式,則表示您邀請了好朋友來幫忙。每個朋友在一個單獨的房間裡工作,並且不能協助在
其他房間工作的人。他們各自負責自己的空間和空間內的物品搬運。如果您採用自由線程方法,您仍然邀請相同的朋友來幫忙,但是所有朋友可以隨時在任何一個房
間工作,共同打包物品。與此類似,您的房子就是運行所有線程的進程,每個朋友都是一個代碼執行個體,搬運的物品為應用程式的資源和變數。
本樣本解釋了不同線程模式的優點和缺點。單元線程比單線程要快,因為有多個組件執行個體在工作。在某些情況下,自
由線程比單元線程更快更有效,這是因為所有事情同時發生,並且可以共用所有資源。但是,當多線程更改共用資源時,這可能會出現問題。假設一個人開始使用箱
子打包廚房用具,此時另一個朋友進來了,要使用同一個箱子打包浴室的東西。第一個朋友在箱子上貼上了“廚房用具”,另一個朋友用“洗漱用品”標籤覆蓋了原
標籤。結果,當您拆包時,就會發生將廚房用品搬到浴室的情況。
應用程式範例
第一步是要檢查應用程式範例的設計。應用程式將產生多個線程,每個線程都偵聽來自 MSMQ 隊列的訊息。本樣本使用兩個類,主 Form 類和自訂 MQListen 類。Form 類將處理使用者介面並建立,管理和破壞輔助線程。MQListen 類包含所有代碼,包括輔助線程運行所需的訊息佇列因素。
準備應用程式
- 要啟動應用程式,請開啟 Visual Studio .NET 並建立一個名為
MultiThreadedMQListener 的新 C# Windows 應用程式。開啟表單的屬性,將其命名為
QueueListenerForm。畫出初始表單後,將兩個標籤、兩個按鈕、一個狀態列和兩個文字框拖放到表單上。將第一個文字框命名為 Server,第二個文字框命名為 Queue。將第一個按鈕命名為 StartListening,第二個按鈕命名為 StopListening。可以保留狀態列的預設名稱 statusBar1。
- 下一步,單擊 Project(項目)菜單並單擊 Add Reference(添加引用),以向 System.Messaging 名稱空間添加一個引用。在 .NET 組件列表中找到並選擇 System.Messaging.Dll。名稱空間包含與 MSMQ 隊列通訊所使用的類。
- 下一步,單擊 File(檔案)菜單,然後單擊 Add New Item(添加新項),以在項目中添加一個新類。選擇 Class(類)模板並將其命名為 MQListen。在類的頂部添加下列 using 語句:
// C#
using System.Threading;
using System.Messaging;
System.Threading 名稱空間允許您訪問所有必要的線程功能,在本例中,您可以訪問 Thread 類和 ThreadInterruptException 建構函式。該名稱空間還包括許多其他進階功能,本文不作詳細討論。System.Messaging 名稱空間允許您訪問 MSMQ 功能,包括向隊列發送訊息和接收隊列訊息。在本例中,您將使用 MessageQueue 類來接收訊息。還必須在主表單代碼中添加 using System.Threading。
所有引用就位後,您就可以開始編寫代碼了。
輔助線程
首先需要構建封裝所有線程工作的 MQListen 類。將下列代碼插入 MQListen 中。
// C#
public class MQListen
{
private string m_MachineName;
private string m_QueueName;
// 建構函式接收必要的隊列資訊。
public MQListen(string MachineName, string QueueName)
{
m_MachineName = MachineName;
m_QueueName = QueueName;
}
// 每個線程用來偵聽 MQ 訊息的一種唯一方法
public void Listen()
{
// 建立一個 MessageQueue 對象。
System.Messaging.MessageQueue MQ = new
System.Messaging.MessageQueue();
// 設定 MessageQueue 對象的路徑屬性。
MQ.Path = m_MachineName + "\\private$\\" + m_QueueName;
// 建立一個 Message 對象。
System.Messaging.Message Message = new
System.Messaging.Message();
// 重複上述步驟,直到收到中斷。
while (true)
{
try
{
// 休眠以在中斷髮出時捕捉中斷。
System.Threading.Thread.Sleep(100);
// 將 Message 對象設定為與接收函數的結果相等。
// 期間(天、小時、分鐘、秒)。
Message = MQ.Receive(new TimeSpan(0, 0, 0, 1));
// 顯示已接收訊息的標籤
System.Windows.Forms.MessageBox.Show(" Label: " + Message.Label);
}
catch (ThreadInterruptedException e)
{
// 從主線程捕捉 ThreadInterrupt 並退出。
Console.WriteLine("Exiting Thread");
Message.Dispose();
MQ.Dispose();
break;
}
catch (Exception GenericException)
{
// 捕捉接收過程中拋出的所有異常。
Console.WriteLine(GenericException.Message);
}
}
}
}
代碼討論
MQListen 類包含一個不同於建構函式的函數。該函數封裝每個輔助線程要執行的所有工作。在主線程中,您向線程建構函式傳遞一個對此函數的引用,以便在啟動線程時執行該函數。
Listen 所做的第一件事情是設定一個訊息佇列對象。MessageQueue
建構函式通過三種實現進行重載。第一種實現使用兩個參數:一個字串參數,指定偵聽隊列的位置;一個布爾值參數,指示是否為訪問隊列的第一個應用程式賦予
獨佔讀取隊列的許可權。第二種實現只使用隊列路徑參數,第三種實現不使用參數。為了簡便起見,您可以使用第三種實現,在下一行分配路徑。
如果您引用了隊列,則必須建立一個訊息對象。訊息建構函式也有三種實現方式。如果您想將訊息寫入隊列,則可以使用前兩種實現。這兩種實現採用兩個對象:一個是位於訊息本文中的對象;一個是定義如何將對象序列化到訊息本文的 IMessageFormatter 對象。在本例中,您將從隊列中讀取資料,以初始化空的訊息對象。
初始化對象後,您需要輸入執行所有工作的主迴圈。然後,當主線程調用 Interrupt 終止這些線程時,則只有線上程處於等待、睡眠或串連狀態下才會被中斷。如果沒有處於上述三種狀態,則要等到下次進入這三種狀態中的一種時才會被中斷。要確保輔助線程進入等待、睡眠或串連狀態,請調用位於 System.Threading 名稱空間的 Sleep 方法。對於使用過 Windows API 睡眠函數的 C++ 和 Visual Basic 開發人員而言,Sleep 方法並不陌生。它只使用一個參數:線程處於睡眠狀態的毫秒數。如果您從未調用過 Sleep,輔助線程將永遠不會進入可以接收插斷要求的狀態,而會無限制地繼續下去,除非您手動關閉進程。
MQ Receive 方法有兩種實現。第一種實現不使用參數,將一直等待接收訊息。第二種實現(本例使用這種實現)使用 TimeSpan 對象指定一個逾時值。TimeSpan 建構函式包含四個參數:日、小時、分鐘和秒。在本例中,Receive 方法在逾時和返回前將等待一秒種。
收到的訊息將被分配給先前建立的訊息對象,然後,便可以對其進行處理了。本例開啟一個帶有標籤的訊息框,並刪除了此訊息。如果您想在實際使用中採用此代碼,則可以在此處放置任何訊息處理代碼。
當輔助線程收到 Interrupt 請求後,將發出一個 ThreadInterruptedException 異常。要捕捉此異常,請在 try-catch 塊中包含 Sleep 和 Receive 函數。您應當指定兩個捕獲:第一個用於捕獲中斷異常,第二個用於處理捕獲到的錯誤異常。捕獲到中斷異常時,請首先將其寫入線程正在退出的調試視窗。下一步,對隊列對象和訊息對象調用 Dispose 方法,以保證所有記憶體都被清空並發送到記憶體回收器。最後,中斷 while 迴圈。
函數退出 while 迴圈後,關聯的線程也將立即結束,代碼為
0。在調試視窗,您將看到一則訊息,例如“The thread '<name>' (0x660) has exited with
code 0 (0x0)”(線程 '<name>' (0x660) 已經退出,代碼為 0
(0x0))。現在,線程已經退出該環境,並已自動被破壞。主線程和輔助線程都不需要執行專門的清除操作。
主表單
下一步是向表單添加代碼以產生輔助線程並針對各輔助線程啟動 MQListen 類。首先,請向表單添加下列函數:
// C#
private void StartThreads()
{
int LoopCounter; // 執行緒計數
StopListeningFlag = false; // 跟蹤輔助線程是否應當
// 終止的標誌。
// 將一個包含 5 個線程的數組聲明為輔助線程。
Thread[] ThreadArray = new Thread[5];
// 聲明包含輔助線程的所有代碼的類。
MQListen objMQListen = new
MQListen(this.ServerName.Text,this.QueueName.Text);
for (LoopCounter = 0; LoopCounter < NUMBER_THREADS; LoopCounter++)
{
// 建立一個 Thread 對象。
ThreadArray[LoopCounter] = new Thread(new
ThreadStart(objMQListen.Listen));
// 啟動線程將調用 ThreadStart 委託。
ThreadArray[LoopCounter].Start();
}
statusBar1.Text = LoopCounter.ToString() + " listener threads started";
while (!StopListeningFlag)
{
// 等待使用者按下停止按鈕。
// 在等待過程中,讓系統處理其他事件。
System.Windows.Forms.Application.DoEvents();
}
statusBar1.Text = "Stop request received, stopping threads";
// 向每個線程發送一個插斷要求。
for (LoopCounter = 0;LoopCounter < NUMBER_THREADS; LoopCounter++)
{
ThreadArray[LoopCounter].Interrupt();
}
statusBar1.Text = "All Threads have been stopped";
}
代碼討論
要啟動此函數,請建立一個包含 5 個項目的線程數組。此數組將保持對所有線程的引用,以備將來使用。
MQListen 類的建構函式使用兩個參數:包含訊息佇列的電腦名稱以及要偵聽的隊列的名稱。建構函式使用文字框中的值來為這兩個參數賦值。
要建立線程,您需要進入迴圈以初始化每個線程對象。Thread 建構函式要求您向其傳遞一個委託,該委託在調用線程的 Start 方法時指向要調用的函數。您希望線程開始使用 MQListen.Listen 函數,但該線程並不是一個委託。為了滿足線程建構函式的要求,您必須傳遞一個 ThreadStart 對象,該對象將建立一個給定函數名稱的委託。此時,請向 ThreadStart 對象傳遞一個對 MQListen.Listen 函數的引用。由於該數組元素已被初始化,請立即調用 Start 來開始線程。
所有線程開始後,請用相應的訊息來更新表單中的狀態列。隨著線程的運行和偵聽隊列,主線程將等待使用者請求應用程式停止偵聽。為此,主線程將進入一個 while 迴圈,直至您單擊 StopListening 按鈕更改 StopListeningFlag 的值。在此等待迴圈中,將允許應用程式使用 Forms.Application.DoEvents 方法處理其他需要處理的工作。對於熟悉 Visual Basic 的讀者來說,這一點與舊的 DoEvents 方法相同。對於熟悉 C++ 的讀者來說,這等於編寫一個 MSG 泵。
當使用者單擊 StopListening 按鈕時,該迴圈將退出並進入線程關閉代碼。要關閉所有線程,代碼必須檢查線程數組,並向每個線程發送一個中斷訊號。在此迴圈內部,請對數組中的每個線程調用 Interrupt 方法。調用此方法之前,MQListen 類中的代碼將繼續正常執行。因此,您可以對每個輔助線程調用 Interrupt,而不必考慮線程是否正在處理其他事件。完成後,線程類將處理所有線程的清除。最後,請在退出前更新主表單中的狀態列。
現在,您需要在按鈕後添加代碼。請向 StartListening 按鈕的 Click 事件添加以下代碼:
// C#
statusBar1.Text = "Starting Threads";
StartThreads();
這將更新狀態列並調用 StartThreads 方法。對於 StopListening 按鈕,您只需使用以下代碼將 StopListeningFlag 設定為 True:
// C#
StopListeningFlag = true;
最後一步是為 StopListeningFlag 添加表單級的變數。請在表單代碼的頂部添加以下行:
// C#
private bool StopListeningFlag = false;
要測試應用程式,您可以下載 MQWrite,這是一個寫入訊息佇列的應用程式範例。
多線程代碼問題
您已經完成了範例程式碼,因此您已經具備編寫自己的多線程應用程式所需的工具。線程可以顯著提高某些應用程式的
效能和延展性。在功能增強同時,您還必須瞭解線程有危險的一面。使用線程可能會破壞您的應用程式,這樣的情況確實存在。線程可能會阻止運行,造成無法
預料的後果,甚至會導致應用程式停止運行。
如果您有多個線程,請確保它們之間不存在互相等待以到達某一點或完成的情況。如果操作錯誤,可能會導致死結狀態,兩個線程都無法完成,因為它們都在相互等待。
如果多線程要求訪問不能輕易共用的資源(如軟碟機、序列埠或紅外線連接埠),您可能需要避免使用線程或需
要使用一種更進階的線程工具(如 synclocks 或
mutexes)來管理並發性。如果兩個線程試圖同時訪問這些資源,其中一個線程將無法獲得資源,或者會導致資料損毀。
使用線程的另一個常見問題是競爭狀態。如果一個線程正在將資料寫入檔案,而另一個線程正在從該檔案中讀取資料,您將無法知道哪個線程先完成。這種情況稱為競爭狀態,因為兩個線程都在競相到達檔案末尾。如果讀取線程快於寫入線程,則將返回無法預料的結果。
使用線程時,還應當考慮所有線程是否都能夠完全獨立地進行工作。如果確實需要來回傳遞資料,在資料相對簡單的情況下,只要小心操作即可。傳遞複雜物件時,來回移動這些對象的封送代價將十分可觀。這將導致作業系統管理的額外開銷並且會降低總體效能。
另一個問題是將代碼轉交給其他開發人員的傳遞成本。雖然 .NET 確實使線程變得容易,但請注意,維護您代碼的下一位開發人員必須瞭解要使用的線程。儘管這不是避免使用線程的理由,但是它充分說明了應該提供足夠的代碼注釋。
這些問題本身並不能打消您使用線程的熱情,但您在設計應用程式和決定是否使用線程時應該考慮到這些問題。遺憾
的是,本文無法詳細論述某些避免這些問題的方法。如果您已決定使用線程但遇到了上述某些問題,請檢查 synclocks 或 mutexes
看是否能解決問題或引導您使用其他解決方案。
總結
有了上述資訊,您就可以編寫使用線程的應用程式。不過,在編寫過程中,請記住上面提到的問題。如果使用得當,那麼,與單線程相比,多線程應用程式將具有更好的效能和延展性。但是,如果使用不當,使用線程會適得其反,並且會導致應用程式不穩定。