用戶不喜歡反應(yīng)慢的程序。程序反應(yīng)越慢,就越?jīng)]有用戶會(huì)喜歡它。在執(zhí)行耗時(shí)較長(zhǎng)的操作時(shí),使用多線程是明智之舉,它可以提高程序 UI 的響應(yīng)速度,使得一切運(yùn)行顯得更為快速。在 Windows 中進(jìn)行多線程編程曾經(jīng)是 C++ 開(kāi)發(fā)人員的專屬特權(quán),但是現(xiàn)在,可以使用所有兼容 Microsoft .NET 的語(yǔ)言來(lái)編寫,其中包括 Visual Basic.NET。不過(guò),Windows 窗體對(duì)線程的使用強(qiáng)加了一些重要限制。本文將對(duì)這些限制進(jìn)行闡釋,并說(shuō)明如何利用它們來(lái)提供快速、高質(zhì)量的 UI 體驗(yàn),即使是程序要執(zhí)行的任務(wù)本身速度就較慢。
為什么選擇多線程?
多線程程序要比單線程程序更難于編寫,并且不加選擇地使用線程也是導(dǎo)致難以找到細(xì)小錯(cuò)誤的重要原因。這就自然會(huì)引出兩個(gè)問(wèn)題:為什么不堅(jiān)持編寫單線程代碼?如果必須使用多線程,如何才能避免缺陷呢?本文的大部分篇幅都是在回答第二個(gè)問(wèn)題,但首先我要來(lái)解釋一下為什么確實(shí)需要多線程。
多線程處理可以使您能夠通過(guò)確保程序“永不睡眠”從而保持 UI 的快速響應(yīng)。大部分程序都有不響應(yīng)用戶的時(shí)候:它們正忙于為您執(zhí)行某些操作以便響應(yīng)進(jìn)一步的請(qǐng)求。也許最廣為人知的例子就是出現(xiàn)在“打開(kāi)文件”對(duì)話框頂部的組合框。如果在展開(kāi)該組合框時(shí),CD-ROM驅(qū)動(dòng)器里恰好有一張光盤,則計(jì)算機(jī)通常會(huì)在顯示列表之前先讀取光盤。這可能需要幾秒鐘的時(shí)間,在此期間,程序既不響應(yīng)任何輸入,也不允許取消該操作,尤其是在確實(shí)并不打算使用光驅(qū)的時(shí)候,這種情況會(huì)讓人無(wú)法忍受。
執(zhí)行這種操作期間 UI 凍結(jié)的原因在于,UI 是個(gè)單線程程序,單線程不可能在等待 CD-ROM驅(qū)動(dòng)器讀取操作的同時(shí)處理用戶輸入,如圖 1 所示。“打開(kāi)文件”對(duì)話框會(huì)調(diào)用某些阻塞 (blocking) API 來(lái)確定 CD-ROM 的標(biāo)題。阻塞 API 在未完成自己的工作之前不會(huì)返回,因此這期間它會(huì)阻止線程做其他事情。
在多線程下,像這樣耗時(shí)較長(zhǎng)的任務(wù)就可以在其自己的線程中運(yùn)行,這些線程通常稱為輔助線程。因?yàn)橹挥休o助線程受到阻止,所以阻塞操作不再導(dǎo)致用戶界面凍結(jié),如圖 2 所示。應(yīng)用程序的主線程可以繼續(xù)處理用戶的鼠標(biāo)和鍵盤輸入的同時(shí),受阻的另一個(gè)線程將等待 CD-ROM 讀取,或執(zhí)行輔助線程可能做的任何操作。
圖 2 多線程
其基本原則是,負(fù)責(zé)響應(yīng)用戶輸入和保持用戶界面為最新的線程(通常稱為 UI 線程)不應(yīng)該用于執(zhí)行任何耗時(shí)較長(zhǎng)的操作。慣常做法是,任何耗時(shí)超過(guò) 30ms 的操作都要考慮從 UI 線程中移除。這似乎有些夸張,因?yàn)?30ms 對(duì)于大多數(shù)人而言只不過(guò)是他們可以感覺(jué)到的最短的瞬間停頓,實(shí)際上該停頓略短于電影屏幕中顯示的連續(xù)幀之間的間隔。
如果鼠標(biāo)單擊和相應(yīng)的 UI 提示(例如,重新繪制按鈕)之間的延遲超過(guò) 30ms,那么操作與顯示之間就會(huì)稍顯不連貫,并因此產(chǎn)生如同影片斷幀那樣令人心煩的感覺(jué)。為了達(dá)到完全高質(zhì)量的響應(yīng)效果,上限必須是 30ms。另一方面,如果您確實(shí)不介意感覺(jué)稍顯不連貫,但也不想因?yàn)橥nD過(guò)長(zhǎng)而激怒用戶,則可按照通常用戶所能容忍的限度將該間隔設(shè)為 100ms。
這意味著如果想讓用戶界面保持響應(yīng)迅速,則任何阻塞操作都應(yīng)該在輔助線程中執(zhí)行 — 不管是機(jī)械等待某事發(fā)生(例如,等待 CD-ROM 啟動(dòng)或者硬盤定位數(shù)據(jù)),還是等待來(lái)自網(wǎng)絡(luò)的響應(yīng)。
異步委托調(diào)用
在輔助線程中運(yùn)行代碼的最簡(jiǎn)單方式是使用異步委托調(diào)用(所有委托都提供該功能)。委托通常是以同步方式進(jìn)行調(diào)用,即,在調(diào)用委托時(shí),只有包裝方法返回后該調(diào)用才會(huì)返回。要以異步方式調(diào)用委托,請(qǐng)調(diào)用 BeginInvoke 方法,這樣會(huì)對(duì)該方法排隊(duì)以在系統(tǒng)線程池的線程中運(yùn)行。調(diào)用線程會(huì)立即返回,而不用等待該方法完成。這比較適合于 UI 程序,因?yàn)榭梢杂盟鼇?lái)啟動(dòng)耗時(shí)較長(zhǎng)的作業(yè),而不會(huì)使用戶界面反應(yīng)變慢。
例如,在以下代碼中,System.Windows.Forms.MethodInvoker 類型是一個(gè)系統(tǒng)定義的委托,用于調(diào)用不帶參數(shù)的方法。
private void StartSomeWorkFromUIThread () {
// The work we want to do is too slow for the UI
// thread, so let's farm it out to a worker thread.
MethodInvoker mi = new MethodInvoker(
RunsOnWorkerThread);
mi.BeginInvoke(null, null); // This will not block.
}
// The slow work is done here, on a thread
// from the system thread pool.
private void RunsOnWorkerThread() {
DoSomethingSlow();
}
如果想要傳遞參數(shù),可以選擇合適的系統(tǒng)定義的委托類型,或者自己來(lái)定義委托。MethodInvoker 委托并沒(méi)有什么神奇之處。和其他委托一樣,調(diào)用 BeginInvoke 會(huì)使該方法在系統(tǒng)線程池的線程中運(yùn)行,而不會(huì)阻塞 UI 線程以便其可執(zhí)行其他操作。對(duì)于以上情況,該方法不返回?cái)?shù)據(jù),所以啟動(dòng)它后就不用再去管它。如果您需要該方法返回的結(jié)果,則 BeginInvoke 的返回值很重要,并且您可能不傳遞空參數(shù)。然而,對(duì)于大多數(shù) UI 應(yīng)用程序而言,這種“啟動(dòng)后就不管”的風(fēng)格是最有效的,稍后會(huì)對(duì)原因進(jìn)行簡(jiǎn)要討論。您應(yīng)該注意到,BeginInvoke 將返回一個(gè) IAsyncResult。這可以和委托的 EndInvoke 方法一起使用,以在該方法調(diào)用完畢后檢索調(diào)用結(jié)果。
還有其他一些可用于在另外的線程上運(yùn)行方法的技術(shù),例如,直接使用線程池 API 或者創(chuàng)建自己的線程。然而,對(duì)于大多數(shù)用戶界面應(yīng)用程序而言,有異步委托調(diào)用就足夠了。采用這種技術(shù)不僅編碼容易,而且還可以避免創(chuàng)建并非必需的線程,因?yàn)榭梢岳镁€程池中的共享線程來(lái)提高應(yīng)用程序的整體性能。
線程和控件
Windows 窗體體系結(jié)構(gòu)對(duì)線程使用制定了嚴(yán)格的規(guī)則。如果只是編寫單線程應(yīng)用程序,則沒(méi)必要知道這些規(guī)則,這是因?yàn)閱尉€程的代碼不可能違反這些規(guī)則。然而,一旦采用多線程,就需要理解 Windows 窗體中最重要的一條線程規(guī)則:除了極少數(shù)的例外情況,否則都不要在它的創(chuàng)建線程以外的線程中使用控件的任何成員。
本規(guī)則的例外情況有文檔說(shuō)明,但這樣的情況非常少。這適用于其類派生自 System.Windows.Forms.Control 的任何對(duì)象,其中幾乎包括 UI 中的所有元素。所有的 UI 元素(包括表單本身)都是從 Control 類派生的對(duì)象。此外,這條規(guī)則的結(jié)果是一個(gè)被包含的控件(如,包含在一個(gè)表單中的按鈕)必須與包含它控件位處于同一個(gè)線程中。也就是說(shuō),一個(gè)窗口中的所有控件屬于同一個(gè) UI 線程。實(shí)際中,大部分 Windows 窗體應(yīng)用程序最終都只有一個(gè)線程,所有 UI 活動(dòng)都發(fā)生在這個(gè)線程上。這個(gè)線程通常稱為 UI 線程。這意味著您不能調(diào)用用戶界面中任意控件上的任何方法,除非在該方法的文檔說(shuō)明中指出可以調(diào)用。該規(guī)則的例外情況(總有文檔記錄)非常少而且它們之間關(guān)系也不大。請(qǐng)注意,以下代碼是非法的:
// Created on UI thread
private Label lblStatus;
...
// Doesn't run on UI thread
private void RunsOnWorkerThread() {
DoSomethingSlow();
lblStatus.Text = "Finished!"; // BAD!!
}
如果您在 .NET Framework 1.0 版本中嘗試運(yùn)行這段代碼,也許會(huì)僥幸運(yùn)行成功,或者初看起來(lái)是如此。這就是多線程錯(cuò)誤中的主要問(wèn)題,即它們并不會(huì)立即顯現(xiàn)出來(lái)。甚至當(dāng)出現(xiàn)了一些錯(cuò)誤時(shí),在第一次演示程序之前一切看起來(lái)也都很正常。但不要搞錯(cuò) — 我剛才顯示的這段代碼明顯違反了規(guī)則,并且可以預(yù)見(jiàn),任何抱希望于“試運(yùn)行時(shí)良好,應(yīng)該就沒(méi)有問(wèn)題”的人在即將到來(lái)的調(diào)試期是會(huì)付出沉重代價(jià)的。
要注意,在明確創(chuàng)建線程之前會(huì)發(fā)生這樣的問(wèn)題。使用委托的異步調(diào)用實(shí)用程序(調(diào)用它的 BeginInvoke 方法)的任何代碼都可能出現(xiàn)同樣的問(wèn)題。委托提供了一個(gè)非常吸引人的解決方案來(lái)處理 UI 應(yīng)用程序中緩慢、阻塞的操作,因?yàn)檫@些委托能使您輕松地讓此種操作運(yùn)行在 UI 線程外而無(wú)需自己創(chuàng)建新線程。但是由于以異步委托調(diào)用方式運(yùn)行的代碼在一個(gè)來(lái)自線程池的線程中運(yùn)行,所以它不能訪問(wèn)任何 UI 元素。上述限制也適用于線程池中的線程和手動(dòng)創(chuàng)建的輔助線程。
在正確的線程中調(diào)用控件
有關(guān)控件的限制看起來(lái)似乎對(duì)多線程編程非常不利。如果在輔助線程中運(yùn)行的某個(gè)緩慢操作不對(duì) UI 產(chǎn)生任何影響,用戶如何知道它的進(jìn)行情況呢?至少,用戶如何知道工作何時(shí)完成或者是否出現(xiàn)錯(cuò)誤?幸運(yùn)的是,雖然此限制的存在會(huì)造成不便,但并非不可逾越。有多種方式可以從輔助線程獲取消息,并將該消息傳遞給 UI 線程。理論上講,可以使用低級(jí)的同步原理和池化技術(shù)來(lái)生成自己的機(jī)制,但幸運(yùn)的是,因?yàn)橛幸粋€(gè)以 Control 類的 Invoke 方法形式存在的解決方案,所以不需要借助于如此低級(jí)的工作方式。
Invoke 方法是 Control 類中少數(shù)幾個(gè)有文檔記錄的線程規(guī)則例外之一:它始終可以對(duì)來(lái)自任何線程的 Control 進(jìn)行 Invoke 調(diào)用。Invoke 方法本身只是簡(jiǎn)單地?cái)y帶委托以及可選的參數(shù)列表,并在 UI 線程中為您調(diào)用委托,而不考慮 Invoke 調(diào)用是由哪個(gè)線程發(fā)出的。實(shí)際上,為控件獲取任何方法以在正確的線程上運(yùn)行非常簡(jiǎn)單。但應(yīng)該注意,只有在 UI 線程當(dāng)前未受到阻塞時(shí),這種機(jī)制才有效 — 調(diào)用只有在 UI 線程準(zhǔn)備處理用戶輸入時(shí)才能通過(guò)。從不阻塞 UI 線程還有另一個(gè)好理由。Invoke 方法會(huì)進(jìn)行測(cè)試以了解調(diào)用線程是否就是 UI 線程。如果是,它就直接調(diào)用委托。否則,它將安排線程切換,并在 UI 線程上調(diào)用委托。無(wú)論是哪種情況,委托所包裝的方法都會(huì)在 UI 線程中運(yùn)行,并且只有當(dāng)該方法完成時(shí),Invoke 才會(huì)返回。
Control 類也支持異步版本的 Invoke,它會(huì)立即返回并安排該方法以便在將來(lái)某一時(shí)間在 UI 線程上運(yùn)行。這稱為 BeginInvoke,它與異步委托調(diào)用很相似,與委托的明顯區(qū)別在于,該調(diào)用以異步方式在線程池的某個(gè)線程上運(yùn)行,然而在此處,它以異步方式在 UI 線程上運(yùn)行。實(shí)際上,Control 的 Invoke、BeginInvoke 和 EndInvoke 方法,以及 InvokeRequired 屬性都是 ISynchronizeInvoke 接口的成員。該接口可由任何需要控制其事件傳遞方式的類實(shí)現(xiàn)。
由于 BeginInvoke 不容易造成死鎖,所以盡可能多用該方法;而少用 Invoke 方法。因?yàn)?Invoke 是同步的,所以它會(huì)阻塞輔助線程,直到 UI 線程可用。但是如果 UI 線程正在等待輔助線程執(zhí)行某操作,情況會(huì)怎樣呢?應(yīng)用程序會(huì)死鎖。BeginInvoke 從不等待 UI 線程,因而可以避免這種情況。
現(xiàn)在,我要回顧一下前面所展示的代碼片段的合法版本。首先,必須將一個(gè)委托傳遞給 Control 的 BeginInvoke 方法,以便可以在 UI 線程中運(yùn)行對(duì)線程敏感的代碼。這意味著應(yīng)該將該代碼放在它自己的方法中,如圖 3 所示。一旦輔助線程完成緩慢的工作后,它就會(huì)調(diào)用 Label 中的 BeginInvoke,以便在其 UI 線程上運(yùn)行某段代碼。通過(guò)這樣,它可以更新用戶界面。
包裝 Control.Invoke
雖然圖 3中的代碼解決了這個(gè)問(wèn)題,但它相當(dāng)繁瑣。如果輔助線程希望在結(jié)束時(shí)提供更多的反饋信息,而不是簡(jiǎn)單地給出“Finished!”消息,則 BeginInvoke 過(guò)于復(fù)雜的使用方法會(huì)令人生畏。為了傳達(dá)其他消息,例如“正在處理”、“一切順利”等等,需要設(shè)法向 UpdateUI 函數(shù)傳遞一個(gè)參數(shù)。可能還需要添加一個(gè)進(jìn)度欄以提高反饋能力。這么多次調(diào)用 BeginInvoke 可能導(dǎo)致輔助線程受該代碼支配。這樣不僅會(huì)造成不便,而且考慮到輔助線程與 UI 的協(xié)調(diào)性,這樣設(shè)計(jì)也不好。對(duì)這些進(jìn)行分析之后,我們認(rèn)為包裝函數(shù)可以解決這兩個(gè)問(wèn)題,如圖 4 所示。
ShowProgress 方法對(duì)將調(diào)用引向正確線程的工作進(jìn)行封裝。這意味著輔助線程代碼不再擔(dān)心需要過(guò)多關(guān)注 UI 細(xì)節(jié),而只要定期調(diào)用 ShowProgress 即可。請(qǐng)注意,我定義了自己的方法,該方法違背了“必須在 UI 線程上進(jìn)行調(diào)用”這一規(guī)則,因?yàn)樗M(jìn)而只調(diào)用不受該規(guī)則約束的其他方法。這種技術(shù)會(huì)引出一個(gè)較為常見(jiàn)的話題:為什么不在控件上編寫公共方法呢(這些方法記錄為 UI 線程規(guī)則的例外)?
剛好 Control 類為這樣的方法提供了一個(gè)有用的工具。如果我提供一個(gè)設(shè)計(jì)為可從任何線程調(diào)用的公共方法,則完全有可能某人會(huì)從 UI 線程調(diào)用這個(gè)方法。在這種情況下,沒(méi)必要調(diào)用 BeginInvoke,因?yàn)槲乙呀?jīng)處于正確的線程中。調(diào)用 Invoke 完全是浪費(fèi)時(shí)間和資源,不如直接調(diào)用適當(dāng)?shù)姆椒ā榱吮苊膺@種情況,Control 類將公開(kāi)一個(gè)稱為 InvokeRequired 的屬性。這是“只限 UI 線程”規(guī)則的另一個(gè)例外。它可從任何線程讀取,如果調(diào)用線程是 UI 線程,則返回假,其他線程則返回真。這意味著我可以按以下方式修改包裝:
public void ShowProgress(string msg, int percentDone) {
if (InvokeRequired) {
// As before
...
} else {
// We're already on the UI thread just
// call straight through.
UpdateUI(this, new MyProgressEvents(msg,
PercentDone));
}
}
ShowProgress 現(xiàn)在可以記錄為可從任何線程調(diào)用的公共方法。這并沒(méi)有消除復(fù)雜性 — 執(zhí)行 BeginInvoke 的代碼依然存在,它還占有一席之地。不幸的是,沒(méi)有簡(jiǎn)單的方法可以完全擺脫它。
鎖定
任何并發(fā)系統(tǒng)都必須面對(duì)這樣的事實(shí),即,兩個(gè)線程可能同時(shí)試圖使用同一塊數(shù)據(jù)。有時(shí)這并不是問(wèn)題 — 如果多個(gè)線程在同一時(shí)間試圖讀取某個(gè)對(duì)象中的某個(gè)字段,則不會(huì)有問(wèn)題。然而,如果有線程想要修改該數(shù)據(jù),就會(huì)出現(xiàn)問(wèn)題。如果線程在讀取時(shí)剛好另一個(gè)線程正在寫入,則讀取線程有可能會(huì)看到虛假值。如果兩個(gè)線程在同一時(shí)間、在同一個(gè)位置執(zhí)行寫入操作,則在同步寫入操作發(fā)生之后,所有從該位置讀取數(shù)據(jù)的線程就有可能看到一堆垃圾數(shù)據(jù)。雖然這種行為只在特定情況下才會(huì)發(fā)生,讀取操作甚至不會(huì)與寫入操作發(fā)生沖突,但是數(shù)據(jù)可以是兩次寫入結(jié)果的混加,并保持錯(cuò)誤結(jié)果直到下一次寫入值為止。為了避免這種問(wèn)題,必須采取措施來(lái)確保一次只有一個(gè)線程可以讀取或?qū)懭肽硞€(gè)對(duì)象的狀態(tài)。
防止這些問(wèn)題出現(xiàn)所采用的方式是,使用運(yùn)行時(shí)的鎖定功能。C# 可以讓您利用這些功能、通過(guò)鎖定關(guān)鍵字來(lái)保護(hù)代碼(Visual Basic 也有類似構(gòu)造,稱為 SyncLock)。規(guī)則是,任何想要在多個(gè)線程中調(diào)用其方法的對(duì)象在每次訪問(wèn)其字段時(shí)(不管是讀取還是寫入)都應(yīng)該使用鎖定構(gòu)造。例如,請(qǐng)參見(jiàn)圖 5。
鎖定構(gòu)造的工作方式是:公共語(yǔ)言運(yùn)行庫(kù) (CLR) 中的每個(gè)對(duì)象都有一個(gè)與之相關(guān)的鎖,任何線程均可獲得該鎖,但每次只能有一個(gè)線程擁有它。如果某個(gè)線程試圖獲取另一個(gè)線程已經(jīng)擁有的鎖,那么它必須等待,直到擁有該鎖的線程將鎖釋放為止。C# 鎖定構(gòu)造會(huì)獲取該對(duì)象鎖(如果需要,要先等待另一個(gè)線程利用它完成操作),并保留到大括號(hào)中的代碼退出為止。如果執(zhí)行語(yǔ)句運(yùn)行到塊結(jié)尾,該鎖就會(huì)被釋放,并從塊中部返回,或者拋出在塊中沒(méi)有捕捉到的異常。
請(qǐng)注意,MoveBy 方法中的邏輯受同樣的鎖語(yǔ)句保護(hù)。當(dāng)所做的修改比簡(jiǎn)單的讀取或?qū)懭敫鼜?fù)雜時(shí),整個(gè)過(guò)程必須由單獨(dú)的鎖語(yǔ)句保護(hù)。這也適用于對(duì)多個(gè)字段進(jìn)行更新 — 在對(duì)象處于一致?tīng)顟B(tài)之前,一定不能釋放該鎖。如果該鎖在更新?tīng)顟B(tài)的過(guò)程中釋放,則其他線程也許能夠獲得它并看到不一致?tīng)顟B(tài)。如果您已經(jīng)擁有一個(gè)鎖,并調(diào)用一個(gè)試圖獲取該鎖的方法,則不會(huì)導(dǎo)致問(wèn)題出現(xiàn),因?yàn)閱为?dú)線程允許多次獲得同一個(gè)鎖。對(duì)于需要鎖定以保護(hù)對(duì)字段的低級(jí)訪問(wèn)和對(duì)字段執(zhí)行的高級(jí)操作的代碼,這非常重要。MoveBy 使用 Position 屬性,它們同時(shí)獲得該鎖。只有最外面的鎖阻塞完成后,該鎖才會(huì)恰當(dāng)?shù)蒯尫拧?/p>
對(duì)于需要鎖定的代碼,必須嚴(yán)格進(jìn)行鎖定。稍有疏漏,便會(huì)功虧一簣。如果一個(gè)方法在沒(méi)有獲取對(duì)象鎖的情況下修改狀態(tài),則其余的代碼在使用它之前即使小心地鎖定對(duì)象也是徒勞。同樣,如果一個(gè)線程在沒(méi)有事先獲得鎖的情況下試圖讀取狀態(tài),則它可能讀取到不正確的值。運(yùn)行時(shí)無(wú)法進(jìn)行檢查來(lái)確保多線程代碼正常運(yùn)行。
死鎖
鎖是確保多線程代碼正常運(yùn)行的基本條件,即使它們本身也會(huì)引入新的風(fēng)險(xiǎn)。在另一個(gè)線程上運(yùn)行代碼的最簡(jiǎn)單方式是,使用異步委托調(diào)用(請(qǐng)參見(jiàn)圖 6)。
如果曾經(jīng)調(diào)用過(guò) Foo 的 CallBar 方法,則這段代碼會(huì)慢慢停止運(yùn)行。CallBar 方法將獲得 Foo 對(duì)象上的鎖,并直到 BarWork 返回后才釋放它。然后,BarWork 使用異步委托調(diào)用,在某個(gè)線程池線程中調(diào)用 Foo 對(duì)象的 FooWork 方法。接下來(lái),它會(huì)在調(diào)用委托的 EndInvoke 方法前執(zhí)行一些其他操作。EndInvoke 將等待輔助線程完成,但輔助線程卻被阻塞在 FooWork 中。它也試圖獲取 Foo 對(duì)象的鎖,但鎖已被 CallBar 方法持有。所以,F(xiàn)ooWork 會(huì)等待 CallBar 釋放鎖,但 CallBar 也在等待 BarWork 返回。不幸的是,BarWork 將等待 FooWork 完成,所以 FooWork 必須先完成,它才能開(kāi)始。結(jié)果,沒(méi)有線程能夠進(jìn)行下去。
這就是一個(gè)死鎖的例子,其中有兩個(gè)或更多線程都被阻塞以等待對(duì)方進(jìn)行。這里的情形和標(biāo)準(zhǔn)死鎖情況還是有些不同,后者通常包括兩個(gè)鎖。這表明如果有某個(gè)因果性(過(guò)程調(diào)用鏈)超出線程界限,就會(huì)發(fā)生死鎖,即使只包括一個(gè)鎖!Control.Invoke 是一種跨線程調(diào)用過(guò)程的方法,這是個(gè)不爭(zhēng)的重要事實(shí)。BeginInvoke 不會(huì)遇到這樣的問(wèn)題,因?yàn)樗⒉粫?huì)使因果性跨線程。實(shí)際上,它會(huì)在某個(gè)線程池線程中啟動(dòng)一個(gè)全新的因果性,以允許原有的那個(gè)獨(dú)立進(jìn)行。然而,如果保留 BeginInvoke 返回的 IAsyncResult,并用它調(diào)用 EndInvoke,則又會(huì)出現(xiàn)問(wèn)題,因?yàn)?EndInvoke 實(shí)際上已將兩個(gè)因果性合二為一。避免這種情況的最簡(jiǎn)單方法是,當(dāng)持有一個(gè)對(duì)象鎖時(shí),不要等待跨線程調(diào)用完成。要確保這一點(diǎn),應(yīng)該避免在鎖語(yǔ)句中調(diào)用 Invoke 或 EndInvoke。其結(jié)果是,當(dāng)持有一個(gè)對(duì)象鎖時(shí),將無(wú)需等待其他線程完成某操作。要堅(jiān)持這個(gè)規(guī)則,說(shuō)起來(lái)容易做起來(lái)難。
在檢查代碼的 BarWork 時(shí),它是否在鎖語(yǔ)句的作用域內(nèi)并不明顯,因?yàn)樵谠摲椒ㄖ胁](méi)有鎖語(yǔ)句。出現(xiàn)這個(gè)問(wèn)題的唯一原因是 BarWork 調(diào)用自 Foo.CallBar 方法的鎖語(yǔ)句。這意味著只有確保正在調(diào)用的函數(shù)并不擁有鎖時(shí),調(diào)用 Control.Invoke 或 EndIn-voke 才是安全的。對(duì)于非私有方法而言,確保這一點(diǎn)并不容易,所以最佳規(guī)則是,根本不調(diào)用 Control.Invoke 和 EndInvoke。這就是為什么“啟動(dòng)后就不管”的編程風(fēng)格更可取的原因,也是為什么 Control.BeginInvoke 解決方案通常比 Control.Invoke 解決方案好的原因。
有時(shí)候除了破壞規(guī)則別無(wú)選擇,這種情況下就需要仔細(xì)嚴(yán)格地分析。但只要可能,在持有鎖時(shí)就應(yīng)該避免阻塞,因?yàn)槿绻贿@樣,死鎖就難以消除。
使其簡(jiǎn)單
如何既從多線程獲益最大,又不會(huì)遇到困擾并發(fā)代碼的棘手錯(cuò)誤呢?如果提高的 UI 響應(yīng)速度僅僅是使程序時(shí)常崩潰,那么很難說(shuō)是改善了用戶體驗(yàn)。大部分在多線程代碼中普遍存在的問(wèn)題都是由要一次運(yùn)行多個(gè)操作的固有復(fù)雜性導(dǎo)致的,這是因?yàn)榇蠖鄶?shù)人更善于思考連續(xù)過(guò)程而非并發(fā)過(guò)程。通常,最好的解決方案是使事情盡可能簡(jiǎn)單。
UI 代碼的性質(zhì)是:它從外部資源接收事件,如用戶輸入。它會(huì)在事件發(fā)生時(shí)對(duì)其進(jìn)行處理,但卻將大部分時(shí)間花在了等待事件的發(fā)生。如果可以構(gòu)造輔助線程和 UI 線程之間的通信,使其適合該模型,則未必會(huì)遇到這么多問(wèn)題,因?yàn)椴粫?huì)再有新的東西引入。我是這樣使事情簡(jiǎn)單化的:將輔助線程視為另一個(gè)異步事件源。如同 Button 控件傳遞諸如 Click 和 MouseEnter 這樣的事件,可以將輔助線程視為傳遞事件(如 ProgressUpdate 和 WorkComplete)的某物。只是簡(jiǎn)單地將這看作一種類比,還是真正將輔助對(duì)象封裝在一個(gè)類中,并按這種方式公開(kāi)適當(dāng)?shù)氖录@完全取決于您。后一種選擇可能需要更多的代碼,但會(huì)使用戶界面代碼看起來(lái)更加統(tǒng)一。不管哪種情況,都需要 Control.BeginInvoke 在正確的線程上傳遞這些事件。
對(duì)于輔助線程,最簡(jiǎn)單的方式是將代碼編寫為正常順序的代碼塊。但如果想要使用剛才介紹的“將輔助線程作為事件源”模型,那又該如何呢?這個(gè)模型非常適用,但它對(duì)該代碼與用戶界面的交互提出了限制:這個(gè)線程只能向 UI 發(fā)送消息,并不能向它提出請(qǐng)求。
例如,讓輔助線程中途發(fā)起對(duì)話以請(qǐng)求完成結(jié)果需要的信息將非常困難。如果確實(shí)需要這樣做,也最好是在輔助線程中發(fā)起這樣的對(duì)話,而不要在主 UI 線程中發(fā)起。該約束是有利的,因?yàn)樗鼘⒋_保有一個(gè)非常簡(jiǎn)單且適用于兩線程間通信的模型 — 在這里簡(jiǎn)單是成功的關(guān)鍵。這種開(kāi)發(fā)風(fēng)格的優(yōu)勢(shì)在于,在等待另一個(gè)線程時(shí),不會(huì)出現(xiàn)線程阻塞。這是避免死鎖的有效策略。
圖 7
顯示了使用異步委托調(diào)用以在輔助線程中執(zhí)行可能較慢的操作(讀取某個(gè)目錄的內(nèi)容),然后將結(jié)果顯示在 UI 上。它還不至于使用高級(jí)事件語(yǔ)法,但是該調(diào)用確實(shí)是以與處理事件(如單擊)非常相似的方式來(lái)處理完整的輔助代碼。
取消
前面示例所帶來(lái)的問(wèn)題是,要取消操作只能通過(guò)退出整個(gè)應(yīng)用程序?qū)崿F(xiàn)。雖然在讀取某個(gè)目錄時(shí) UI 仍然保持迅速響應(yīng),但由于在當(dāng)前操作完成之前程序?qū)⒔孟嚓P(guān)按鈕,所以用戶無(wú)法查看另一個(gè)目錄。如果試圖讀取的目錄是在一臺(tái)剛好沒(méi)有響應(yīng)的遠(yuǎn)程機(jī)器上,這就很不幸,因?yàn)檫@樣的操作需要很長(zhǎng)時(shí)間才會(huì)超時(shí)。
要取消一個(gè)操作也比較困難,盡管這取決于怎樣才算取消。一種可能的理解是“停止等待這個(gè)操作完成,并繼續(xù)另一個(gè)操作。”這實(shí)際上是拋棄進(jìn)行中的操作,并忽略最終完成時(shí)可能產(chǎn)生的后果。對(duì)于當(dāng)前示例,這是最好的選擇,因?yàn)楫?dāng)前正在處理的操作(讀取目錄內(nèi)容)是通過(guò)調(diào)用一個(gè)阻塞 API 來(lái)執(zhí)行的,取消它沒(méi)有關(guān)系。但即使是如此松散的“假取消”也需要進(jìn)行大量工作。如果決定啟動(dòng)新的讀取操作而不等待原來(lái)的操作完成,則無(wú)法知道下一個(gè)接收到的通知是來(lái)自這兩個(gè)未處理請(qǐng)求中的哪一個(gè)。
支持取消在輔助線程中運(yùn)行的請(qǐng)求的唯一方式是,提供與每個(gè)請(qǐng)求相關(guān)的某種調(diào)用對(duì)象。最簡(jiǎn)單的做法是將它作為一個(gè) Cookie,由輔助線程在每次通知時(shí)傳遞,允許 UI 線程將事件與請(qǐng)求相關(guān)聯(lián)。通過(guò)簡(jiǎn)單的身份比較(參見(jiàn)圖 8),UI 代碼就可以知道事件是來(lái)自當(dāng)前請(qǐng)求,還是來(lái)自早已廢棄的請(qǐng)求。
如果簡(jiǎn)單拋棄就行,那固然很好,不過(guò)您可能想要做得更好。如果輔助線程執(zhí)行的是進(jìn)行一連串阻塞操作的復(fù)雜操作,那么您可能希望輔助線程在最早的時(shí)機(jī)停止。否則,它可能會(huì)繼續(xù)幾分鐘的無(wú)用操作。在這種情況下,調(diào)用對(duì)象需要做的就不止是作為一個(gè)被動(dòng) Cookie。它至少還需要維護(hù)一個(gè)標(biāo)記,指明請(qǐng)求是否被取消。UI 可以隨時(shí)設(shè)置這個(gè)標(biāo)記,而輔助線程在執(zhí)行時(shí)將定期測(cè)試這個(gè)標(biāo)記,以確定是否需要放棄當(dāng)前工作。
對(duì)于這個(gè)方案,還需要做出幾個(gè)決定:如果 UI 取消了操作,它是否要等待直到輔助線程注意到這次取消?如果不等待,就需要考慮一個(gè)爭(zhēng)用條件:有可能 UI 線程會(huì)取消該操作,但在設(shè)置控制標(biāo)記之前輔助線程已經(jīng)決定傳遞通知了。因?yàn)?UI 線程決定不等待,直到輔助線程處理取消,所以 UI 線程有可能會(huì)繼續(xù)從輔助線程接收通知。如果輔助線程使用 BeginInvoke 異步傳遞通知,則 UI 甚至有可能收到多個(gè)通知。UI 線程也可以始終按與“廢棄”做法相同的方式處理通知 — 檢查調(diào)用對(duì)象的標(biāo)識(shí)并忽略它不再關(guān)心的操作通知。或者,在調(diào)用對(duì)象中進(jìn)行鎖定并決不從輔助線程調(diào)用 BeginInvoke 以解決問(wèn)題。但由于讓 UI 線程在處理一個(gè)事件之前簡(jiǎn)單地對(duì)其進(jìn)行檢查以確定是否有用也比較簡(jiǎn)單,所以使用該方法碰到的問(wèn)題可能會(huì)更少。
請(qǐng)查看“代碼下載”(本文頂部的鏈接)中的 AsyncUtils,它是一個(gè)有用的基類,可為基于輔助線程的操作提供取消功能。圖 9 顯示了一個(gè)派生類,它實(shí)現(xiàn)了支持取消的遞歸目錄搜索。這些類闡明了一些有趣的技術(shù)。它們都使用 C# 事件語(yǔ)法來(lái)提供通知。該基類將公開(kāi)一些在操作成功完成、取消和拋出異常時(shí)出現(xiàn)的事件。派生類對(duì)此進(jìn)行了擴(kuò)充,它們將公開(kāi)通知客戶端搜索匹配、進(jìn)度以及顯示當(dāng)前正在搜索哪個(gè)目錄的事件。這些事件始終在 UI 線程中傳遞。實(shí)際上,這些類并未限制為 Control 類 — 它們可以將事件傳遞給實(shí)現(xiàn) ISynchronizeInvoke 接口的任何類。圖 10 是一個(gè)示例 Windows 窗體應(yīng)用程序,它為 Search 類提供一個(gè)用戶界面。它允許取消搜索并顯示進(jìn)度和結(jié)果。
程序關(guān)閉
某些情況下,可以采用“啟動(dòng)后就不管”的異步操作,而不需要其他復(fù)雜要求來(lái)使操作可取消。然而,即使用戶界面不要求取消,有可能還是需要實(shí)現(xiàn)這項(xiàng)功能以使程序可以徹底關(guān)閉。
當(dāng)應(yīng)用程序退出時(shí),如果由線程池創(chuàng)建的輔助線程還在運(yùn)行,則這些線程會(huì)被終止。終止是簡(jiǎn)單粗暴的操作,因?yàn)殛P(guān)閉甚至?xí)@開(kāi)任何還起作用的 Finally 塊。如果異步操作執(zhí)行的某些工作不應(yīng)該以這種方式被打斷,則必須確保在關(guān)閉之前這樣的操作已經(jīng)完成。此類操作可能包括對(duì)文件執(zhí)行的寫入操作,但由于突然中斷后,文件可能被破壞。
一種解決辦法是創(chuàng)建自己的線程,而不用來(lái)自輔助線程池的線程,這樣就自然會(huì)避開(kāi)使用異步委托調(diào)用。這樣,即使主線程關(guān)閉,應(yīng)用程序也會(huì)等到您的線程退出后才終止。System.Threading.Thread 類有一個(gè) IsBackground 屬性可以控制這種行為。它默認(rèn)為 false,這種情況下,CLR 會(huì)等到所有非背景線程都退出后才正常終止應(yīng)用程序。然而,這會(huì)帶來(lái)另一個(gè)問(wèn)題,因?yàn)閼?yīng)用程序掛起時(shí)間可能會(huì)比您預(yù)期的長(zhǎng)。窗口都關(guān)閉了,但進(jìn)程仍在運(yùn)行。這也許不是個(gè)問(wèn)題。如果應(yīng)用程序只是因?yàn)橐M(jìn)行一些清理工作才比正常情況掛起更長(zhǎng)時(shí)間,那沒(méi)問(wèn)題。另一方面,如果應(yīng)用程序在用戶界面關(guān)閉后還掛起幾分鐘甚至幾小時(shí),那就不可接受了。例如,如果它仍然保持某些文件打開(kāi),則可能妨礙用戶稍后重啟該應(yīng)用程序。
最佳方法是,如果可能,通常應(yīng)該編寫自己的異步操作以便可以將其迅速取消,并在關(guān)閉應(yīng)用程序之前等待所有未完成的操作完成。這意味著您可以繼續(xù)使用異步委托,同時(shí)又能確保關(guān)閉操作徹底且及時(shí)。
錯(cuò)誤處理
在輔助線程中出現(xiàn)的錯(cuò)誤一般可以通過(guò)觸發(fā) UI 線程中的事件來(lái)處理,這樣錯(cuò)誤處理方式就和完成及進(jìn)程更新方式完全一樣。因?yàn)楹茈y在輔助線程上進(jìn)行錯(cuò)誤恢復(fù),所以最簡(jiǎn)單的策略就是讓所有錯(cuò)誤都為致命錯(cuò)誤。錯(cuò)誤恢復(fù)的最佳策略是使操作完全失敗,并在 UI 線程上執(zhí)行重試邏輯。如果需要用戶干涉來(lái)修復(fù)造成錯(cuò)誤的問(wèn)題,簡(jiǎn)單的做法是給出恰當(dāng)?shù)奶崾尽?/p>
AsyncUtils 類處理錯(cuò)誤以及取消。如果操作拋出異常,該基類就會(huì)捕捉到,并通過(guò) Failed 事件將異常傳遞給 UI。
小結(jié)
謹(jǐn)慎地使用多線程代碼可以使 UI 在執(zhí)行耗時(shí)較長(zhǎng)的任務(wù)時(shí)不會(huì)停止響應(yīng),從而顯著提高應(yīng)用程序的反應(yīng)速度。異步委托調(diào)用是將執(zhí)行速度緩慢的代碼從 UI 線程遷移出來(lái),從而避免此類間歇性無(wú)響應(yīng)的最簡(jiǎn)單方式。
Windows Forms Control 體系結(jié)構(gòu)基本上是單線程,但它提供了實(shí)用程序以將來(lái)自輔助線程的調(diào)用封送返回至 UI 線程。處理來(lái)自輔助線程的通知(不管是成功、失敗還是正在進(jìn)行的指示)的最簡(jiǎn)單策略是,以對(duì)待來(lái)自常規(guī)控件的事件(如鼠標(biāo)單擊或鍵盤輸入)的方式對(duì)待它們。這樣可以避免在 UI 代碼中引入新的問(wèn)題,同時(shí)通信的單向性也不容易導(dǎo)致出現(xiàn)死鎖。
有時(shí)需要讓 UI 向一個(gè)正在處理的操作發(fā)送消息。其中最常見(jiàn)的是取消一個(gè)操作。通過(guò)建立一個(gè)表示正在進(jìn)行的調(diào)用的對(duì)象并維護(hù)由輔助線程定期檢查的取消標(biāo)記可實(shí)現(xiàn)這一目的。如果用戶界面線程需要等待取消被認(rèn)可(因?yàn)橛脩粜枰拦ぷ饕汛_實(shí)終止,或者要求徹底退出程序),實(shí)現(xiàn)起來(lái)會(huì)有些復(fù)雜,但所提供的示例代碼中包含了一個(gè)將所有復(fù)雜性封裝在內(nèi)的基類。派生類只需要執(zhí)行一些必要的工作、周期性測(cè)試取消,以及要是因?yàn)槿∠?qǐng)求而停止工作,就將結(jié)果通知基類。
from: http://dotnet.csdn.net/n/20060726/92986.html