参考
Safe, Simple Multithreading in Windows Forms, Part 1, 2,3
http://msdn.microsoft.com/library/en-us/dnforms/html/winforms06112002.asp
http://msdn.microsoft.com/library/en-us/dnforms/html/winforms08162002.asp
http://msdn.microsoft.com/library/en-us/dnforms/html/winforms01232003.asp
Safe, Even Simpler Multithreading in Windows Forms 2.0
http://www.mikedub.net/mikeDubSamples/SafeReallySimpleMultithreadingInWindowsForms20/SafeReallySimpleMultithreadingInWindowsForms20.htm
翻译+篡改 by RivenHuang 2006/07/27
关键字:
UI线程 工作线程 线程同步 线程通信 竞争 死锁 boxing 异步调用web service
设想实现以下的case:
一个win form application, 用来计算任意长度的pi值, from上的progress
可以显示计算的进度, from上的cancle button 可以终止计算, 计算完成后form可以得到通知.
正如作者所言,”It all started innocently enough.”
Design 1—————————————————–
button click 调用函数CalcPi(int digits), CalcPi中在一个循环中干活,每计算一次更新一下UI,
包括计算结果, progress bar:
private void button_Calc_Click(object sender, System.EventArgs e)
{
this.CalcPi((int)this.numericUpDown_Digits.Value);
}
void ShowProgress(string pi, int totalDigits, int digitsSoFar)
{
this.textBox_Result.Text = pi;
this.progressBar_Calc.Maximum = totalDigits;
this.progressBar_Calc.Value = digitsSoFar;
}
void CalcPi(int digits)
{
StringBuilder pi = new StringBuilder(”3″, digits + 2);
// Show progress
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));
// Show progress
ShowProgress(pi.ToString(), digits, i + digitCount);
}
}
}
看上去很美,尝试一下CalcPi(1000),此时,progress bar在努力地前进着,而textbox好像在偷懒,
text区域没有任何输出,但它的滚动条上的thumb又在变化(出现,变短), 然后切换到别的程序,
再切回来,form会失去响应,白屏了.如果这是一个我自己玩的程序,我会容忍,因为我知道它在干活,
如果是工作中的程序,就有必要解决一下这个问题.
分析一下:
此时的程序是一个单线程程序(相信你攒的大多数程序都是这样),在CalcPi()中,试图调用ShowProgress
来设置textBox_Result和progressBar_Calc的值来立刻重画以显示当前的工作成绩,(progress的表现要比
textbox的表现好一些,why?我也不知道),在把form放到后台,再放到前台后,form的Paint event会被触发
(其实就是windows的WM_PAINT消息,对于windows程序来说,这是个很有些门道的message,详情见
Programming windows 5e ch5 图形基础),但实际上此时程序正在执行CalcPi(),Paint 的事件处理函数只好
排队.
要解决这个问题肯定要使用多线程, 正解是另开一个工作线程来干活,并和UI通信,报告当前的进度,但我在
我们的代码中见过另类的做法: 在UI线程中干活,再开一个线程来刷新UI,也能干活,但我担心在某些情况下
会出现一些微妙的bug.
Desing 2———————————————–
在button click中另开线程.注意ThreadStart含参数,所以要把CalcPi(digits)包装一下
private void button_Calc_Click(object sender, System.EventArgs e)
{
Thread piThread = new Thread(new ThreadStart(CalcPiThreadStart));
piThread.Start();
}
void CalcPiThreadStart()
{
CalcPi((int)this.numericUpDown_Digits.Value);
}
run, 看上去更美,唯一的缺憾就是CalcPiThreadStart这个没用的东西非常碍眼.
使用异步的delegate可以弥补这个缺憾,注意,此时的工作线程是线程池中的线程.
Desing 3———————————————–
private void button_Calc_Click(object sender, System.EventArgs e)
{
CalcPiDelegate calcPi = new CalcPiDelegate(CalcPi);
//–忆苦代码:-)
//calcPi((int)numericUpDown_Digits.Value);
calcPi.BeginInvoke((int)numericUpDown_Digits.Value, null, null);
}
有关 delegate, Chris Sells推荐了.NET Delegates: A C# Bedtime Story一文,
在Framework design guideline一书中,有 Async Pattern可供参考.
真的天下太平了吗?Windows世界中有这样一条警句:
“Though shalt not operate on a window from other than its creating thread”,
回头看看已有的代码,X! 不就在说我吗?当然目前好像没什么错误,但是在”某些情况下
会出现一些微妙的bug.”
为了安全,在ShowProgress中添加
void ShowProgress(string pi, int totalDigits, int digitsSoFar)
{
// Make sure we’re on the right thread
Debug.Assert(this.InvokeRequired == false);
…
}
红叉子马上就来了.
有矛就有盾,.NET中所有从System.Windows.Forms.Control派生的class,当然包含
System.Windows.Forms.Form, 都有一个InvokeRequired属性,用以返回是否需要使用一些
Control上的方法把对control的操作传递给create control的线程.这个property可以在
任何一个线程中访问,
.NET的Control上只有Invoke, BeginInvoke, EndInvoke, GreateGraphics这4个方法可以从任何线程
调用,其他的调用, 必须使用Invoke方法来传递.
目前问题的解决方案就是使用一个delegate来把对UI control的操作通过 Control上的Invoke
方法传递过去,当然还需要使用异步Invoke,否则工作线程也会被block.本例中由于工作线程只为
UI thread一人服务,使用Invoke不会有问题.
Design4 ——————–
void CalcPi(int digits)
{
StringBuilder pi = new StringBuilder(”3″, digits + 2);
// Get ready to show progress asynchronously
ShowProgressDelegate showProgress = new ShowProgressDelegate(ShowProgress);
// Show progress
this.Invoke(showProgress, new object[] { 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));
// Show progress
this.Invoke(showProgress,new object[] { pi.ToString(), digits, i + digitCount});
}
}
}
在Design4中出现了两次this.Invoke, 重构, 把代码移动到ShowProgress中
Desing5———————————-
void ShowProgress(string pi, int totalDigits, int digitsSoFar)
{
System.Diagnostics.Debug.Assert(this.InvokeRequired == false);
if( this.InvokeRequired == false )
{
this.textBox_Result.Text = pi;
this.progressBar_Calc.Maximum = totalDigits;
this.progressBar_Calc.Value = digitsSoFar;
}
else
{
// Show progress asynchronously
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 )
{
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));
// Show progress
ShowProgress(pi.ToString(), digits, 0);
}
}
}
现在可以考虑cancel 功能的实现了
1. UI上的对应: 在开始计算后, button上的”Calc”要变为”Cancel”
还可以弹出一个带progress bar和cancel button的 dialog,
2.通常会使用一个变量来标示是否操作被取消,在从UI线程得知工作线程线程应该停止
(cancel button click被执行),到工作线程自己知道将被停止,并停止发送进度之间的这
一小段时间内,应该禁用 UI。
否则,用户在第一个工作线程停后又开始别的工作, UI就线程必须判断是从新的工作线程获
取进度还是从即将关闭的旧线程获取进度。这就需要工作线程能够通知外界是否它已经停止.
Design 6
enum CalcState
{
Pending, // No calculation running or canceling
Calculating, // Calculation in progress
Canceled, // Calculation canceled in UI but not worker
}
CalcState _state = CalcState.Pending;
在本例中由ShowProgress来设置_state变量,以通知工作线程停止
并重新设置UI, 使button enable.
delegate void ShowProgressDelegate(string pi, int totalDigits, int digitsSoFar);
void ShowProgress(string pi, int totalDigits, int digitsSoFar)
{
lock(this._stateLock)
{
if( _state == CalcState.Canceled )
{
_state = CalcState.Pending;
}
}
// Make sure we’re on the right thread
if( this.InvokeRequired == false )
{
this.textBox_Result.Text = pi;
this.progressBar_Calc.Maximum = totalDigits;
this.progressBar_Calc.Value = digitsSoFar;
// Check for completion
if( _state == CalcState.Pending || (digitsSoFar == totalDigits) )
{
_state = CalcState.Pending;
this.button_Calc.Text = “Calc”;
this.button_Calc.Enabled = true;
}
}
// Transfer control to correct thread
else
{
ShowProgressDelegate showProgress = new ShowProgressDelegate(ShowProgress);
// Show progress synchronously (so we can check for cancel)
Invoke(showProgress, new object[] { pi, totalDigits, digitsSoFar});
}
}
void CalcPi(int digits)
{
StringBuilder pi = new StringBuilder(”3″, digits + 2);
// Show progress (ignoring Cancel so soon)
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));
// Show progress (checking for Cancel)
ShowProgress(pi.ToString(), digits, i + digitCount);
if( this._state == CalcState.Canceled ) break;
}
}
}
此时,工作线程和UI线程通过一个内部变量_state来记录工作状态,为了避免竞争,需要加上一个监视锁,
在.NET中为了共享对象提供了 Monitor 类,其作用类似于为数据加了一把锁.
Design 6.1
object _stateLock = new object();
void ShowProgress(string pi, int totalDigits, int digitsSoFar, out bool cancel)
{
lock( _stateLock )
{ // 监视锁
if( _state == CalcState.Cancel )
{
_state = CalcState.Pending;
cancel = true;
}
}
…
}
但是这样的做法又有可能引起死锁,需要强调的是:
“通过共享数据进行的多线程编程很难做到十全十美”.
可通过在线程之间传递数据的副本来避免使用共享数据,只有在数据很大时才考虑使用共享数据.
此处,只让UI线程(在UI线程中执行的ShowProgress)来检查_state,并返回给工作线程一个状态值表示是否取消计算.
但返回值通常用来表示操作是否正常进行,所以使用一个out参数来传递信息.
注意1:
此时工作线程通过调用ShowProgress来查看操作是否已被取消,所以不能使用Control.BeginInvoke来执行ShowProgress,
使用Control.BeginInvoke又会需要同步. 有一个卖糕的.所以还是使用Invoke
注意2:
不能直接向 Control.Invoke 简单传递bool变量来获得 cancel 参数!
因为 bool 是valut type,而 Invoke 采用object array 作为参数,其结果是作为对象传递的 bool
将被boxing而保持实际的 bool , 为此必须使用自己的对象变量 (inoutCancel) 传递它,
在同步调用 Invoke 后,我们将 object cast为 bool 以查看是否应该取消操作。
!任何时候调用带有 out 或 ref 参数的 Control.Invoke或 Control.BeginInvoke时,都必须注意
值类型和引用类型数据之间的区别。
Desing 7———————————–
void CalcPi(int digits)
{
bool cancel = false;
StringBuilder pi = new StringBuilder(”3″, digits + 2);
// Show progress (ignoring Cancel so soon)
ShowProgress(pi.ToString(), digits, 0, out cancel);
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));
// Show progress (checking for Cancel)
ShowProgress(pi.ToString(), digits, i + digitCount,out cancel);
if( cancel ) break;
}
}
}
delegate void ShowProgressDelegate(string pi, int totalDigits, int digitsSoFar,out bool cancel);
void ShowProgress(string pi, int totalDigits, int digitsSoFar,out bool cancel)
{
// Make sure we’re on the right thread
if( this.InvokeRequired == false )
{
this.textBox_Result.Text = pi;
this.progressBar_Calc.Maximum = totalDigits;
this.progressBar_Calc.Value = digitsSoFar;
// Check for Cancel
cancel = (_state == CalcState.Canceled);
// Check for completion
if( cancel || (digitsSoFar == totalDigits) )
{
_state = CalcState.Pending;
this.button_Calc.Text = “Calc”;
this.button_Calc.Enabled = true;
}
}
// Transfer control to correct thread
else
{
ShowProgressDelegate showProgress = new ShowProgressDelegate(ShowProgress);
// Avoid boxing and losing our return value
object inoutCancel = false; // Avoid boxing and losing our return value
// Show progress synchronously (so we can check for cancel)
Invoke(showProgress, new object[] { pi, totalDigits, digitsSoFar, inoutCancel});
cancel = (bool)inoutCancel;
}
}
一路挣扎到这,我已经满头大汗,可故事还没有结束, 代码还有可优化的余地.
作者称之为 message passing model:
工作线程create一个message, 交给UI线程处理,然后检查UI线程的处理结果,整个处理过程很安全,
不存在多线程之间纠缠不清的问题,而且具有很好的可扩充性,又什么要交互的信息,就加到
ShowProgressArgs中
Desing8————————————————-
void CalcPi(int digits)
{
StringBuilder pi = new StringBuilder(”3″, digits + 2);
// Show progress (ignoring Cancel so soon)
object sender = System.Threading.Thread.CurrentThread;
ShowProgressArgs e = new ShowProgressArgs(pi.ToString(), digits, 0);
ShowProgress(sender, e);
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));
// Show progress (checking for Cancel)
e.Pi = pi.ToString();
e.DigitsSoFar = i + digitCount;
ShowProgress(sender, e);
if( e.Cancel ) break;
}
}
}
class ShowProgressArgs : EventArgs
{
public string Pi;
public int TotalDigits;
public int DigitsSoFar;
public bool Cancel;
public ShowProgressArgs(string pi, int totalDigits, int digitsSoFar)
{
this.Pi = pi;
this.TotalDigits = totalDigits;
this.DigitsSoFar = digitsSoFar;
}
}
delegate void ShowProgressHandler(object sender, ShowProgressArgs e);
void ShowProgress(object sender, ShowProgressArgs e)
{
// Make sure we’re on the right thread
if( this.InvokeRequired == false )
{
this.textBox_Result.Text = e.Pi;
this.progressBar_Calc.Maximum = e.TotalDigits;
this.progressBar_Calc.Value = e.DigitsSoFar;
// Check for Cancel
e.Cancel = (_state == CalcState.Canceled);
// Check for completion
if( e.Cancel || (e.DigitsSoFar == e.TotalDigits) )
{
_state = CalcState.Pending;
this.button_Calc.Text = “Calc”;
this.button_Calc.Enabled = true;
}
}
// Transfer control to correct thread
else
{
ShowProgressHandler showProgress = new ShowProgressHandler(ShowProgress);
Invoke(showProgress, new object[] { sender, e});
}
}
最后,来看看最实用的case: Asynchronous Web Services,代码堪称典范,以致可以copy-paste:
CalcState state = CalcState.Pending;
localhost.CalcPiServiceProxy service = new localhost.CalcPiServiceProxy();
void calcButton_Click(object sender, System.EventArgs e)
{
switch( state )
{
case CalcState.Pending:
state = CalcState.Calculating;
calcButton.Text = “Cancel”;
// Start web service request
service.BeginCalcPi((int)digitsUpDown.Value, new AsyncCallback(PiCalculated), null);
break;
case CalcState.Calculating:
state = CalcState.Canceled;
calcButton.Enabled = false;
service.Abort(); // Fail all outstanding requests
break;
case CalcState.Canceled:
Debug.Assert(false);
break;
}
}
void PiCalculated(IAsyncResult res)
{
try
{
ShowPi(service.EndCalcPi(res));
}
catch( WebException ex )
{
//maybe time-out
ShowPi(ex.Message);
}
}
delegate void ShowPiDelegate(string pi);
void ShowPi(string pi)
{
if( this.InvokeRequired == false )
{
piTextBox.Text = pi;
state = CalcState.Pending;
calcButton.Text = “Calc”;
calcButton.Enabled = true;
}
else
{
ShowPiDelegate showPi = new ShowPiDelegate(ShowPi);
this.BeginInvoke(showPi, new object[] {pi});
}
}
在.NET2.0中,MS派来了弥赛亚 BackgroudWorker 来救助为了UI,耗时计算而抓破头皮的程序员.
ToolBox->Components->BackgroundWorker, drag it to the form.
>处理BackgroundWorker仅有的3个event:
DoWork
ProgressChanged
RunWorkerCompleted
>设置
WorkerReportsProgress = true; //实现进度条
WorkerSupportsCancellation = true; //实现cancel
想干活,请调用:BackgroundWorker的 RunWorkerAsync 方法, 搞定!
//———————————
private void button_Calc_Click(object sender, EventArgs e)
{
if (this.button_Calc.Text == “Cancel”)
{
this.backgroundWorker_Calc.CancelAsync();
return;
}
this.button_Calc.Text = “Cancel”;
this.backgroundWorker_Calc.RunWorkerAsync(this.numericUpDown_Digits.Value);
this.progressBar_Calc.Maximum = Convert.ToInt32(this.numericUpDown_Digits.Value);
}
// This method will run on a thread other than the UI thread.
// Be sure not to manipulate any Windows Forms controls created
// on the UI thread from this method.
private void backgroundWorker_Calc_DoWork(object sender, DoWorkEventArgs e)
{
int digits = int.Parse(e.Argument.ToString()); // <= RunWorkerAsync(this.numericUpDown_Digits.Value);
StringBuilder pi = new StringBuilder(”3″, digits + 2);
CalcPiUserState userState = new CalcPiUserState(pi.ToString(), digits, 0);
this.backgroundWorker_Calc.ReportProgress(0, userState);
// Calculate rest of pi, if required
if (digits > 0)
{
pi.Append(”.”);
for (int i = 0; i < digits; i += 9)
{
// Calculate next i decimal places
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));
// Show current progress
userState.Pi = pi.ToString();
userState.DigitsSoFar = i + digitCount;
this.backgroundWorker_Calc.ReportProgress(0, userState);
// Check for cancellation
if (this.backgroundWorker_Calc.CancellationPending)
{
// Need to set Cancel if you need to distinguish how a worker thread completed
// ie by checking RunWorkerCompletedEventArgs.Cancelled
e.Cancel = true;
break;
}
}
}
e.Result = “Finished Normally.”;
}
private void backgroundWorker_Calc_ProgressChanged(object sender, ProgressChangedEventArgs e)
{
CalcPiUserState userToken = (CalcPiUserState)e.UserState;
this.progressBar_Calc.Value = userToken.DigitsSoFar;
this.textBox_Result.Text = userToken.Pi;
}
private void backgroundWorker_Calc_RunWorkerCompleted(object sender, RunWorkerCompletedEventArgs e)
{
this.button_Calc.Text = “Calc”;
this.textBox_Result.Text = “”;
this.progressBar_Calc.Value = 0;
if (!e.Cancelled)
{
this.Text = e.Result.ToString();
}
}
}