通常認為在編寫程序中用到多線程是一個高級的編程任務,容易發生錯誤。在本月的欄目中,我將在一個Windows窗體應用程序中使用多線程,它具有實際的意義,同時盡量使事情簡單。我的目標是在一個普通的需求描述中用最好的辦法講解多線程;客戶仍然比較喜歡使用戶交互方式的應用程序。
多線程通常和服務器端軟件,可擴展性及性能技術聯系在一起。 然而,在微軟.NET框架中,許多服務器端應用程序都駐留在ASP.NET體系結構中。同樣,這些應用程序在邏輯上是單線程的, 因為IIS和ASP.NET在ASP.NET Web Form或Web服務程序中執行了許多或所有的多線程。 在ASP.NET應用程序中你一般可以忽略線程性。 這就是為什麼在.NET框架中,多線程更傾向於在客戶端使用的一個原因,比如在保證同用戶交互的同時而執行一個很長的操作。
線程背景
線程執行代碼。它們由操作系統實現,是CPU本身的一種抽象。許多系統都只有一個CPU, 線程是把CPU快速的處理能力分開而執行多個操作的一種方法,使它們看起來好像同步似的。即使一個系統由多個CPU, 但運行的線程一般要比處理器多。
在一個Windows為基礎的應用程序中,每一個進程至少要有一個線程,它能夠執行機器語言指令。 一旦一個進程的所有線程都中止了,進程本身和它所占用的資源將會被Windows清除。
許多應用程序都被設計為單線程程序,這意味著該程序實現的進程從來不會有超過一個線程在執行,即使在系統中有多個同樣的處理在進行。一般一個進程不會關心系統中其他進程的線程的執行。
然而,在單個進程裡的所有線程不僅共享虛擬地址空間,而且許多進程級的資源也被共享, 比如文件和窗口句柄等。由於進程資源共享的特征,一個線程必須考慮同一進程中其它線程正在做什麼。線程同步是在多線程的進程中保持各線程互不沖突的一門藝術。這也使得多線程比較困難。
最好的方式是只有在需要時才使用多線程,盡量保持事情簡單。而且要避免線程同步的情況。在本欄目中,我將向你展示如何為一個普通的客戶應用程序做這些事情。
為什麼使用多個線程?
已經有許多單線程的客戶端應用程序,而且每天還有許多正在被寫。在許多情況下,單線程的行為已經足夠了。
然而,在某些特定的應用程序中加入一些異步行為可以提高你的經驗。典型的數據庫前端程序是一個很好的例子。
數據庫查詢需要花費大量時間完成。在一個單線程的應用程序裡,這些查詢會導致window消息處理能力阻塞,導致程序的用戶交互被凍結。解決辦法就是,這個我將要詳細描述,用一個線程處理來自操作系統的消息,而另外一個線程做一個很長的工作。在你的代碼中使用第二個線程的重要原因就是即使在幕後有一個繁忙的工作在進行,也要保證你的程序的用戶交互有響應。
我們首先看一下執行一長串操作的單線程的GUI程序。然後我們將用額外的線程整理該程序。
Figure 1 是用C#寫的一個程序的完整源代碼。它創建了一個帶有文本框和按鈕的窗體。如果你在文本框中鍵入了一個數字,然後按下按鈕,這個程序將處理你輸入的那個數字,它表示秒數,每秒鐘響鈴一次代表後台的處理。除了Figure 1 的代碼外,你可以從本文開頭的鏈接中下載完整的代碼。下載或鍵入Figure 1 所示的代碼,在讀之前編譯運行它,(編譯前,在Visual Studio.NET中右擊你的工程,加入Microsoft Visual Basic運行時引用)當你試著運行Figure 1 中的
SingleThreadedForm.cs應用程序時,你馬上就會看到幾個問題。
Figure 1 SingleThreadedForm.cs
using System;
using System.Drawing;
using System.Threading;
using System.Windows.Forms;
using Microsoft.VisualBasic;
class App {
// Application entry point
public static void Main() {
// Run a Windows Forms message loop
Application.Run(new SingleThreadedForm());
}
}
// A Form-derived type
class SingleThreadedForm : Form {
// Constructor method
public SingleThreadedForm() {
// Create a text box
text.Location = new Point(10, 10);
text.Size = new Size(50, 20);
Controls.Add(text);
// Create a button
button.Text = "Beep";
button.Size = new Size(50, 20);
button.Location = new Point(80, 10);
// Register Click event handler
button.Click += new EventHandler(OnClick);
Controls.Add(button);
}
// Method called by the button's Click event
void OnClick(Object sender, EventArgs args) {
// Get an int from a string
Int32 count = 0;
try { count = Int32.Parse(text.Text); } catch (FormatException) {}
// Count to that number
Count(count);
}
// Method beeps once per second
void Count(Int32 seconds) {
for (Int32 index = 0; index < seconds; index++) {
Interaction.Beep();
Thread.Sleep(1000);
}
}
// Some private fields by which to reference controls
Button button = new Button();
TextBox text = new TextBox();
}
在你第一次測試運行時,在文本框中輸入20,按下按鈕。你將看到程序的用戶交互變得完全沒有響應了。你不能單擊按鈕或者編輯文本框,程序也不能被從容的關閉,如果你覆蓋該窗體接著會顯示一個窗口的部分區域,它將不再重繪自己(見 Figure 2),這個程序被鎖定足足20秒, 然而它還可以繼續響鈴,證明它還沒有真正的死掉。這個簡單的程序解釋了單線程GUI程序的問題。
我將用多線程解決第一個問題:未響應的用戶交互,但首先我將解釋是什麼導致了這種現象。
線程和Windows用戶界面
Windows Forms類庫建立在大家所熟知的User32 Win32 API 基礎上。User32實現了GUI的基本元素,例如窗體,菜單及按鈕之類等。所有由User32實現的窗體和控件都使用了事件驅動型結構。
這裡簡單的講講它們如何工作。發生在窗體上的事情,例如鼠標單擊,坐標變化,大小變化和重繪請求,都稱作事件。在User32 API模型中的事件是由窗體消息表示的。每一個窗體有一個函數,叫做窗口過程或WndProc,它由應用程序實現。WndProc為窗體負責處理窗體消息。
但是WndProc不是神奇的被系統調用。相反,應用程序必須調用GetMessage主動地從系統中得到窗體消息。該消息被應用程序調用DispatchMethod API方法分配到它們的目標窗體的WndProc方法中。應用程序只是簡單的循環接收和分配窗口消息,一般叫做消息泵或消息循環。線程擁有所有窗體,這樣它就可以提取消息,WndProc函數也被同樣的線程所調用。
現在回到Windows Forms類來。Windows Forms在應用程序中對User32的消息結構進行了大約95%的抽象。代替了WndProc函數,Windows Forms程序定義了事件處理器和虛擬函數重載來處理與窗體(窗口)或控件有關的不同系統事件。然而消息提取必須要運行,它在Windows Forms API的Application.Run方法裡面實現。
Figure 1 所示的代碼似乎僅僅調用了Application.Run接著就退出了。 然而這缺少了透明性:應用程序的主線程在其生命周期裡只對Application.Run進行一次調用進行消息提取,其結果卻為用應用程序其它部分創造了不同事件處理器的調用。當窗體上的按鈕被單擊時,在Figure 1 中的OnClick方法被主線程調用,該線程同樣要負責在Application.Run中提取消息。
這解釋了為什麼在一個長操作發生時,用戶交互沒有響應。如果在一個事件處理器中一個很長的操作 (如數據庫查詢)發生了,那麼主線程就被占用,它又需要不斷提取消息。沒有能力提取消息並發送到窗口或窗體上, 就沒有能力響應調整大小,重繪自己,處理單擊或響應用戶的任何交互。
在接下來的部分為了執行長操作我將使用公共語言運行時的線程池來修改Figure 1 所示的例子代碼,這樣主線程仍然可以提取消息。
托管線程池
CLR為每一個托管進程維護了一個線程池,這意味著當你的應用程序主線程需要進行某些異步處理時,你可以很容易的從線程池中借助某個線程實現特定的處理。一旦處理工作完成,線程被歸還到線程池以便以後使用。讓我們看一個例子,修改使用線程池。
注意Figure 3 中FlawMultiThreadForm.cs中紅色部分表示的行;它們是由Figure 1 中的單線程變為多線程程序 時唯一要修改的代碼。如果你編譯Figure 3 所示的代碼,並設置運行20秒,你將看到當處理20個響鈴的請求時,仍然能夠響應用戶的交互。在客戶端程序中使用多線程來響應用戶交互是一個吸引人的原因。
Figure 3 FlawedMultiThreadedForm.cs
using System;
using System.Drawing;
using System.Threading;
using System.Windows.Forms;
using Microsoft.VisualBasic;
class App {
// Application entry point
public static void Main() {
// Run a Windows Forms message loop
Application.Run(new FlawedMultiThreadedForm());
}
}
// A Form-derived type
class FlawedMultiThreadedForm : Form {
// Constructor method
public FlawedMultiThreadedForm() {
// Create a text box
text.Location = new Point(10, 10);
text.Size = new Size(50, 20);
Controls.Add(text);
// Create a button
button.Text = "Beep";
button.Size = new Size(50, 20);
button.Location = new Point(80, 10);
// Register Click event handler
button.Click += new EventHandler(OnClick);
Controls.Add(button);
}
// Method called by the button's Click event
void OnClick(Object sender, EventArgs args) {
// Get an int from a string
Int32 count = 0;
try { count = Int32.Parse(text.Text); } catch (FormatException) {}
// Count to that number
WaitCallback async = new WaitCallback(Count);
ThreadPool.QueueUserWorkItem(async, count);
}
// Async method beeps once per second
void Count(Object param) {
Int32 seconds = (Int32) param;
for (Int32 index = 0; index < seconds; index++) {
Interaction.Beep();
Thread.Sleep(1000);
}
}
// Some private fields by which to reference controls
Button button = new Button();
TextBox text = new TextBox();
}
然而,在Figure 3 中所做的變化,卻引入了一個新問題(如 Figure 3 的名字一樣);現在用戶可以啟動多個同時響鈴的長操作。在許多實時應用中這會導致線程間的沖突。為了修正這個線程同步請求,我將講述這些,但首先熟悉一下CLR''''s線程池。
類庫中的System.Threading.ThreadPool類提供了一個訪問CLR''''s線程池的API接口, ThreadPool類型不能被實例化,它由靜態成員組成。ThreadPool類型最重要的方法是對ThreadPool.QueueUserWorkItem的兩個重載。這兩種方法讓你定義一個你願意被線程池中的一個線程進行回調的函數。通過使用類庫中的WaitCallback委托類型的一個實例來定義你的方法。一種重載讓你對異步方法定義一個參數;這是Figure 3 所使用的版本。
下面的兩行代碼創建一個委托實例,代表了一個Count方法,接下來的調用排隊等候讓線程池中的方法進行回調。
WaitCallback async = new WaitCallback(Count);
ThreadPool.QueueUserWorkItem(async, count);
ThreadPool.QueueUserWorkItem 的兩個方法讓你在隊列中定義一個異步回調方法,然後立即返回。 同時線程池監視這個隊列,接著出列方法,並使用線程池中的一個或多個線程調用該方法。這是CLR''''s線程池的主要用法。
CLR''''s線程池也被系統的其它APIs所使用。例如, System.Threading.Timer對象在定時間隔到來時將會在線程池中排隊等候回調。 ThreadPool.RegisterWaitForSingleObject 方法當響應內核系統同步對象有信號時會在線程池中排隊等候調用。最後,回調由類庫中的不同異步方法執行,這些異步方法又由CLR''''s線程池來執行。
一般來說,一個應用程序僅僅對於簡單的異步操作需要使用多線程時毫無疑問應該使用線程池。相比較手工創建一個線程對象,這種方法是被推薦的。調用ThreadPool.QueueUserWorkItem執行簡單,而且相對於重復的手動創建線程來說能夠更好的利用系統資源。
最簡單的線程同步
在本欄目開始我就稱保持線程同步而不互相沖突是一門藝術。Figure 3 所示的FlawedMultiThreadForm.cs應用程序有一個問題:用戶可以通過單擊按鈕引發一個很長的響鈴操作,他們可以繼續單擊按鈕而引發更多的響鈴操作。如果不是響鈴,該長操作是數據庫查詢或者在進程的內存中進行數據結構操作,你一定不想在同一時間內,有一個以上的線程做同樣的工作。最好的情況下這是系統資源的一種浪費,最壞的情況下會導致數據毀滅。
最容易的解決辦法就是禁止按鈕一類的用戶交互元素;兩個進程間的通信稍微有點難度。過一會我將給你看如何做這些事情。但首先,讓我指出所有線程同步使用的一些線程間通信的形式-從一個線程到另一個線程通信的一種手段。稍後我將討論大家所熟知的AutoResetEvent對象類型,它僅用在線程間通信。
現在讓我們首先看一下為Figure 3 中FlawedMultiThreadedForm.cs程序中加入的線程同步代碼。再一次的,Figure 4 CorrectMultiThreadedForm.cs程序中紅色部分表示的是其先前程序的較小的改動部分。 如果你運行這個程序你將看到當一個長響鈴操作在進行時用戶交互被禁止了(但沒有掛起),響鈴完成的時候又被允許了。這次這些代碼的變化已經足夠了,我將逐個運行他們。
Figure 4 CorrectMultiThreadedForm.cs
using System;
using System.Drawing;
using System.Threading;
using System.Windows.Forms;
using Microsoft.VisualBasic;
class App {
// Application entry point
public static void Main() {
// Run a Windows Forms message loop
Application.Run(new CorrectMultiThreadedForm());
}
}
// A Form-derived type
class CorrectMultiThreadedForm : Form{
// Constructor method
public CorrectMultiThreadedForm() {
// Create a textbox
text.Location = new Point(10, 10);
text.Size = new Size(50, 20);
Controls.Add(text);
// Create a button
button.Text = "Beep";
button.Size = new Size(50, 20);
button.Location = new Point(80, 10);
// Register Click event handler
button.Click += new EventHandler(OnClick);
Controls.Add(button);
// Cache a delegate for repeated reuse
enableControls = new BooleanCallback(EnableControls);
}
// Method called by the button's Click event
void OnClick(Object sender, EventArgs args) {
// Get an int from a string
Int32 count = 0;
try { count = Int32.Parse(text.Text); } catch (FormatException) {}
// Count to that number
EnableControls(false);
WaitCallback async = new WaitCallback(Count);
ThreadPool.QueueUserWorkItem(async, count);
}
// Async method beeps once per second
void Count(Object param) {
Int32 seconds = (Int32) param;
for (Int32 index = 0; index < seconds; index++) {
Interaction.Beep();
Thread.Sleep(1000);
}
Invoke(enableControls, new Object[]{true});
}
void EnableControls(Boolean enable) {
button.Enabled = enable;
text.Enabled = enable;
}
// A delegate type and matching field
delegate void BooleanCallback(Boolean enable);
BooleanCallback enableControls;
// Some private fields by which to reference controls
Button button = new Button();
TextBox text = new TextBox();
}
在Figure 4 的末尾處有一個EnableControls的新方法,它允許或禁止窗體上的文本框和按鈕控件。在Figure 4 的開始我加入了一個EnableControls調用,在後台響鈴操作排隊等候之前立即禁止文本框和按鈕。到這裡線程的同步工作已經完成了一半,因為禁止了用戶交互,所以用戶不能引發更多的後台沖突操作。在Figure 4 的末尾你將看到一個名為BooleanCallback的委托類型被定義,其簽名是同EnableControls方法兼容的。在那個定義之前,一個名為EnableControls的委托域被定義(見例子),它引用了該窗體的EnableControls方法。這個委托域在代碼的開始處被分配。
你也將看到一個來自主線程的回調,該主線程為窗體和其控件擁有和提取消息。這個調用通過向EnableControls傳遞一個true參數來使能控件。這通過後台線程調用窗體的Invoke方法來完成,當其一旦完成其長響鈴操時。代碼傳送的委托引用EnableControls去Invoke,該方法的參數帶有一個對象數組。Invoke方法是線程間通信的一個非常靈活的方式,特別是對於Windows Forms類庫中的窗口或窗體。在這個例子中,Invoke被用來告訴主GUI線程通過調用EnableControls方法重新使能窗體上的控件。
Figure 4 中的CorrectMultiThreadedForm.cs的變化實現了我早先的建議――當響鈴操作在執行時你不想運行,就禁止引發響鈴操作的用戶交互部分。當操作完成時,告訴主線程重新使能被禁止的部分。對Invoke的調用是唯一的,這一點應該注意。
Invoke方法在 System.Windows.Forms.Controls類型中定義,包含Form類型讓類庫中的所有派生控件都可使用該方法。Invoke方法的目的是配置了一個從任何線程對為窗體或控件實現消息提取線程的調用。
當訪問控件派生類時,包括Form類,從提取控件消息的線程來看你必須這樣做。這在單線程的應用程序中是很自然的事情。但是當你從線程池中使用多線程時,要避免從後台線程中調用用戶交互對象的方法和屬性是很重要的。相反,你必須使用控件的Invoke方法間接的訪問它們。Invoke是控件中很少見的一個可以安全的從任何線程中調用的方法,因為它是用Win32的PostMessage API實現的。
使用Control.Invoke方法進行線程間的通信有點復雜。但是一旦你熟悉了這個過程,你就有了在你的客戶端程序中實現多線程目標的工具。本欄目的剩余部分將覆蓋其它一些細節,但是Figure 4 中的CorrectMultiThreadedForm.cs應用程序是一個完整的解決辦法:當執行任意長的操作時仍然能夠響應用戶的其它操作。盡管大多數的用戶交互被禁止,但用戶仍然可以重新配置和調整窗口,也可以關閉程序。然而,用戶不能任意使用程序的異步行為。這個小細節能夠讓你對你的程序保持自信心。
在我的第一個線程同步程序中,沒有使用任何傳統的線程結構,例如互斥或信號量,似乎一錢不值。然而,我卻使用了禁止控件的最普通的方法。
細節-實現一個取消按鈕
有時你想為你的用戶提供一種取消長操作的方法。你所需要的就是你的主線程同後台線程之間的一些通信方法,通知後台線程操作不再被需要,可以停止。System.Threading名字空間為這個方法提供了一個類:AutoResetEvent。
AutoResetEvent是線程間通信的一種簡單機制。一個AutoResetEvent對象可以有兩種狀態中的一個:有信號的和無信號的。當你創建一個AutoResetEvent實例時,你可以通過構造函數的參數來決定其初始狀態。然後感知該對象的線程通過檢查AutoResetEvent對象的狀態,或者用AutoResetEvent對象的Set或Reset方法調整其狀態,進行相互通信。
在某種程度上AutoResetEvent很像一個布爾類型,但是它提供的特征使其更適合於在線程間進行通信。這樣的一個例子就是它有這種能力:一個線程可以有效的等待直到一個AutoResetEvent對象從一個無信號的狀態變為有信號的狀態。它是通過在該對象上調用WaitOne實現的。任何一個線程對一個無信號的AutoResetEvent對象調用了WaitOne,就會被有效的阻塞直到其它線程使該對象有信號。使用布爾變量線程必須在一個循環中登記該變量,這是無效率的。一般來說沒有必要使用Reset來使一個AutoResetEvent變為無信號,因為當其它線程感知到該對象為有信號時,它會被立即自動的設為無信號的。
現在你需要一種讓你的後台線程無阻塞的測試AutoResetEvent對象的方法,你會有許多工具實現線程的取消。為了完成這些,調用帶有WaitOne的重載窗體並指出一個零毫秒的超出時間,以零毫秒為超出時間的WaitOne會立即返回,而不管AutoResetEvent對象的狀態是否為有信號。如果返回值為true,這個對象是有信號的;否則由於時間超出而返回。
我們整理一下實現取消的特點。如果你想實現一個取消按鈕,它能夠取消後台線程中的一個長操作,按照以下步驟:
在你的窗體上加入AutoResetEvent域類型
通過在AutoResetEvent的構造函數中傳入false參數,設置該對象初始狀態為無信號的。 接著在你的窗體上保 存該對象的引用域,這是為了能夠在窗體的整個生命周期內可以對後台線程的後台操作實現取消操作。
在你窗體上加入一個取消按鈕。
在取消按鈕的Click事件處理器中,通過調用AutoResetEvent對象的Set方法使其有信號。
同時,在你的後台線程的邏輯中周期性地在AutoResetEvent對象上調用WaitOne來檢查用戶是否取消了。
if(cancelEvent.WaitOne(0, false)){
// cancel operation
}
你必須記住使用零毫秒參數,這樣可以避免在後台線程操作中不必要的停頓。
如果用戶取消了操作,通過主線程AutoResetEvent會被設為有信號的。 當WaitOne返回true時你的後台線程會 得到警告,並停止操作。同時在後台線程中由於調用了WaitOne該事件會被自動的置為無信號狀態。
為了能夠看到取消長操作窗體的例子,你可以下載CancelableForm.cs文件。這個代碼是一個完整的程序,它與Figure 4 中的CorrectMultiThreadedForm.cs只有稍微的不同。
注意在CancelableForm.cs也采用了比較高級的用法Control.Invoke, 在那裡EnableControls方法被設計用來調用它自己如果當它被一個錯誤的線程所調用時。在它使用窗體上的任何GUI對象的方法或屬性時要先做這個檢查。 這樣能夠使得EnableControls能夠從任何線程中直接安全的調用,在方法的實現中有效的隱藏了Invoke調用的復雜性。這些可以使應用程序更加有維護性。注意在這個例子中同樣使用了Control.BeginInvoke, 它是Control.Invoke的異步版本。
你也許注意到取消的邏輯依賴於後台線程通過WaitOne調用周期性的取消檢查的能力。 但是如果正在討論的問題不能被取消怎麼辦?如果後台操作是一個單個調用,像DataAdapter.Fill,它會花很長時間?有時會有解決辦法的,但並不總是。
如果你的長操作根本不能取消,你可以使用一個偽取消的方法來完成你的操作,但在你的程序中不要影響你的操作結果。這不是技術上的取消操作,它把一個可忍受的操作幫定到一個線程池中,但這是在某種情況下的一種折中辦法。如果你實現了類似的解決辦法,你應該從你的取消按鈕事件處理器中直接使能你已禁止的UI元素,而不要還依賴於被綁定的後台線程通過Invoke調用使能你的控件。同樣重要的使設計你的後台操作線程,當其返回時測試一下它是否被取消,以便它不影響現在被取消的操作的結果。
這種長操作取消是比較高級的方法,它只在某些情況下才可行。例如,數據庫查詢的偽取消就是這樣,但是一個數據庫的更新,刪除,插入偽取消是一個滯後的操作。有永久的操作結果或與反饋有關的操作,像聲音和圖像,就不容易使用偽取消方法,因為操作的結果在用戶取消以後是非常明顯的。
更多細節-有關定時器
在應用程序中需要一個定時器來引發一個定期的任務一定不一般。例如,如果你的程序在窗體的狀態條上顯示當前時間,你可能每5秒鐘更新一次時間。System.Threading 名字空間包括了一個名為Timer多線程定時器類。
當你創建一個定時器類的實例時,你為定時器回調指明了一個以毫秒為單位的周期,而且你也傳遞給該對象一個委托用來每過一個時鐘周期調用你。回調發生在線程池中的線程上。事實上,每次時鐘周期到來時真正發生的是一個工作條目在線程池中排隊;一般來說一個調用會馬上發生的,但是如果線程池比較忙,這個回調也許會在稍後的一個時間點發生。
如果你考慮在你的程序中使用多線程,你也許會考慮使用定時器類。然而,如果你的程序使用了Windows窗體,你不必使用多線程的行為,在System.Windows.Forms名字空間中有另外一個也叫Timer的定時器類。
System.Windows.Forms.Timer與其多線程的同伴比起來有一個明顯的好處:因為它不是多線程的,所以不會在其它線程中對你進行回調,而且更適合為應用程序提取窗口消息的主線程。實際上System.Windows.Forms.Timer的實現是在系統中使用了WM_TIMER的一個窗口消息。這種方法在你的System.Windows.Forms.Timer的事件處理器中不必擔心線程同步,線程間通信之類的問題。
對於Windows窗體類程序,作為一個很好的技巧就是使用System.Windows.Forms.Timer類, 除非你特別需要線程池中的線程對你進行回調。既然這種要求很少見,為了使事情簡單,把使用System.Windows.Forms.Timer作為一個規則,即使在你的程序的其它地方使用了多線程。
展望將來
微軟最近展示了一個即將出現的GUI API,代號為“Avalon”,本期MSDN雜志的問題列表中(見70頁)Charles Petzold''''s的文章描述了其特點。在Avalon框架中用戶接口元素沒有被系與一個特殊的線程;作為更換每個用戶接口元素與一個單獨的邏輯線程上下文相關聯,在UIContext類中實現。但是當你發現UIContext類中包含了Invoke方法,及其姊妹BeginInvoke時,你就不會驚奇了,在名字上與窗體類中的控件類上名稱一樣的目的是說明他們在邏輯作用上是一致的。
作者簡介
Jason Clark 為微軟和Wintellect公司提供培訓和咨詢,他是Windows NT和Windows 2000服務器團隊的開發前輩。他是Windows 2000服務器程序編程一書的合著者。與Jason的聯系方式:[email protected]