通過多線程爲基於 .NET 的應用程序實現響應迅速的用戶

摘要

如果應用程序在控制用戶界面的線程上執行非 UI 處理,則會使應用程序的運行顯得緩慢而遲鈍,讓用戶難以忍受。但是長期以來,編寫適用於 Windows 的多線程應用程序只限於 C++ 開發人員。現在有了 .NET Framework,您就可以充分利用 C# 中的多線程來控制程序中的指令流,並使 UI 線程獨立出來以便用戶界面能夠迅速響應。本文將向您介紹如何實現這一目標。此外,本文還將討論多線程的缺陷並提供一個框架來保護併發線程執行的安全。

 

內容

爲什麼選擇多線程? 爲什麼選擇多線程?
異步委託調用 異步委託調用
線程和控件 線程和控件
在正確的線程中調用控件 在正確的線程中調用控件
包裝 Control.Invoke 包裝 Control.Invoke
鎖定 鎖定
死鎖 死鎖
使其簡單 使其簡單
取消 取消
程序關閉 程序關閉
錯誤處理 錯誤處理
小結 小結

用戶不喜歡反應慢的程序。程序反應越慢,就越沒有用戶會喜歡它。在執行耗時較長的操作時,使用多線程是明智之舉,它可以提高程序 UI 的響應速度,使得一切運行顯得更爲快速。在 Windows 中進行多線程編程曾經是 C++ 開發人員的專屬特權,但是現在,可以使用所有兼容 Microsoft .NET 的語言來編寫,其中包括 Visual Basic.NET。不過,Windows 窗體對線程的使用強加了一些重要限制。本文將對這些限制進行闡釋,並說明如何利用它們來提供快速、高質量的 UI 體驗,即使是程序要執行的任務本身速度就較慢。

 

爲什麼選擇多線程?


 

多線程程序要比單線程程序更難於編寫,並且不加選擇地使用線程也是導致難以找到細小錯誤的重要原因。這就自然會引出兩個問題:爲什麼不堅持編寫單線程代碼?如果必須使用多線程,如何才能避免缺陷呢?本文的大部分篇幅都是在回答第二個問題,但首先我要來解釋一下爲什麼確實需要多線程。

多線程處理可以使您能夠通過確保程序“永不睡眠”從而保持 UI 的快速響應。大部分程序都有不響應用戶的時候:它們正忙於爲您執行某些操作以便響應進一步的請求。也許最廣爲人知的例子就是出現在“打開文件”對話框頂部的組合框。如果在展開該組合框時,CD-ROM驅動器裏恰好有一張光盤,則計算機通常會在顯示列表之前先讀取光盤。這可能需要幾秒鐘的時間,在此期間,程序既不響應任何輸入,也不允許取消該操作,尤其是在確實並不打算使用光驅的時候,這種情況會讓人無法忍受。

執行這種操作期間 UI 凍結的原因在於,UI 是個單線程程序,單線程不可能在等待 CD-ROM驅動器讀取操作的同時處理用戶輸入,如圖 1 所示。“打開文件”對話框會調用某些阻塞 (blocking) API 來確定 CD-ROM 的標題。阻塞 API 在未完成自己的工作之前不會返回,因此這期間它會阻止線程做其他事情。

 


圖 1 單線程


 

在多線程下,像這樣耗時較長的任務就可以在其自己的線程中運行,這些線程通常稱爲輔助線程。因爲只有輔助線程受到阻止,所以阻塞操作不再導致用戶界面凍結,如圖 2 所示。應用程序的主線程可以繼續處理用戶的鼠標和鍵盤輸入的同時,受阻的另一個線程將等待 CD-ROM 讀取,或執行輔助線程可能做的任何操作。

 


圖 2 多線程


 

其基本原則是,負責響應用戶輸入和保持用戶界面爲最新的線程(通常稱爲 UI 線程)不應該用於執行任何耗時較長的操作。慣常做法是,任何耗時超過 30ms 的操作都要考慮從 UI 線程中移除。這似乎有些誇張,因爲 30ms 對於大多數人而言只不過是他們可以感覺到的最短的瞬間停頓,實際上該停頓略短於電影屏幕中顯示的連續幀之間的間隔。

