說實在的,我最初打算做的事情和本文主要討論的內容毫不相關。那時,我第一次發現我需要在.Net中計算一個圓的面積,當然,首先需要一個pi(π)的精確值。System.Math.PI用起來倒是很方便,但它只提供了20位的精度,我不禁為計算的精度而擔心(其實21位的就可以絕對令我感到舒服)。所以和其他任何稱職的程序員一樣,我忘記了真正需要解決的問題,而埋頭寫出了一個自己喜歡的可以算出任意位小數的π值的程序。最終的結果如圖1。
圖1. 計算Pi值的程序
耗時操作(Long-Running Operations)的進度
雖然大多數的程序不需要計算pi的值,但是很多的程序都需要進行一些耗時的操作,比如打印、調用一個Web service或者計算一位太平洋西北岸億萬富翁的利息收入。對用戶來說,只要可以看到當前完成的進度,這樣的等待通常還是可以接受的,甚至是一個抽身忙些其他事情的機會。所以我給我的每一個小程序都添加了一個進度條(progress bar)。我的這個計算pi值的程序所用的算法每次計算9位數字。一旦新的一組數字被計算出來,我的程序就更新TextBox控件並移動ProgressBar來顯示我們的進度。比如圖2就是正在計算1000位pi值的情形(如果21位沒有問題,1000位也必定更好)。
圖2. 正在計算1000位Pi值
下面的代碼展示了pi值的數字被計算出來之後,用戶界面(UI)是如何更新的:
void ShowProgress(string pi, int totalDigits, int digitsSoFar) {
_pi.Text = pi;
_piProgress.Maximum = totalDigits;
_piProgress.Value = digitsSoFar;
}
void CalcPi(int digits) {
StringBuilder pi = new StringBuilder("3", digits + 2);
// 顯示進度
ShowProgress(pi.ToString(), digits, 0);
if( digits > 0 ) {
pi.Append(".");
for( int i = 0; i < digits; i += 9 ) {
int nineDigits = NineDigitsOfPi.StartingAt(i+1);
int digitCount = Math.Min(digits - i, 9);
string ds = string.Format("{0:D9}", nineDigits);
pi.Append(ds.Substring(0, digitCount));
// 顯示進度
ShowProgress(pi.ToString(), digits, i + digitCount);
}
}
}
一切進行的都很順利,直到我在計算pi的1000位的過程中切換到了其他程序做了些什麼然後再切換回來時……。我看到了圖3顯示的畫面:
圖3. 沒有paint事件了
問題的根源當然在於我們的程序是單線程的。當這個線程忙於計算pi值時,就沒有機會去繪制UI了。之前我之所以沒有遇到這個問題是因為當我設置TextBox.Text屬性和ProgressBar.Value屬性時,這些控件,作為設置屬性操作的一部分,可以強制他們的繪畫操作立即進行(盡管我注意到了progress bar的情況要比text box好一些)。然而,當我把這個程序放到後台然後再帶回前台時,我需要重繪整個客戶區,對窗口來說就是一個Paint事件。因為當前正在處理的事件(Calc按鈕的Click事件)返回之前其他事件是不會得到處理的,於是我們就沒有機會看到更多的進度顯示了。我現在需要做的就是把UI線程釋放出來專做UI工作,把耗時的操作放到後台處理。為了實現這個目標,我們需要另外一個線程。
異步操作
現在我的同步Click handler看起來是這樣的:
void _calcButton_Click(object sender, EventArgs e) {
CalcPi((int)_digits.Value);
}
回憶一下我們的問題是“直到CalcPi返回,線程才可以從Click handler返回,窗口才有機會處理Paint(或其他)事件”。處理這種情況的一個方法是啟動另外的線程,比如:
using System.Threading;
int _digitsToCalc = 0;
void CalcPiThreadStart() {
CalcPi(_digitsToCalc);
}
void _calcButton_Click(object sender, EventArgs e) {
_digitsToCalc = (int)_digits.Value;
Thread piThread = new Thread(new ThreadStart(CalcPiThreadStart));
piThread.Start();
}
現在,在button Click事件返回之前無需再等待CalcPi的完成了,我創建並啟動了一個新的線程,Thread.Start方法將調度並啟動新的線程,然後立即返回,讓UI線程回到它自己的工作上來。現在如果用戶和程序交互(比如放到後台,置到前台,改變窗口大小,關閉它),UI線程可以自由地處理這些事件,同時worker線程也在進行它的計算pi的工作。圖4顯示了兩個線程工作的情形:
圖4. 幼稚的多線程
你也許注意到了,我沒有為worker線程的入口點——CalcPiThreadStart傳遞任何參數,而是把需要計算的位數放入一個字段(field)_digitsToCalc中,調用線程的入口點,CalcPi被調用。這是一種痛苦,也是我喜歡用Delegate來作異步工作的一個原因。Delegate支持參數,避免了我對增加一個額外的臨時fIEld和一個額外的函數而做激烈的思想斗爭。
如果你對delegate(委托)不熟悉,就認為他們是調用static或instance函數的對象。在C#中它們的聲明語法和函數是一樣的。比如CalcPi的一個委托看起來是這樣的:
delegate void CalcPiDelegate(int digits);
一旦有了一個委托,我就可以實例化一個對象來同步調用CalcPi函數:
void _calcButton_Click(object sender, EventArgs e) {
CalcPiDelegate calcPi = new CalcPiDelegate(CalcPi);
calcPi((int)_digits.Value);
}
當然,我並不想同步調用CalcPi;我想要異步調用它。然而,在我做這個之前我們需要了解一點委托的工作原理。上面的委托聲明實際上聲明了一個類,該類繼承自一個自帶有三個函數(Invoke、BeginInvoke和EndInvoke)的MultiCastDelegate類,像這樣:
class CalcPiDelegate : MulticastDelegate {
public void Invoke(int digits);
public void BeginInvoke(int digits, AsyncCallback callback,
object asyncState);
public void EndInvoke(IAsyncResult result);
}
當我先實例化一個CalcPiDelegate對象然後像調用函數一樣調用它時,我實際上調用了他的同步Invoke函數。它隨後調用了我的CalcPi。然而BeginInvoke和EndInvoke是一對可以讓你異步調用並收獲(harvest)返回值的函數。所以,為了讓CalcPi在另外一個線程中運行,我需要像下面那樣調用BeginInvoke:
void _calcButton_Click(object sender, EventArgs e) {
CalcPiDelegate calcPi = new CalcPiDelegate(CalcPi);
calcPi.BeginInvoke((int)_digits.Value, null, null);
}
注意我們為BeginInvoke的最後兩個參數傳遞了null。這兩個參數在我們需要隨後收獲(harvest)函數的返回值時(EndInvoke就是用來做這個的)就有用了。由於CalcPi函數直接更新UI,我們僅需為這兩個參數傳遞null。如果你對委托的細節(同步和異步)感興趣,可以看看.Net委托:一個C#睡前故事(中文翻譯版)這篇文章。
到這時,我應該感到高興。我已經在我的程序裡顯示耗時操作進度並保持了UI的良好交互性。
多線程安全
看起來我足夠幸運(或者說不幸,看你怎麼看待這些事情了)。Microsoft Windows(R)XP給我提供了一個非常健壯的Windows Forms賴以建立的windows系統底層實現。它是如此健壯,以至於優雅地幫我包攬了所有問題,即使我違反了主要的Windows編程方針——不要在創建一個窗口的線程之外的線程操作這個窗口。不幸的是,不能保證其他不太健壯的Windows實現不會同樣優雅地給我臉色看。
問題當然是我自己造成的。回憶一下圖4,我用兩個線程同時訪問一個窗口。然而,因為耗時操作在Windows程序中是如此的普遍,以至於Windows Forms裡的每個UI類(每一個本質上從System.Windows.Forms.Control繼承的類)都有一個可以在任何線程中安全訪問的屬性InvokeRequired。這個屬性在一個線程調用控件的對象方法之前需要先封傳該控件到創建這個控件的線程時返回true。我的ShowProgress函數中如果簡單地增加一個Assert就會立即顯現出我上述做法的錯誤之處。
using System.Diagnostics;
void ShowProgress(string pi, int totalDigits, int digitsSoFar) {
// Make sure we're on the right thread
Debug.Assert(_pi.InvokeRequired == false);
...
}
實事上,.Net文檔在這一點上是很清晰的。它描述如下:“控件上有四種方法可以安全地從任何線程進行調用:Invoke、BeginInvoke、EndInvoke 和 CreateGraphics。對於所有其他方法調用,當從另一個線程進行調用時,應使用這些 Invoke 方法之一。”所以,當我設置控件屬性時,就明確地違反了這條規則。前三個函數的名字就明確地指出了我需要構建另外一個在UI線程中執行的委托。如果我不想像阻塞UI線程一樣阻塞我的worker線程,我就需要使用異步的BeginInvoke和EndInvoke。然而,由於我的worker線程生來就是為UI線程服務的,就讓世界簡單一點,使用同步的Invoke方法吧。像下面:
public object Invoke(Delegate method);
public object Invoke(Delegate method, object[] args);
第一個重載的Invoke接收一個包含我們將要在UI線程中調用的方法的委托的實例,這個委托(或方法)必須沒有參數。然而我們想要用來更新UI的函數ShowProgress帶有三個參數,所以我們需要第二個重載形式。我們同樣需要為我們的ShowProgress方法定義一個單獨的委托以便我們可以正確地傳遞參數。這裡給出了如何使用Invoke來確保我們調用的ShowProgress也包括我們對窗口的使用是在正確的線程中執行的方法:(請確認替換CalcPi中兩處對ShowProgress的調用)。
delegate
void ShowProgressDelegate(string pi, int totalDigits, int digitsSoFar);
void CalcPi(int digits) {
StringBuilder pi = new StringBuilder("3", digits + 2);
// 准備異步顯示進度
ShowProgressDelegate showProgress =
new ShowProgressDelegate(ShowProgress);
// 顯示進度
this.Invoke(showProgress, new object[] { pi.ToString(), digits, 0});
if( digits > 0 ) {
pi.Append(".");
for( int i = 0; i < digits; i += 9 ) {
...
// 顯示進度
this.Invoke(showProgress,
new object[] { pi.ToString(), digits, i + digitCount});
}
}
}
Invoke的使用最終讓我在Windows Forms程序中安全的使用多線程。UI線程孵化出一個worker線程來執行耗時操作,當UI需要更新時worker線程把控制傳遞回UI線程。圖5顯示了我們的安全多線程構架。
圖5. 安全的多線程
簡化了的多線程
對Invoke的調用多少有點麻煩,因為它在CalcPi中調用了兩次,我可以簡化一下,改進ShowProgress讓它自己來作異步調用。如果ShowProgress是在正確的線程中調用的,他將更新控件,但是如果它在不正確的線程中被調用,它就會使用Invoke在正確的線程中回頭調用它自身。這讓我們回到了以前,簡單些的CalcPi:
void ShowProgress(string pi, int totalDigits, int digitsSoFar) {
// 確保我們在正確的線程中
if( _pi.InvokeRequired == false ) {
_pi.Text = pi;
_piProgress.Maximum = totalDigits;
_piProgress.Value = digitsSoFar;
}
else {
// 異步顯示進度
ShowProgressDelegate showProgress =
new ShowProgressDelegate(ShowProgress);
this.Invoke(showProgress,
new object[] { pi, totalDigits, digitsSoFar});
}
}
void CalcPi(int digits) {
StringBuilder pi = new StringBuilder("3", digits + 2);
// 顯示進度
ShowProgress(pi.ToString(), digits, 0);
if( digits > 0 ) {
pi.Append(".");
for( int i = 0; i < digits; i += 9 ) {
...
//顯示進度
ShowProgress(pi.ToString(), digits, i + digitCount);
}
}
}
因為Invoke是一個異步調用並且我們並不真的需要它的返回值(事實上,ShowProgress本身就不返回任何值),這裡最好是使用BeginInvoke以便worker線程不阻塞。就像下面:
BeginInvoke(showProgress, new object[] { pi, totalDigits, digitsSoFar});
如果不需要函數調用的返回值,BeginInvoke總是應該優先考慮,因為它可以讓worker線程立即回到自己的工作上來,並且避免死鎖的可能性。
我們做了什麼?
我用了一個簡短的例子,演示了如何在執行耗時操作的同時顯示進度並保持UI的用戶操作的響應。為了完成這個任務,我用了一個異步委托來“孵化”出一個worker線程、主窗口上的Invoke方法和另外一個要封傳回UI線程執行的代理。
我非常小心地避免做一件事情,那就是在UI線程和worker線程之間共享數據。相反,對於那些必需的數據我傳遞它們的拷貝(要計算的位數、已經計算出來的數字和進度)。在最終的解決方案中,我從來都沒有傳遞對象的引用,比如當前StringBuilder的引用(雖然傳遞引用可以節省每次回到UI線程時對字符串的拷貝)。如果我要在兩個線程之間傳遞數據的引用,我就必須使用.Net原始的同步手段來確保任一時刻只有一個線程訪問一個對象,這將增加許多工作量。這樣已經足夠了,無需引入同步進制。
當然,如果你需要處理的是數據庫,你肯定不會打算把數據庫到處復制。然而在Windows Forms程序中,如果可能我推薦你在worker線程和UI線程之間使用異步委托和消息來實現耗時操作。