在Windows窗體中,解決方法是首先檢查Control.InvokeRequired屬性,若Control. InvokeRequired屬性為true,那么調用ControlInvoke()。在WPF中,可以使用System.Windows.Threading.Dispatcher中的Invoke()和BeginInvoke()方法。這兩種情況中都發生了很多事情,你也同樣有別的選擇。這兩個API為你做了很多事情,不過在某些情況下仍有可能會失敗。因為這些方法將用來處理跨線程調用,因此若是沒有正確使用(甚至是正確使用但沒有完全理解其行為)的話,也有可能會導致競爭條件的出現。
無論是Windows窗體還是WPF,問題的成因都很簡單:Windows控件使用的是組件對象模型(Component Object Model,COM)單線程單元(Single-threaded Apartment,STA)模型,因為其底層的控件是單元線程(apartment-threaded)的。此外,很多控件都用消息泵(message pump)來完成操作。因此,這種模型就需要所有調用該控件的方法都和創建該控件的方法位于同一個線程上。Invoke、BeginInvoke和EndInvoke調度方法都需要在正確的線程上調用。兩種模型的底層代碼非常相似,因此這里將以Windows窗體的API為例。不過當調用方法有所區別時,我將同時給出兩個版本。其具體的做法非常復雜,但仍需要深入了解。
首先,我們來看一段簡單的泛型代碼,能夠讓你在遇到此種情況時得到一定的簡化。匿名委托讓僅在一處使用的小方法更加易于編寫。不過,匿名委托卻并不能與接受System.Delegate類型的方法(例如Control.Invoke)配合使用。因此,你需要首先定義一個非抽象的委托類型,隨后在使用Control. Invoke時傳入。
private void OnTick(object sender, EventArgs e)
{
??? Action action = () =>
??????? toolStripStatusLabel1.Text =
??????????? DateTime.Now.ToLongTimeString();
??? if (this.InvokeRequired)
??????? this.Invoke(action);
??? else
??????? action();
}
C# 3.0大大簡化了上述代碼。System.Core.Action委托定義了一類專門的委托類型,用來表示不接受任何參數并返回void的方法。lambda表達式也能夠更加簡單地定義方法體。但若你仍舊需要支持C# 2.0,那么需要編寫如下的代碼。
delegate void Invoker();
private void OnTick20(object sender, EventArgs e)
{
??? Action action = delegate()
??? {
??????? toolStripStatusLabel1.Text =
??????????? DateTime.Now.ToLongTimeString();
??? };
??? if (this.InvokeRequired)
??????? this.Invoke(action);
??? else
??????? action();
}
在WPF中,則需要使用控件上的System.Threading.Dispatcher對象來執行封送操作。
private void UpdateTime()
{
??? Action action = () => textBlock1.Text =
??????? DateTime.Now.ToString();
??? if (System.Threading.Thread.CurrentThread !=
??????? textBlock1.Dispatcher.Thread)
??? {
??????? textBlock1.Dispatcher.Invoke
??????????? (System.Windows.Threading.DispatcherPriority.Normal,
??????????? action);
??? }
??? else
??? {
??????? action();
??? }
}
這種做法讓事件處理程序的實際邏輯變得更加模糊,讓代碼難以閱讀和維護。這種做法還需要引入一個委托定義,僅僅用來滿足方法的簽名。
使用一小段泛型代碼即可改善這種情況。下面的這個ControlExtensions靜態類所包含的泛型方法適用于調用不超過兩個參數的委托。再添加一些重載即可支持更多的參數。此外,其中的方法還可使用委托定義來調用目標方法,既可以直接調用,也可以通過Control.Invoke的封送。
public static class ControlExtensions
{
??? public static void InvokeIfNeeded(this Control ctl,
??????? Action doit)
? ??{
??????? if (ctl.InvokeRequired)
??????????? ctl.Invoke(doit);
??????? else
??????????? doit();
??? }
??? public static void InvokeIfNeeded<T>(this Control ctl,
??????? Action<T> doit, T args)
??? {
??????? if (ctl.InvokeRequired)
??????????? ctl.Invoke(doit, args);
??????? else
??????????? doit(args);
??? }
}
在多線程環境中使用InvokeIfNeeded能夠很大程度上簡化事件處理程序的代碼。
private void OnTick(object sender, EventArgs e)
{
??? this.InvokeIfNeeded(() => toolStripStatusLabel1.Text =
??????? DateTime.Now.ToLongTimeString());
}
對于WPF控件,也可以創建出一系列類似的擴展。
public static class WPFControlExtensions
{
??? public static void InvokeIfNeeded(
??????? this System.Windows.Threading.DispatcherObject ctl,
??????? Action doit,
??????? System.Windows.Threading.DispatcherPriority priority)
??? {
??????? if (System.Threading.Thread.CurrentThread !=
??????????? ctl.Dispatcher.Thread)
??????? {
??????????? ctl.Dispatcher.Invoke(priority,
??????????????? doit);
??????? }
??????? else
??????? {
??????????? doit();
??????? }
??? }
??? public static void InvokeIfNeeded<T>(
??????? this System.Windows.Threading.DispatcherObject ctl,
??????? Action<T> doit,
??????? T args,
??????? System.Windows.Threading.DispatcherPriority priority)
??? {
??????? if (System.Threading.Thread.CurrentThread !=
??????????? ctl.Dispatcher.Thread)
??????? {
??????????? ctl.Dispatcher.Invoke(priority,
??????????????? doit, args);
??????? }
??????? else
??????? {
??????????? doit(args);
??????? }
??? }
}
WPF版本沒有檢查InvokeRequired,而是檢查了當前線程的標識,并于將要進行控件交互的線程進行比較。DispatcherObject是很多WPF控件的基類,用來為WPF控件處理線程之間的分發操作。注意,在WPF中還可以指定事件處理程序的優先級。這是因為WPF應用程序使用了兩個UI線程。一個線程用來專門處理UI呈現,以便讓UI總是能夠及時呈現出動畫等效果。你可以通過指定優先級來告訴框架哪類操作對于用戶更加重要:要么是UI呈現,要么是處理某些特定的后臺事件。
這段代碼有幾個優勢。雖然使用了匿名委托定義,不過事件處理程序的核心仍位于事件處理程序中。與直接使用Control.IsInvokeRequired或ControlInvoke相比,這種做法更加易讀且易于維護。在ControlExtensions中,使用了泛型方法來檢查InvokeRequired或是比較兩個線程,這也就讓使用者從中解脫了起來。若是代碼僅在單線程應用程序中使用,那么我也不會使用這些方法。不過若是程序最終可能在多線程環境中運行,那么不如使用上面這種更加完善的處理方式。
若想支持C# 2.0,那么還要做一些額外的工作。主要在于無法使用擴展方法和lambda表達式語法。這樣,代碼將變得有些臃腫。
// 定義必要的Action:
public delegate void Action;
public delegate void Action<T>(T arg);
// 3個和4個參數的Action定義省略
public static class ControlExtensions
{
??? public static void InvokeIfNeeded(Control ctl, Action doit)
??? {
??????? if (ctl.InvokeRequired)
??????????? ctl.Invoke(doit);
??????? else
?????????? ?doit();
??? }
??? public static void InvokeIfNeeded<T>( Control ctl,
??????? Action<T> doit, T args)
??? {
??????? if (ctl.InvokeRequired)
??????????? ctl.Invoke(doit, args);
??????? else
??????????? doit(args);
??? }
}
// 其他位置:
private void OnTick20(object sender, EventArgs e)
{
??? ControlExtensions.InvokeIfNeeded(this, delegate()
??? {
??????? toolStripStatusLabel1.Text =
????????? DateTime.Now.ToLongTimeString();
??? });
}
在將這個方法應用到事件處理程序之前,我們來仔細看看InvokeRequired和Control.Invoke所做的工作。這兩個方法并非沒有什么代價,也不建議將這種模式應用到各處。Control.InvokeRequired用來判斷當前代碼是運行于創建該控件的線程之上,還是運行于另一個線程之上。若是運行于另一個線程之上,那么則需要使用封送。大多數情況下,這個屬性的實現還算簡單:只要檢查當前線程的ID,并與創建該控件的線程ID進行比較即可。若二者匹配,那么則無需Invoke,否則就需要Invoke。這個比較并不需要花費太多時間,WPF版本的這類擴展方法也是執行了同樣的檢查。
不過其中還有一些邊緣情況。若需要判斷的控件還沒有被創建,在父控件已創建好,正在創建子控件時就可能發生這個情況。那么此時,雖然C#對象已經存在,不過其底層的窗口句柄仍舊為null。此時也就無法進行比較,因此框架本身將花費一定代價來處理這種情況。框架將沿著控件樹向上尋找,看看是否有上層控件已被創建。若是框架能夠找到一個創建好了的窗體,那么該窗體將作為封送窗體。這是一個非常合理的假設,因為父控件將要負責創建子控件。這種做法可以保證子控件將會與父控件在同一個線程上創建。找到合適的父控件之后,框架即可執行同樣的檢查,比較當前線程的ID和創建該父控件的線程的ID。
不過,若是框架無法找到任何一個已創建的父窗體,那么則需要找到一些其他類型的窗體。若在層次體系中無法找到可用的窗體,那么框架將開始尋找暫存窗體(parking window),暫存窗體讓你不會被某些Win32 API奇怪的行為所干擾。簡而言之,有些對窗體的修改(例如修改某些樣式)需要銷毀并重新創建該窗體。暫存窗體就是用來在父窗體被銷毀并重新創建的過程中用來臨時保存其中的控件的。在這段時間內,UI線程僅運行于暫存窗體中。
在WPF中,得益于Dispatcher類的使用,上述很多過程都得到了簡化。每個線程都有一個Dispatcher。在第一次訪問某個控件的Dispatcher時,類庫將察看該線程是否已經擁有了Dispatcher。若已經存在,那么直接返回。如果沒有的話,那么將創建一個新的Dispatcher對象,并關聯在控件及其所在的線程之上。
不過這其中仍舊有可能存在著漏洞和發生失敗。有可能所有的窗體,包括暫存窗體都沒有被創建。在這種情況下,InvokeRequired將返回false,表示無需將調用封送到另一個線程上。這種情況可能會比較危險,因為這個假設可能是錯誤的,但框架也僅能做到如此了。任何需要訪問窗體句柄的方法都無法成功執行,因為現在還沒有任何窗體。此外,封送也自然會失敗。若是框架無法找到任何可以封送的控件,自然也無法將當前調用封送到UI線程上。于是框架選擇了一個可能在稍后出現的失敗,而不是當前會立即出現的失敗。幸運的是,這種情況在實際中非常少見。不過在WPF中,Dispatcher還是包含了額外的代碼來預防這種情況。
總結一下InvokeRequired的相關內容。一旦控件創建完成,那么InvokeRequired的效率將會不錯,且也能保證安全。不過若是目標控件尚未被創建,那么InvokeRequired則可能會耗費比較長的時間。而若是沒有創建好任何控件,那么InvokeRequired則可能要相當長的時間,同時其結論也無法保證正確。但雖然Control.InvokeRequired有可能耗時較長,也比非必要地調用Control.Invoke要高效得多。且在WPF中,很多邊緣情況都得到了優化,性能要比Windows窗體的實現提高不少。
接下來看看Control.Invoke的執行過程。(Control.Invoke的執行非常復雜,因此這里將僅做簡要介紹。)首先,有一個特殊情況是雖然調用了Invoke方法,不過當前線程卻和控件的創建線程一樣。這是個最為簡單的特例,框架將直接調用委托。即當InvokeRequired返回false時仍舊調用Control.Invoke()將會有微小的損耗,不過仍舊是安全的。
在真正需要調用Invoke時會發生一些有趣的情況。Control.Invoke能夠通過將消息發送至目標控件的消息隊列來實現跨線程調用。Control.Invoke還創建了一個專門的結構,其中包含了調用委托所需要的所有信息,包括所有的參數、調用棧以及委托的目標等。參數均會被預先復制出來,以避免在調用目標委托之前被修改(記住這是在多線程的世界中)。
在創建好這個結構并添加到隊列中之后,Control.Invoke將向目標對象發送一條消息。Control.Invoke隨后將在等待UI線程處理消息并調用委托時組合使用旋轉等待(spin wait)和休眠。這部分的處理包含了一個重要的時間問題。當目標控件開始處理Invoke消息時,它并不會僅僅執行一個委托,而是處理掉隊列中所有的委托。若你使用的是Control.Invoke的同步版本,那么不會看到任何效果。不過若是混合使用了Control.Invoke和Control.BeginInvoke,那么行為將有所不同。這部分內容將在稍后繼續介紹,目前需要了解的是,控件的WndProc將在開始處理消息時處理掉每一個等待中的Invoke消息。對于WPF,可控制的要多一些,因為可以指定異步操作的優先級。你可以讓Dispatcher將消息放在隊列中時給出三種優先級:(1)基于系統或應用程序的當前狀況;(2)使用普通優先級;(3)高優先級。
當然,這些委托中可能會拋出異常,且異常無法跨線程傳遞。因此框架將把對委托的調用用try/catch包圍起來并捕獲所有的異常。隨后在UI線程完成處理之后,其中發生的異常將被復制到專門的數據結構中,供原線程分析。
在UI線程處理結束之后,Control.Invoke將察看UI線程中拋出的所有異常。如果確有異常發生,那么將在后臺線程中重新拋出。若沒有異常,那么將繼續進行普通的處理。可以看到,調用一個方法的過程并不簡單。
Control.Invoke將在執行封送調用時阻塞后臺線程,雖然實際上在多線程環境中運行,不過仍舊讓人覺得是同步的行為。
不過這可能不是你所期待的。很多時候,你希望讓工作線程觸發一個事件之后繼續進行下面的操作,而不是同步地等待UI。這時則應該使用BeginInvoke。該方法的功能和Control.Invoke基本相同,不過在向目標控件發送消息之后,BeginInvoke將立即返回,而不是等待目標委托完成。BeginInvoke支持發送消息(可能在稍后才會處理)后立即返回到調用線程上。你可以根據需要為ControlExtensions類添加相應的異步方法,以便簡化異步跨線程UI調用的操作。雖然與前面的那些方法相比,這些方法帶來的優勢不那么明顯,不過為了保持一致,我們還是在ControlExtensions中給出。
public static void QueueInvoke(this Control ctl, Action doit)
{
??? ctl.BeginInvoke(doit);
}
public static void QueueInvoke<T>(this Control ctl,
??? Action<T> doit, T args)
{
??? ctl.BeginInvoke(doit, args);
}
QueueInvoke并沒有在一開始檢查InvokeRequired。這是因為即使當前已經運行于UI線程之上,你仍可能想要異步地調用方法。BeginInvoke()就實現了這個功能。Control.BeginInvoke將消息發送至目標控件,然后返回。隨后目標控件將在其下一次檢查消息隊列時處理該消息。若是在UI線程中調用的BeginInvoke,那么實際上這并不是異步的:當前操作后就會立即執行該調用。
這里我忽略了BeginInvoke所返回的Asynch結果對象。實際上,UI更新很少帶有返回值。這會大大簡化異步處理消息的過程。只需簡單地調用BeginInvoke,然后等待委托在稍后的某個時候執行即可。但編寫委托方法時需要格外小心,因為所有的異常都會在跨線程封送中被默認捕獲。
在結束這個條目之前,我再來簡單介紹一下控件的WndProc。當WndProc接收到了Invoke消息之后,將執行InvokeQueue中的每一個委托。若是希望按照特定的順序處理事件,且你還混合使用了Invoke和BeginInvoke,那么可能會在時間上出現問題。可以保證的是,使用Control. BeginInvoke或Control.Invoke調用的委托將按照其發出的順序執行。BeginInvoke僅僅會在隊列中添加一個委托。不過稍后的任意一個Control.Invoke調用均會讓控件開始處理隊列中所有的消息,包括先前由BeginInvoke添加的委托。“稍后的某一時間”處理委托意味著你無法控制“稍后的某一事件”到底是何時。“現在”處理委托則意味著應用程序先執行所有等待的異步委托,然后處理當前的這一個。很有可能的是,某個由BeginInvoke發出的異步委托將在Invoke委托調用之前改變了程序的狀態。因此需要小心地編寫代碼,確保在委托中重新檢查程序的狀態,而不是依賴于調用Control.Invoke時傳入的狀態。
簡單舉例,如下版本的事件處理程序很難顯示出那段額外的文字。
private void OnTick(object sender, EventArgs e)
{
??? this.InvokeAsynch(() => toolStripStatusLabel1.Text =
??????? DateTime.Now.ToLongTimeString());
??? toolStripStatusLabel1.Text += "? And set more stuff";
}
這是因為第一個修改會被暫存于隊列中,隨后在開始處理接下來的消息時才會修改文字。而此時,第二條語句已經給標簽添加了額外的文字。
Invoke和InvokeRequired為你默默地做了很多的工作。這些工作都是必需的,因為Windows窗體控件構建于STA模型之上。這個行為在最新的WPF中依舊存在。在所有最新的.NET Framework代碼之下,原有的Win32 API并沒有什么變化。因此這類消息傳遞以及線程封送仍舊可能導致意料之外的行為。你必須對這些方法的工作原理及其行為有著充分的理解。