如果鼠標單擊和相應的 UI 提示(例如,重新繪製按鈕)之間的延遲超過 30ms,那麼操作與顯示之間就會稍顯不連貫,並因此產生如同影片斷幀那樣令人心煩的感覺。爲了達到完全高質量的響應效果,上限必須是 30ms。另一方面,如果您確實不介意感覺稍顯不連貫,但也不想因爲停頓過長而激怒用戶,則可按照通常用戶所能容忍的限度將該間隔設爲 100ms。

這意味着如果想讓用戶界面保持響應迅速,則任何阻塞操作都應該在輔助線程中執行 — 不管是機械等待某事發生(例如,等待 CD-ROM 啓動或者硬盤定位數據),還是等待來自網絡的響應。

 

異步委託調用


 

在輔助線程中運行代碼的最簡單方式是使用異步委託調用(所有委託都提供該功能)。委託通常是以同步方式進行調用,即,在調用委託時,只有包裝方法返回後該調用纔會返回。要以異步方式調用委託,請調用 BeginInvoke 方法,這樣會對該方法排隊以在系統線程池的線程中運行。調用線程會立即返回,而不用等待該方法完成。這比較適合於 UI 程序,因爲可以用它來啓動耗時較長的作業,而不會使用戶界面反應變慢。

例如,在以下代碼中,System.Windows.Forms.MethodInvoker 類型是一個系統定義的委託,用於調用不帶參數的方法。

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();
}

如果想要傳遞參數,可以選擇合適的系統定義的委託類型,或者自己來定義委託。MethodInvoker 委託並沒有什麼神奇之處。和其他委託一樣,調用 BeginInvoke 會使該方法在系統線程池的線程中運行,而不會阻塞 UI 線程以便其可執行其他操作。對於以上情況,該方法不返回數據,所以啓動它後就不用再去管它。如果您需要該方法返回的結果,則 BeginInvoke 的返回值很重要,並且您可能不傳遞空參數。然而,對於大多數 UI 應用程序而言,這種“啓動後就不管”的風格是最有效的,稍後會對原因進行簡要討論。您應該注意到,BeginInvoke 將返回一個 IAsyncResult。這可以和委託的 EndInvoke 方法一起使用,以在該方法調用完畢後檢索調用結果。

還有其他一些可用於在另外的線程上運行方法的技術,例如,直接使用線程池 API 或者創建自己的線程。然而,對於大多數用戶界面應用程序而言,有異步委託調用就足夠了。採用這種技術不僅編碼容易,而且還可以避免創建並非必需的線程,因爲可以利用線程池中的共享線程來提高應用程序的整體性能。

 

線程和控件


 

Windows 窗體體系結構對線程使用制定了嚴格的規則。如果只是編寫單線程應用程序,則沒必要知道這些規則,這是因爲單線程的代碼不可能違反這些規則。然而,一旦採用多線程,就需要理解 Windows 窗體中最重要的一條線程規則:除了極少數的例外情況,否則都不要在它的創建線程以外的線程中使用控件的任何成員。

本規則的例外情況有文檔說明,但這樣的情況非常少。這適用於其類派生自 System.Windows.Forms.Control 的任何對象,其中幾乎包括 UI 中的所有元素。所有的 UI 元素(包括表單本身)都是從 Control 類派生的對象。此外,這條規則的結果是一個被包含的控件(如,包含在一個表單中的按鈕)必須與包含它控件位處於同一個線程中。也就是說,一個窗口中的所有控件屬於同一個 UI 線程。實際中,大部分 Windows 窗體應用程序最終都只有一個線程,所有 UI 活動都發生在這個線程上。這個線程通常稱爲 UI 線程。這意味着您不能調用用戶界面中任意控件上的任何方法,除非在該方法的文檔說明中指出可以調用。該規則的例外情況(總有文檔記錄)非常少而且它們之間關係也不大。請注意,以下代碼是非法的:

// 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 版本中嘗試運行這段代碼,也許會僥倖運行成功,或者初看起來是如此。這就是多線程錯誤中的主要問題,即它們並不會立即顯現出來。甚至當出現了一些錯誤時,在第一次演示程序之前一切看起來也都很正常。但不要搞錯 — 我剛纔顯示的這段代碼明顯違反了規則,並且可以預見,任何抱希望於“試運行時良好,應該就沒有問題”的人在即將到來的調試期是會付出沉重代價的。

