日期:2011-06-18  浏览次数:20408 次

说实在的,我最初打算做的事情和本文主要讨论的内容毫不相关。那时,我第一次发现我需要在.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,

&nb