從 MSDN Code Center 下載 asynchcaclpi.exe 示例文件(英文)。
摘要:本文探討了如何利用多線(xiàn)程從長(cháng)時(shí)間運行的操作中分離出用戶(hù)界面 (UI),以將用戶(hù)的后續輸入傳遞給輔助線(xiàn)程以調節其行為,從而實(shí)現穩定而正確的多線(xiàn)程處理的消息傳遞方案。
或許您還能回想起以前的一些專(zhuān)欄,例如 Safe, Simple Multithreading in Windows Forms(英文)。如果您仔細閱讀,就可以使 Windows 窗體和線(xiàn)程很好地協(xié)同工作。執行長(cháng)時(shí)間運行的操作的較好方法是使用線(xiàn)程,例如計算 pi 小數點(diǎn)之后的多位數值(如以下圖 1 所示)。

圖 1:Pi 的位數應用程序
在上一篇文章中,我們介紹了直接啟動(dòng)線(xiàn)程進(jìn)行后臺處理,但選擇使用異步委托來(lái)啟動(dòng)輔助線(xiàn)程。異步委托在傳遞參數時(shí)具有語(yǔ)法方便的優(yōu)點(diǎn),并且通過(guò)在進(jìn)程范圍的、公共語(yǔ)言運行庫管理的池中使用線(xiàn)程來(lái)獲得更大的作用范圍。我們遇到的僅有的問(wèn)題發(fā)生在輔助線(xiàn)程需要向用戶(hù)通知進(jìn)度時(shí)。在本例中,輔助線(xiàn)程不允許直接使用 UI 控件(長(cháng)期使用的 Win32® UI 不被允許)。取而代之的是,輔助線(xiàn)程必須向 UI 線(xiàn)程發(fā)送或發(fā)布一條消息,并使用 Control.Invoke 或 Control.BeginInvoke 在擁有 UI 控件的線(xiàn)程上執行代碼??紤]到這些因素后的代碼如下:
// 委托以開(kāi)始異步計算 pidelegate void CalcPiDelegate(int digits);void _calcButton_Click(object sender, EventArgs e) { // 開(kāi)始異步計算 pi CalcPiDelegate calcPi = new CalcPiDelegate(CalcPi); calcPi.BeginInvoke((int)_digits.Value, null, null);}void CalcPi(int digits) { StringBuilder pi = new StringBuilder("3", digits + 2); // 顯示進(jìn)度 ShowProgress(pi.ToString(), digits, 0); if( digits > 0 ) { pi.Append("."); for( int i = 0; i < digits; i += 9 ) { ... // 顯示進(jìn)度 ShowProgress(pi.ToString(), digits, i + digitCount); } }}// 委托以向 UI 線(xiàn)程通知輔助線(xiàn)程的進(jìn)度delegatevoid ShowProgressDelegate(string pi, int totalDigits, int digitsSoFar);void ShowProgress(string pi, int totalDigits, int digitsSoFar) { // 確保在正確的線(xiàn)程上 if( _pi.InvokeRequired == false ) { _pi.Text = pi; _piProgress.Maximum = totalDigits; _piProgress.Value = digitsSoFar; } else { // 異步顯示進(jìn)度 ShowProgressDelegate showProgress = new ShowProgressDelegate(ShowProgress); this.BeginInvoke(showProgress, new object[] { pi, totalDigits, digitsSoFar }); }}注意,這里有兩個(gè)委托。第一個(gè)是 CalcPiDelegate,用于捆綁要傳遞給(從線(xiàn)程池中分配的)輔助線(xiàn)程上的 CalcPi 的參數。當用戶(hù)決定要計算 pi 時(shí),事件處理程序將創(chuàng )建此委托的一個(gè)實(shí)例。此工作通過(guò)調用 BeginInvoke 在線(xiàn)程池中進(jìn)行排隊。第一個(gè)委托實(shí)際上是由 UI 線(xiàn)程用于向輔助線(xiàn)程傳遞消息。
第二個(gè)委托是 ShowProgressDelegate,由輔助線(xiàn)程用于向 UI 線(xiàn)程回傳消息,通常是有關(guān)長(cháng)時(shí)間運行的操作的最新進(jìn)度。為了對調用者屏蔽與此 UI 線(xiàn)程有關(guān)的線(xiàn)程安全通信信息,ShowProgress 方法在此 UI 線(xiàn)程上通過(guò) Control.BeginInvoke 方法使用 ShowProgressDelegate 給自己發(fā)送消息。Control.BeginInvoke 異步隊列為 UI 線(xiàn)程提供服務(wù),并且不等待結果就繼續運行。
在本示例中,我們可以在輔助線(xiàn)程和 UI 線(xiàn)程之間來(lái)回發(fā)送消息而無(wú)需關(guān)注外部環(huán)境。UI 線(xiàn)程不必等待輔助線(xiàn)程執行完畢,甚至無(wú)需等待完成通知,因為輔助線(xiàn)程在執行過(guò)程中會(huì )與其實(shí)時(shí)交流進(jìn)度情況。同樣,輔助線(xiàn)程也不必等待 UI 線(xiàn)程顯示進(jìn)度,只要進(jìn)度消息按照固定的時(shí)間間隔發(fā)送以使用戶(hù)感到滿(mǎn)意即可。但有一點(diǎn)無(wú)法滿(mǎn)足用戶(hù),即:不能完全控制應用程序正在執行的任何處理。即使 UI 在計算 pi 時(shí)能夠提供響應,有時(shí)用戶(hù)仍需要取消計算操作,例如如果用戶(hù)決定需要計算 1,000,001 位數字但卻錯誤地輸入了 1,000,000。更新的 CalcPi UI 允許取消操作,如圖 2 所示。