要注意,在明確創建線程之前會發生這樣的問題。使用委託的異步調用實用程序(調用它的 BeginInvoke 方法)的任何代碼都可能出現同樣的問題。委託提供了一個非常吸引人的解決方案來處理 UI 應用程序中緩慢、阻塞的操作,因爲這些委託能使您輕鬆地讓此種操作運行在 UI 線程外而無需自己創建新線程。但是由於以異步委託調用方式運行的代碼在一個來自線程池的線程中運行,所以它不能訪問任何 UI 元素。上述限制也適用於線程池中的線程和手動創建的輔助線程。

 

在正確的線程中調用控件


 

有關控件的限制看起來似乎對多線程編程非常不利。如果在輔助線程中運行的某個緩慢操作不對 UI 產生任何影響,用戶如何知道它的進行情況呢?至少,用戶如何知道工作何時完成或者是否出現錯誤?幸運的是,雖然此限制的存在會造成不便,但並非不可逾越。有多種方式可以從輔助線程獲取消息,並將該消息傳遞給 UI 線程。理論上講,可以使用低級的同步原理和池化技術來生成自己的機制,但幸運的是,因爲有一個以 Control 類的 Invoke 方法形式存在的解決方案,所以不需要藉助於如此低級的工作方式。

Invoke 方法是 Control 類中少數幾個有文檔記錄的線程規則例外之一:它始終可以對來自任何線程的 Control 進行 Invoke 調用。Invoke 方法本身只是簡單地攜帶委託以及可選的參數列表,並在 UI 線程中爲您調用委託,而不考慮 Invoke 調用是由哪個線程發出的。實際上,爲控件獲取任何方法以在正確的線程上運行非常簡單。但應該注意,只有在 UI 線程當前未受到阻塞時,這種機制纔有效 — 調用只有在 UI 線程準備處理用戶輸入時才能通過。從不阻塞 UI 線程還有另一個好理由。Invoke 方法會進行測試以瞭解調用線程是否就是 UI 線程。如果是,它就直接調用委託。否則,它將安排線程切換,並在 UI 線程上調用委託。無論是哪種情況,委託所包裝的方法都會在 UI 線程中運行,並且只有當該方法完成時,Invoke 纔會返回。

Control 類也支持異步版本的 Invoke,它會立即返回並安排該方法以便在將來某一時間在 UI 線程上運行。這稱爲 BeginInvoke,它與異步委託調用很相似,與委託的明顯區別在於,該調用以異步方式在線程池的某個線程上運行,然而在此處,它以異步方式在 UI 線程上運行。實際上,Control 的 Invoke、BeginInvoke 和 EndInvoke 方法,以及 InvokeRequired 屬性都是 ISynchronizeInvoke 接口的成員。該接口可由任何需要控制其事件傳遞方式的類實現。

由於 BeginInvoke 不容易造成死鎖,所以儘可能多用該方法;而少用 Invoke 方法。因爲 Invoke 是同步的,所以它會阻塞輔助線程,直到 UI 線程可用。但是如果 UI 線程正在等待輔助線程執行某操作,情況會怎樣呢?應用程序會死鎖。BeginInvoke 從不等待 UI 線程,因而可以避免這種情況。

現在,我要回顧一下前面所展示的代碼片段的合法版本。首先,必須將一個委託傳遞給 Control 的 BeginInvoke 方法,以便可以在 UI 線程中運行對線程敏感的代碼。這意味着應該將該代碼放在它自己的方法中。一旦輔助線程完成緩慢的工作後,它就會調用 Label 中的 BeginInvoke,以便在其 UI 線程上運行某段代碼。通過這樣,它可以更新用戶界面。

 

包裝 Control.Invoke


 

雖然代碼解決了這個問題,但它相當繁瑣。如果輔助線程希望在結束時提供更多的反饋信息,而不是簡單地給出“Finished!”消息,則 BeginInvoke 過於複雜的使用方法會令人生畏。爲了傳達其他消息,例如“正在處理”、“一切順利”等等,需要設法向 UpdateUI 函數傳遞一個參數。可能還需要添加一個進度欄以提高反饋能力。這麼多次調用 BeginInvoke 可能導致輔助線程受該代碼支配。這樣不僅會造成不便,而且考慮到輔助線程與 UI 的協調性,這樣設計也不好。對這些進行分析之後,我們認爲包裝函數可以解決這兩個問題。

