安全、简单的Windows Forms多线程编程实现
图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属性时,这些控件pdf,作为设置属性操作的一部分,可以强制他们的绘画操作立即进行(尽管我注意到了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 vpdf方法之一。”所以,当我设置控件属性时,就明确地违反了这条规则。前三个函数的名字就明确地指出了我需要构建另外一个在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的调用多少有点麻烦,因为它在CalcPipdf中调用了两次,我可以简化一下,改进ShowProgresspdf让它自己来作异步调用。如果ShowProgressv是在正确的线程中调用的,他将更新控件,但是如果它在不正确的线程中被调用,它就会使用Invoke在正确的线程中回头调用它自身。这让我们回到了以前,简单些的CalcPi:
void ShowProgress(string pi, int totalDigits, int digitsSoFar) {
// 确保我们在正确的线程中
if( _pi.InvokeRequired == false ) {
_pi.Text = pi;
_piProgress.Maximum = totalDigits;
_piProgress.Value = digitsSoFar;
}
else {
// 异步显示进度pdf
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是一个异步调用并且我们并不真的v需要它的返回值(事实上,ShowProgress本身就不返回任何值),这里最好是使用BeginInvoke以便worker线程不阻塞。就像下面:
BeginInvoke(showProgress, new object[] pdf{ pi, totalDigits, digitsSoFar});
如果不需要函数调用的返回值,BeginInvoke总是应该优先考虑,因为它可以让worker线程立即回到自己的工作上来,并且避免死锁的可能性。
我们做了什么?
我用了一个简短的例子,演示了如何在执行耗时操作的同时显示进度并保持UI的用户操作的响应。为了完成这个任务,我用了一个异步委托来“孵化”出一个worker线程、主窗口上的Invoke方法和另外一个要封传回UI线程执行的代理。
我非常小心地避免做一件事情,那就是在UI线程和worker线程之间共享数据。相反,对于那些必需的数据我传递它们的拷贝(要计算的位数、已经计算出来的数字和进度)。在最终的解决方案中,我从来都没有传递对象的引用,比如当前StringBuilder的引用(虽然传递引用可以节省每次回到UI线程时对字符串的拷贝)。如果我要在两个线程之间传递数据的引用,我就必须使用.NET原始的同步手段来确保任一时刻只有一个线程访问一个对象,这将增加许多工作量。这样已经足够了,无需引入同步进制。
当然,如果你需要处理的是数据库,你肯定不会打算把数据库到处复制。然而在Windows Forms程序中,如果可能我推荐你在worker线程和UI线程之间使用异步委托和消息来实现耗时操作。