圖 2:允許用戶(hù)取消長(cháng)時(shí)間運行的操作
要實(shí)現取消長(cháng)時(shí)間運行的操作,需要完成多個(gè)步驟。首先,需要為用戶(hù)提供 UI。在本例中,Calc(計算)按鈕在計算開(kāi)始后變?yōu)?Cancel(取消)按鈕。另一個(gè)常見(jiàn)的選擇是進(jìn)度對話(huà)框。該對話(huà)框通常包含當前進(jìn)度的詳細信息,包括顯示工作完成百分比的進(jìn)度條和一個(gè) Cancel(取消)按鈕。
如果用戶(hù)決定取消操作,則應該在成員變量中提供說(shuō)明,并且在從 UI 線(xiàn)程獲知輔助線(xiàn)程應該停止時(shí),到輔助線(xiàn)程自己知道并可以停止發(fā)送進(jìn)度之前的這一小段時(shí)間內,應該禁用 UI。如果忽略這段時(shí)間,可能會(huì )出現這種情況:用戶(hù)在第一個(gè)輔助線(xiàn)程停止發(fā)送進(jìn)度之前又開(kāi)始了另一項操作,這就使 UI 線(xiàn)程必須判斷是從新的輔助線(xiàn)程獲取進(jìn)度還是從即將關(guān)閉的舊線(xiàn)程獲取進(jìn)度。當然,也可以為每個(gè)輔助線(xiàn)程分配一個(gè)唯一的 ID,從而使 UI 線(xiàn)程可以處理好這些工作。(如果有多個(gè)并存的長(cháng)時(shí)間運行的操作,則很有必要這樣做。)這樣,在從 UI 獲知輔助線(xiàn)程即將停止工作時(shí)到輔助線(xiàn)程獲知之前的這一小段時(shí)間內,暫停 UI 通常會(huì )更容易一些。我們的簡(jiǎn)單的 pi 計算器的實(shí)現方式是使用一個(gè)具有三個(gè)值的枚舉變量,如下所示:
enum CalcState { Pending, // 沒(méi)有任何計算正在運行或取消 Calculating, // 正在計算 Canceled, // 在 UI 中計算已被取消但在輔助線(xiàn)程中還沒(méi)有}CalcState _state = CalcState.Pending;現在,根據所處的狀態(tài)不同,我們分別處理 Calc 按鈕,如下所示:
void _calcButton_Click(...) { // Calc 按鈕兼有 Cancel 按鈕的功能 switch( _state ) { // 開(kāi)始新的計算 case CalcState.Pending: // 允許取消 _state = CalcState.Calculating; _calcButton.Text = "Cancel"; // 異步委托方法 CalcPiDelegate calcPi = new CalcPiDelegate(CalcPi); calcPi.BeginInvoke((int)_digits.Value, null, null); break; // 取消正在運行的計算 case CalcState.Calculating: _state = CalcState.Canceled; _calcButton.Enabled = false; break; // 在取消過(guò)程中應該無(wú)法按下 Calc 按鈕 case CalcState.Canceled: Debug.Assert(false); break; }}請注意,如果在處于 Pending 狀態(tài)時(shí)按下 Calc/Cancel 按鈕,我們發(fā)送狀態(tài) Calculating(同時(shí)更改按鈕上的標簽),并像以前那樣開(kāi)始異步計算。如果在處于 Calculating 狀態(tài)時(shí)按下 Calc/Cancel 按鈕,則應該將狀態(tài)切換為 Canceled 并禁止 UI 開(kāi)始新的計算(在它為我們向輔助線(xiàn)程傳遞取消狀態(tài)期間)。一旦我們已經(jīng)向輔助線(xiàn)程傳達了取消操作的信息,就可以再次啟用 UI 并將狀態(tài)重設為 Pending,從而使用戶(hù)可以開(kāi)始其他操作。要向輔助線(xiàn)程傳達取消操作的信息,可以將 ShowProgress 方法擴充為包含新的 out 參數:
void ShowProgress(..., out bool cancel)void CalcPi(int digits) { bool cancel = false; ... for( int i = 0; i < digits; i += 9 ) { ... // 顯示進(jìn)度(檢查是否取消) ShowProgress(..., out cancel); if( cancel ) break; }}您可能想嘗試將取消指示器設置為從 ShowProgress 返回的布爾值,但我從來(lái)都記不住 true 是表示取消還是表示一切正常(或繼續照常執行)。所以我使用 out 參數,這樣可以更直觀(guān)一些。
最后剩下的事情是更新 ShowProgress 方法(即在輔助線(xiàn)程和 UI 線(xiàn)程之間實(shí)際執行傳遞工作的那部分代碼),以判斷用戶(hù)是否請求取消并相應地通知 CalcPi 程序。確切地說(shuō),如何在 UI 和輔助線(xiàn)程之間傳遞信息取決于我們希望使用哪種技術(shù)。
傳遞 UI 當前狀態(tài)的最常見(jiàn)方法是讓輔助線(xiàn)程直接訪(fǎng)問(wèn) _state 成員變量。我們可以使用以下代碼來(lái)達到這一目的:
void ShowProgress(..., out bool cancel) { // 不要這樣做! if( _state == CalcState.Cancel ) { _state = CalcState.Pending; cancel = true; } ...}我希望您看到這段代碼時(shí)能夠自然而然地(而不只是因為代碼中的警告注釋?zhuān)┫氲椒艞壦?。如果您打算編?xiě)多線(xiàn)程的程序,就必須要注意在任何時(shí)候兩個(gè)線(xiàn)程都可能會(huì )同時(shí)訪(fǎng)問(wèn)相同的數據(在本例中是 _state 成員變量)。在線(xiàn)程之間共享訪(fǎng)問(wèn)數據很容易使線(xiàn)程進(jìn)入“競爭狀態(tài)”,即其中一個(gè)線(xiàn)程在另一個(gè)線(xiàn)程完成更新數據之前搶先讀取部分更新的數據。為了實(shí)現共享數據的并發(fā)訪(fǎng)問(wèn),您需要監視共享數據的使用情況,以確保各線(xiàn)程耐心等待其他線(xiàn)程處理完數據。為了監視共享數據的訪(fǎng)問(wèn),.NET 為共享對象提供了 Monitor 類(lèi),其作用類(lèi)似于為數據加了一把鎖(C# 中包含了這種方便的加鎖塊):
object _stateLock = new object();void ShowProgress(..., out bool cancel) { // 也不要這樣做! lock( _stateLock ) { // 監視鎖 if( _state == CalcState.Cancel ) { _state = CalcState.Pending; cancel = true; } ... }}現在我已經(jīng)適當地鎖定了對共享數據的訪(fǎng)問(wèn),但由于我是采取上述方法來(lái)實(shí)現的,因此在執行多線(xiàn)程編程時(shí)就很可能會(huì )產(chǎn)生另一個(gè)常見(jiàn)問(wèn)題,即“死鎖”。當兩個(gè)線(xiàn)程出現死鎖時(shí),在繼續執行之前它們均會(huì )等待另一個(gè)線(xiàn)程完成其工作,這樣實(shí)際上兩者就都不能執行。
如果所有這些有關(guān)競爭狀態(tài)和死鎖的討論都已經(jīng)引起了您的關(guān)注,那就好。通過(guò)共享數據進(jìn)行的多線(xiàn)程編程很難做到十全十美。目前為止,我們已經(jīng)能夠避免這些問(wèn)題,因為我們已經(jīng)傳遞了該數據的很多副本,并且各線(xiàn)程對這些副本具有完全的所有權。如果沒(méi)有共享數據,則無(wú)需考慮同步。如果您發(fā)現必須訪(fǎng)問(wèn)共享數據(也就是說(shuō),復制數據需要大量空間或非常費時(shí)),則需要研究在線(xiàn)程之間共享數據(查看“參考書(shū)目”一節以獲得在此領(lǐng)域中我最喜歡的研究文章)。
然而,絕大部分多線(xiàn)程方案(尤其是當涉及到 UI 多線(xiàn)程時(shí))似乎與我們目前一直使用的簡(jiǎn)單消息傳遞方案配合得最好。大多數時(shí)候,您不希望 UI 對正在后臺進(jìn)行處理的數據具有訪(fǎng)問(wèn)權限(例如正在打印的文檔或正被枚舉的對象集合)。對于這些情況,最好的選擇是避免使用共享數據。
我們已經(jīng)將 ShowProgress 方法擴充為包含 out 參數了。為什么不讓 ShowProgress 在 UI 線(xiàn)程上執行時(shí)檢查 _state 變量的狀態(tài)呢?如下所示:
void ShowProgress(..., out bool cancel) { // 確認在 UI 線(xiàn)程上 if( _pi.InvokeRequired == false ) { ... // 檢查是否取消 cancel = (_state == CalcState.Canceled); // 檢查是否完成 if( cancel || (digitsSoFar == totalDigits) ) { _state = CalcState.Pending; _calcButton.Text = "Calc"; _calcButton.Enabled = true; } } // 將控制傳遞給 UI 線(xiàn)程 else { ... }}由于只有 UI 線(xiàn)程訪(fǎng)問(wèn) _state 成員變量,因此不需要同步?,F在只需要按照上述方法將控制傳遞給 UI 線(xiàn)程,即可獲得 ShowProgressDelegate 的 cancel out 參數。不幸的是,使用 Control.BeginInvoke 使情況變得有些復雜。問(wèn)題在于 BeginInvoke 不會(huì )等待 ShowProgress 在 UI 線(xiàn)程上的調用結果,因此我們有兩個(gè)選擇。其中之一是向 BeginInvoke 傳遞另一個(gè)委托并在 ShowProgress 從 UI 線(xiàn)程返回后調用它,但這同時(shí)也會(huì )發(fā)生在線(xiàn)程池的其他線(xiàn)程上,所以我們還必須回到同步上來(lái),這一次是在輔助線(xiàn)程和連接池中的另一個(gè)線(xiàn)程之間同步。另一個(gè)較為簡(jiǎn)單的方法是切換到同步的 Control.Invoke 方法并等待 cancel out 參數。然而,就算采用這種方法也會(huì )有一點(diǎn)點(diǎn)棘手,如以下代碼所示:
void ShowProgress(..., out bool cancel) { if( _pi.InvokeRequired == false ) { ... } // 將控制傳遞給 UI 線(xiàn)程 else { ShowProgressDelegate showProgress = new ShowProgressDelegate(ShowProgress); // 避免包裝或丟失返回值 object inoutCancel = false; // 同步顯示進(jìn)度(這樣我們可以檢查是否取消) Invoke(showProgress, new object[] { ..., inoutCancel}); cancel = (bool)inoutCancel; }}雖然直接向 Control.Invoke 簡(jiǎn)單傳遞一個(gè)布爾變量來(lái)獲得 cancel 參數可能是一個(gè)理想的方法,但這同樣存在問(wèn)題。問(wèn)題是 bool 是“值數據類(lèi)型”,而 Invoke 采用對象數組作為參數,并且對象是“引用數據類(lèi)型”。(您可以查看“參考書(shū)目”一節以獲得有關(guān)討論兩者區別的書(shū)籍。)其結果是作為對象傳遞的 bool 將被復制而保持實(shí)際的 bool 不變,這意味著(zhù)我們無(wú)法知道操作被取消了。為了避免出現這種情況,我們創(chuàng )建了自己的對象變量 (inoutCancel) 并傳遞它,這樣就避免了復制。在同步調用 Invoke 后,我們將 object 變量轉換為 bool 以查看是否應該取消操作。
任何時(shí)候調用帶有 out 或 ref 參數的 Control.Invoke(或 Control.BeginInvoke)時(shí),都必須注意值類(lèi)型和引用類(lèi)型數據之間的區別。(這里的 out 或 ref 是值類(lèi)型,例如 int 或 bool 等原始類(lèi)型以及枚舉和結構類(lèi)型等。)當然,即便您使用自定義的引用類(lèi)型(也叫做類(lèi))傳遞更加復雜的數據,也不需要專(zhuān)門(mén)再做其他工作。然而,即使在處理 Invoke/BeginInvoke 的數據類(lèi)型時(shí)會(huì )有些麻煩,但相比讓多線(xiàn)程代碼在競爭狀態(tài)或使用死鎖-釋放方法的情況下訪(fǎng)問(wèn)共享數據而言,這算不上是個(gè)大問(wèn)題,所以我認為付出這點(diǎn)小代價(jià)是值得的。
我們又一次使用了一個(gè)很小的示例來(lái)探討一些復雜的問(wèn)題。我們不僅利用了多線(xiàn)程從長(cháng)時(shí)間運行的操作中分離 UI,而且還將用戶(hù)的進(jìn)一步輸入傳遞給輔助線(xiàn)程以調整其行為。盡管我們原本可以使用共享數據來(lái)避免復雜的同步問(wèn)題(這只有在您的上司試用您的代碼時(shí)才會(huì )產(chǎn)生),但最終我們還是使用了消息傳遞方案來(lái)進(jìn)行穩定而正確的多線(xiàn)程處理。
聯(lián)系客服