ShowProgress 方法對將調用引向正確線程的工作進行封裝。這意味着輔助線程代碼不再擔心需要過多關注 UI 細節,而只要定期調用 ShowProgress 即可。請注意,我定義了自己的方法,該方法違背了“必須在 UI 線程上進行調用”這一規則,因爲它進而只調用不受該規則約束的其他方法。這種技術會引出一個較爲常見的話題:爲什麼不在控件上編寫公共方法呢(這些方法記錄爲 UI 線程規則的例外)?

剛好 Control 類爲這樣的方法提供了一個有用的工具。如果我提供一個設計爲可從任何線程調用的公共方法,則完全有可能某人會從 UI 線程調用這個方法。在這種情況下,沒必要調用 BeginInvoke,因爲我已經處於正確的線程中。調用 Invoke 完全是浪費時間和資源,不如直接調用適當的方法。爲了避免這種情況,Control 類將公開一個稱爲 InvokeRequired 的屬性。這是“只限 UI 線程”規則的另一個例外。它可從任何線程讀取,如果調用線程是 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 現在可以記錄爲可從任何線程調用的公共方法。這並沒有消除複雜性 — 執行 BeginInvoke 的代碼依然存在,它還佔有一席之地。不幸的是,沒有簡單的方法可以完全擺脫它。

 

鎖定


 

任何併發系統都必須面對這樣的事實,即,兩個線程可能同時試圖使用同一塊數據。有時這並不是問題 — 如果多個線程在同一時間試圖讀取某個對象中的某個字段,則不會有問題。然而,如果有線程想要修改該數據,就會出現問題。如果線程在讀取時剛好另一個線程正在寫入,則讀取線程有可能會看到虛假值。如果兩個線程在同一時間、在同一個位置執行寫入操作,則在同步寫入操作發生之後,所有從該位置讀取數據的線程就有可能看到一堆垃圾數據。雖然這種行爲只在特定情況下才會發生,讀取操作甚至不會與寫入操作發生衝突,但是數據可以是兩次寫入結果的混加,並保持錯誤結果直到下一次寫入值爲止。爲了避免這種問題,必須採取措施來確保一次只有一個線程可以讀取或寫入某個對象的狀態。

防止這些問題出現所採用的方式是,使用運行時的鎖定功能。C# 可以讓您利用這些功能、通過鎖定關鍵字來保護代碼(Visual Basic 也有類似構造,稱爲 SyncLock)。規則是,任何想要在多個線程中調用其方法的對象在每次訪問其字段時(不管是讀取還是寫入)都應該使用鎖定構造。

鎖定構造的工作方式是:公共語言運行庫 (CLR) 中的每個對象都有一個與之相關的鎖,任何線程均可獲得該鎖,但每次只能有一個線程擁有它。如果某個線程試圖獲取另一個線程已經擁有的鎖,那麼它必須等待,直到擁有該鎖的線程將鎖釋放爲止。C# 鎖定構造會獲取該對象鎖(如果需要,要先等待另一個線程利用它完成操作),並保留到大括號中的代碼退出爲止。如果執行語句運行到塊結尾,該鎖就會被釋放,並從塊中部返回,或者拋出在塊中沒有捕捉到的異常。

請注意,MoveBy 方法中的邏輯受同樣的鎖語句保護。當所做的修改比簡單的讀取或寫入更復雜時,整個過程必須由單獨的鎖語句保護。這也適用於對多個字段進行更新 — 在對象處於一致狀態之前,一定不能釋放該鎖。如果該鎖在更新狀態的過程中釋放,則其他線程也許能夠獲得它並看到不一致狀態。如果您已經擁有一個鎖,並調用一個試圖獲取該鎖的方法,則不會導致問題出現,因爲單獨線程允許多次獲得同一個鎖。對於需要鎖定以保護對字段的低級訪問和對字段執行的高級操作的代碼,這非常重要。MoveBy 使用 Position 屬性,它們同時獲得該鎖。只有最外面的鎖阻塞完成後,該鎖纔會恰當地釋放。

對於需要鎖定的代碼,必須嚴格進行鎖定。稍有疏漏,便會功虧一簣。如果一個方法在沒有獲取對象鎖的情況下修改狀態,則其餘的代碼在使用它之前即使小心地鎖定對象也是徒勞。同樣,如果一個線程在沒有事先獲得鎖的情況下試圖讀取狀態,則它可能讀取到不正確的值。運行時無法進行檢查來確保多線程代碼正常運行。

 

死鎖


 

鎖是確保多線程代碼正常運行的基本條件,即使它們本身也會引入新的風險。在另一個線程上運行代碼的最簡單方式是,使用異步委託調用。

如果曾經調用過 Foo 的 CallBar 方法,則這段代碼會慢慢停止運行。CallBar 方法將獲得 Foo 對象上的鎖,並直到 BarWork 返回後才釋放它。然後,BarWork 使用異步委託調用,在某個線程池線程中調用 Foo 對象的 FooWork 方法。接下來,它會在調用委託的 EndInvoke 方法前執行一些其他操作。EndInvoke 將等待輔助線程完成,但輔助線程卻被阻塞在 FooWork 中。它也試圖獲取 Foo 對象的鎖,但鎖已被 CallBar 方法持有。所以,FooWork 會等待 CallBar 釋放鎖,但 CallBar 也在等待 BarWork 返回。不幸的是,BarWork 將等待 FooWork 完成,所以 FooWork 必須先完成,它才能開始。結果,沒有線程能夠進行下去。

這就是一個死鎖的例子,其中有兩個或更多線程都被阻塞以等待對方進行。這裏的情形和標準死鎖情況還是有些不同,後者通常包括兩個鎖。這表明如果有某個因果性(過程調用鏈)超出線程界限,就會發生死鎖,即使只包括一個鎖!Control.Invoke 是一種跨線程調用過程的方法,這是個不爭的重要事實。BeginInvoke 不會遇到這樣的問題,因爲它並不會使因果性跨線程。實際上,它會在某個線程池線程中啓動一個全新的因果性,以允許原有的那個獨立進行。然而,如果保留 BeginInvoke 返回的 IAsyncResult,並用它調用 EndInvoke,則又會出現問題,因爲 EndInvoke 實際上已將兩個因果性合二爲一。避免這種情況的最簡單方法是,當持有一個對象鎖時,不要等待跨線程調用完成。要確保這一點,應該避免在鎖語句中調用 Invoke 或 EndInvoke。其結果是,當持有一個對象鎖時,將無需等待其他線程完成某操作。要堅持這個規則,說起來容易做起來難。

在檢查代碼的 BarWork 時,它是否在鎖語句的作用域內並不明顯,因爲在該方法中並沒有鎖語句。出現這個問題的唯一原因是 BarWork 調用自 Foo.CallBar 方法的鎖語句。這意味着只有確保正在調用的函數並不擁有鎖時,調用 Control.Invoke 或 EndIn-voke 纔是安全的。對於非私有方法而言,確保這一點並不容易,所以最佳規則是,根本不調用 Control.Invoke 和 EndInvoke。這就是爲什麼“啓動後就不管”的編程風格更可取的原因,也是爲什麼 Control.BeginInvoke 解決方案通常比 Control.Invoke 解決方案好的原因。

有時候除了破壞規則別無選擇,這種情況下就需要仔細嚴格地分析。但只要可能,在持有鎖時就應該避免阻塞,因爲如果不這樣,死鎖就難以消除。

 

使其簡單


 

如何既從多線程獲益最大,又不會遇到困擾併發代碼的棘手錯誤呢?如果提高的 UI 響應速度僅僅是使程序時常崩潰,那麼很難說是改善了用戶體驗。大部分在多線程代碼中普遍存在的問題都是由要一次運行多個操作的固有複雜性導致的,這是因爲大多數人更善於思考連續過程而非併發過程。通常,最好的解決方案是使事情儘可能簡單。

UI 代碼的性質是:它從外部資源接收事件,如用戶輸入。它會在事件發生時對其進行處理,但卻將大部分時間花在了等待事件的發生。如果可以構造輔助線程和 UI 線程之間的通信,使其適合該模型,則未必會遇到這麼多問題,因爲不會再有新的東西引入。我是這樣使事情簡單化的:將輔助線程視爲另一個異步事件源。如同 Button 控件傳遞諸如 Click 和 MouseEnter 這樣的事件,可以將輔助線程視爲傳遞事件(如 ProgressUpdate 和 WorkComplete)的某物。只是簡單地將這看作一種類比,還是真正將輔助對象封裝在一個類中,並按這種方式公開適當的事件,這完全取決於您。後一種選擇可能需要更多的代碼,但會使用戶界面代碼看起來更加統一。不管哪種情況,都需要 Control.BeginInvoke 在正確的線程上傳遞這些事件。

對於輔助線程,最簡單的方式是將代碼編寫爲正常順序的代碼塊。但如果想要使用剛纔介紹的“將輔助線程作爲事件源”模型,那又該如何呢?這個模型非常適用,但它對該代碼與用戶界面的交互提出了限制:這個線程只能向 UI 發送消息,並不能向它提出請求。

例如,讓輔助線程中途發起對話以請求完成結果需要的信息將非常困難。如果確實需要這樣做,也最好是在輔助線程中發起這樣的對話,而不要在主 UI 線程中發起。該約束是有利的,因爲它將確保有一個非常簡單且適用於兩線程間通信的模型 — 在這裏簡單是成功的關鍵。這種開發風格的優勢在於,在等待另一個線程時,不會出現線程阻塞。這是避免死鎖的有效策略。

使用異步委託調用以在輔助線程中執行可能較慢的操作(讀取某個目錄的內容),然後將結果顯示在 UI 上。它還不至於使用高級事件語法,但是該調用確實是以與處理事件(如單擊)非常相似的方式來處理完整的輔助代碼。

 

取消


 

前面示例所帶來的問題是,要取消操作只能通過退出整個應用程序實現。雖然在讀取某個目錄時 UI 仍然保持迅速響應,但由於在當前操作完成之前程序將禁用相關按鈕,所以用戶無法查看另一個目錄。如果試圖讀取的目錄是在一臺剛好沒有響應的遠程機器上,這就很不幸,因爲這樣的操作需要很長時間纔會超時。

要取消一個操作也比較困難,儘管這取決於怎樣纔算取消。一種可能的理解是“停止等待這個操作完成,並繼續另一個操作。”這實際上是拋棄進行中的操作,並忽略最終完成時可能產生的後果。對於當前示例,這是最好的選擇,因爲當前正在處理的操作(讀取目錄內容)是通過調用一個阻塞 API 來執行的,取消它沒有關係。但即使是如此鬆散的“假取消”也需要進行大量工作。如果決定啓動新的讀取操作而不等待原來的操作完成,則無法知道下一個接收到的通知是來自這兩個未處理請求中的哪一個。

支持取消在輔助線程中運行的請求的唯一方式是,提供與每個請求相關的某種調用對象。最簡單的做法是將它作爲一個 Cookie,由輔助線程在每次通知時傳遞,允許 UI 線程將事件與請求相關聯。通過簡單的身份比較,UI 代碼就可以知道事件是來自當前請求,還是來自早已廢棄的請求。

如果簡單拋棄就行,那固然很好,不過您可能想要做得更好。如果輔助線程執行的是進行一連串阻塞操作的複雜操作,那麼您可能希望輔助線程在最早的時機停止。否則,它可能會繼續幾分鐘的無用操作。在這種情況下,調用對象需要做的就不止是作爲一個被動 Cookie。它至少還需要維護一個標記,指明請求是否被取消。UI 可以隨時設置這個標記,而輔助線程在執行時將定期測試這個標記,以確定是否需要放棄當前工作。

對於這個方案,還需要做出幾個決定:如果 UI 取消了操作,它是否要等待直到輔助線程注意到這次取消?如果不等待,就需要考慮一個爭用條件:有可能 UI 線程會取消該操作,但在設置控制標記之前輔助線程已經決定傳遞通知了。因爲 UI 線程決定不等待,直到輔助線程處理取消,所以 UI 線程有可能會繼續從輔助線程接收通知。如果輔助線程使用 BeginInvoke 異步傳遞通知,則 UI 甚至有可能收到多個通知。UI 線程也可以始終按與“廢棄”做法相同的方式處理通知 — 檢查調用對象的標識並忽略它不再關心的操作通知。或者,在調用對象中進行鎖定並決不從輔助線程調用 BeginInvoke 以解決問題。但由於讓 UI 線程在處理一個事件之前簡單地對其進行檢查以確定是否有用也比較簡單,所以使用該方法碰到的問題可能會更少。

請查看“代碼下載”(本文頂部的鏈接)中的 AsyncUtils,它是一個有用的基類,可爲基於輔助線程的操作提供取消功能。圖 9 顯示了一個派生類,它實現了支持取消的遞歸目錄搜索。這些類闡明瞭一些有趣的技術。它們都使用 C# 事件語法來提供通知。該基類將公開一些在操作成功完成、取消和拋出異常時出現的事件。派生類對此進行了擴充,它們將公開通知客戶端搜索匹配、進度以及顯示當前正在搜索哪個目錄的事件。這些事件始終在 UI 線程中傳遞。實際上,這些類並未限制爲 Control 類 — 它們可以將事件傳遞給實現 ISynchronizeInvoke 接口的任何類。圖 10是一個示例 Windows 窗體應用程序,它爲 Search 類提供一個用戶界面。它允許取消搜索並顯示進度和結果。

 

程序關閉


 

某些情況下,可以採用“啓動後就不管”的異步操作,而不需要其他複雜要求來使操作可取消。然而,即使用戶界面不要求取消,有可能還是需要實現這項功能以使程序可以徹底關閉。

當應用程序退出時,如果由線程池創建的輔助線程還在運行,則這些線程會被終止。終止是簡單粗暴的操作,因爲關閉甚至會繞開任何還起作用的 Finally 塊。如果異步操作執行的某些工作不應該以這種方式被打斷,則必須確保在關閉之前這樣的操作已經完成。此類操作可能包括對文件執行的寫入操作,但由於突然中斷後,文件可能被破壞。

一種解決辦法是創建自己的線程,而不用來自輔助線程池的線程,這樣就自然會避開使用異步委託調用。這樣,即使主線程關閉,應用程序也會等到您的線程退出後才終止。System.Threading.Thread 類有一個 IsBackground 屬性可以控制這種行爲。它默認爲 false,這種情況下,CLR 會等到所有非背景線程都退出後才正常終止應用程序。然而,這會帶來另一個問題,因爲應用程序掛起時間可能會比您預期的長。窗口都關閉了,但進程仍在運行。這也許不是個問題。如果應用程序只是因爲要進行一些清理工作才比正常情況掛起更長時間,那沒問題。另一方面,如果應用程序在用戶界面關閉後還掛起幾分鐘甚至幾小時,那就不可接受了。例如,如果它仍然保持某些文件打開,則可能妨礙用戶稍後重啓該應用程序。

最佳方法是,如果可能,通常應該編寫自己的異步操作以便可以將其迅速取消,並在關閉應用程序之前等待所有未完成的操作完成。這意味着您可以繼續使用異步委託,同時又能確保關閉操作徹底且及時。

 

錯誤處理


 

在輔助線程中出現的錯誤一般可以通過觸發 UI 線程中的事件來處理,這樣錯誤處理方式就和完成及進程更新方式完全一樣。因爲很難在輔助線程上進行錯誤恢復,所以最簡單的策略就是讓所有錯誤都爲致命錯誤。錯誤恢復的最佳策略是使操作完全失敗,並在 UI 線程上執行重試邏輯。如果需要用戶干涉來修復造成錯誤的問題,簡單的做法是給出恰當的提示。

AsyncUtils 類處理錯誤以及取消。如果操作拋出異常,該基類就會捕捉到,並通過 Failed 事件將異常傳遞給 UI。

 

小結


 

謹慎地使用多線程代碼可以使 UI 在執行耗時較長的任務時不會停止響應,從而顯著提高應用程序的反應速度。異步委託調用是將執行速度緩慢的代碼從 UI 線程遷移出來,從而避免此類間歇性無響應的最簡單方式。

Windows Forms Control 體系結構基本上是單線程,但它提供了實用程序以將來自輔助線程的調用封送返回至 UI 線程。處理來自輔助線程的通知(不管是成功、失敗還是正在進行的指示)的最簡單策略是,以對待來自常規控件的事件(如鼠標單擊或鍵盤輸入)的方式對待它們。這樣可以避免在 UI 代碼中引入新的問題,同時通信的單向性也不容易導致出現死鎖。

有時需要讓 UI 向一個正在處理的操作發送消息。其中最常見的是取消一個操作。通過建立一個表示正在進行的調用的對象並維護由輔助線程定期檢查的取消標記可實現這一目的。如果用戶界面線程需要等待取消被認可(因爲用戶需要知道工作已確實終止,或者要求徹底退出程序),實現起來會有些複雜,但所提供的示例代碼中包含了一個將所有複雜性封裝在內的基類。派生類只需要執行一些必要的工作、週期性測試取消,以及要是因爲取消請求而停止工作,就將結果通知基類。

 

 

 

 

原文地址http://www.microsoft.com/china/MSDN/library/enterprisedevelopment/softwaredev/misMultithreading.mspx?mfr=true

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章