再谈Windows窗体多线程
2002年9月2日
从 MSDN Code Center 下载 asynchcaclpi.exe 示例文件(英文)。
摘要:本文探讨了如何利用多线程从长时间运行的操作中分离出用户界面 (UI),以将用户的后续输入传递给辅助线程以调节其行为,从而实现稳定而正确的多线程处理的消息传递方案。
或许您还能回想起以前的一些专栏,例如 Safe, Simple Multithreading in Windows Forms(英文)。如果您仔细阅读,就可以使 Windows 窗体和线程很好地协同工作。执行长时间运行的操作的较好方法是使用线程,例如计算 pi 小数点之后的多位数值(如以下图 1 所示)。
图 1:Pi 的位数应用程序
Windows 窗体和后台处理
在上一篇文章中,我们介绍了直接启动线程进行后台处理,但选择使用异步委托来启动辅助线程。异步委托在传递参数时具有语法方便的优点,并且通过在进程范围的、公共语言运行库管理的池中使用线程来获得更大的作用范围。我们遇到的仅有的问题发生在辅助线程需要向用户通知进度时。在本例中,辅助线程不允许直接使用 UI 控件(长期使用的 Win32? UI 不被允许)。取而代之的是,辅助线程必须向 UI 线程发送或发布一条消息,并使用 Control.Invoke
或 Control.BeginInvoke
在拥有 UI 控件的线程上执行代码。考虑到这些因素后的代码如下:
// 委托以开始异步计算 pi
delegate void CalcPiDelegate(int digits);
void _calcButton_Click(object sender, EventArgs e)
{
// 开始异步计算 pi
CalcPiDelegate calcPi = new CalcPiDelegate(CalcPi);
calcPi.BeginInvoke((int)_digits.Value, null, null);
}
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);
}
}
}
// 委托以向 UI 线程通知辅助线程的进度
delegate
void ShowProgressDelegate(string pi, int totalDigits, int digitsSoFar);
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.BeginInvoke(showProgress, new object[] { pi, totalDigits, digitsSoFar });
}
}
注意,这里有两个委托。第一个是 CalcPiDelegate,用于捆绑要传递给(从线程池中分配的)辅助线程上的 CalcPi 的参数。当用户决定要计算 pi 时,事件处理程序将创建此委托的一个实例。此工作通过调用 BeginInvoke 在线程池中进行排队。第一个委托实际上是由 UI 线程用于向辅助线程传递消息。
第二个委托是 ShowProgressDelegate,由辅助线程用于向 UI 线程回传消息,通常是有关长时间运行的操作的最新进度。为了对调用者屏蔽与此 UI 线程有关的线程安全通信信息,ShowProgress 方法在此 UI 线程上通过 Control.BeginInvoke 方法使用 ShowProgressDelegate 给自己发送消息。Control.BeginInvoke 异步队列为 UI 线程提供服务,并且不等待结果就继续运行。
取消
在本示例中,我们可以在辅助线程和 UI 线程之间来回发送消息而无需关注外部环境。UI 线程不必等待辅助线程执行完毕,甚至无需等待完成通知,因为辅助线程在执行过程中会与其实时交流进度情况。同样,辅助线程也不必等待 UI 线程显示进度,只要进度消息按照固定的时间间隔发送以使用户感到满意即可。但有一点无法满足用户,即:不能完全控制应用程序正在执行的任何处理。即使 UI 在计算 pi 时能够提供响应,有时用户仍需要取消计算操作,例如如果用户决定需要计算 1,000,001 位数字但却错误地输入了 1,000,000。更新的 CalcPi UI 允许取消操作,如图 2 所示。
图 2:允许用户取消长时间运行的操作
要实现取消长时间运行的操作,需要完成多个步骤。首先,需要为用户提供 UI。在本例中,Calc(计算)按钮在计算开始后变为 Cancel(取消)按钮。另一个常见的选择是进度对话框。该对话框通常包含当前进度的详细信息,包括显示工作完成百分比的进度条和一个Cancel(取消)按钮。
如果用户决定取消操作,则应该在成员变量中提供说明,并且在从 UI 线程获知辅助线程应该停止时,到辅助线程自己知道并可以停止发送进度之前的这一小段时间内,应该禁用 UI。如果忽略这段时间,可能会出现这种情况:用户在第一个辅助线程停止发送进度之前又开始了另一项操作,这就使 UI 线程必须判断是从新的辅助线程获取进度还是从即将关闭的旧线程获取进度。当然,也可以为每个辅助线程分配一个唯一的 ID,从而使 UI 线程可以处理好这些工作。(如果有多个并存的长时间运行的操作,则很有必要这样做。)这样,在从 UI 获知辅助线程即将停止工作时到辅助线程获知之前的这一小段时间内,暂停 UI 通常会更容易一些。我们的简单的 pi 计算器的实现方式是使用一个具有三个值的枚举变量,如下所示:
enum CalcState {
Pending, // 没有任何计算正在运行或取消
Calculating, // 正在计算
Canceled, // 在 UI 中计算已被取消但在辅助线程中还没有
}
CalcState _state = CalcState.Pending;
现在,根据所处的状态不同,我们分别处理 Calc 按钮,如下所示:
void _calcButton_Click(...) {
// Calc 按钮兼有 Cancel 按钮的功能
switch( _state ) {
// 开始新的计算
case CalcState.Pending:
// 允许取消
_state = CalcState.Calculating;
_calcButton.Text = "Cancel";
// 异步委托方法
CalcPiDelegate calcPi = new CalcPiDelegate(CalcPi);
calcPi.BeginInvoke((int)_digits.Value, null, null);
break;
// 取消正在运行的计算
case CalcState.Calculating:
_state = CalcState.Canceled;
_calcButton.Enabled = false;
break;
// 在取消过程中应该无法按下 Calc 按钮
case CalcState.Canceled:
Debug.Assert(false);
break;
}
}
请注意,如果在处于 Pending 状态时按下 Calc/Cancel 按钮,我们发送状态 Calculating(同时更改按钮上的标签),并像以前那样开始异步计算。如果在处于 Calculating 状态时按下 Calc/Cancel 按钮,则应该将状态切换为 Canceled 并禁止 UI 开始新的计算(在它为我们向辅助线程传递取消状态期间)。一旦我们已经向辅助线程传达了取消操作的信息,就可以再次启用 UI 并将状态重设为 Pending,从而使用户可以开始其他操作。要向辅助线程传达取消操作的信息,可以将 ShowProgress
方法扩充为包含新的 out
参数:
void ShowProgress(..., out bool cancel)
void CalcPi(int digits) {
bool cancel = false;
...
for( int i = 0; i < digits; i += 9 ) {
...
// 显示进度(检查是否取消)
ShowProgress(..., out cancel);
if( cancel ) break;
}
}
您可能想尝试将取消指示器设置为从 ShowProgress 返回的布尔值,但我从来都记不住 true 是表示取消还是表示一切正常(或继续照常执行)。所以我使用 out 参数,这样可以更直观一些。
最后剩下的事情是更新 ShowProgress 方法(即在辅助线程和 UI 线程之间实际执行传递工作的那部分代码),以判断用户是否请求取消并相应地通知 CalcPi 程序。确切地说,如何在 UI 和辅助线程之间传递信息取决于我们希望使用哪种技术。
通过共享数据进行通信
传递 UI 当前状态的最常见方法是让辅助线程直接访问 _state 成员变量。我们可以使用以下代码来达到这一目的:
void ShowProgress(..., out bool cancel) {
// 不要这样做!
if( _state == CalcState.Cancel ) {
_state = CalcState.Pending;
cancel = true;
}
...
}
我希望您看到这段代码时能够自然而然地(而不只是因为代码中的警告注释)想到放弃它。如果您打算编写多线程的程序,就必须要注意在任何时候两个线程都可能会同时访问相同的数据(在本例中是 _state 成员变量)。在线程之间共享访问数据很容易使线程进入“竞争状态”,即其中一个线程在另一个线程完成更新数据之前抢先读取部分更新的数据。为了实现共享数据的并发访问,您需要监视共享数据的使用情况,以确保各线程耐心等待其他线程处理完数据。为了监视共享数据的访问,.NET 为共享对象提供了 Monitor 类,其作用类似于为数据加了一把锁(C# 中包含了这种方便的加锁块):
object _stateLock = new object();
void ShowProgress(..., out bool cancel) {
// 也不要这样做!
lock( _stateLock ) { // 监视锁
if( _state == CalcState.Cancel ) {
_state = CalcState.Pending;
cancel = true;
}
...
}
}
现在我已经适当地锁定了对共享数据的访问,但由于我是采取上述方法来实现的,因此在执行多线程编程时就很可能会产生另一个常见问题,即“死锁”。当两个线程出现死锁时,在继续执行之前它们均会等待另一个线程完成其工作,这样实际上两者就都不能执行。
如果所有这些有关竞争状态和死锁的讨论都已经引起了您的关注,那就好。通过共享数据进行的多线程编程很难做到十全十美。目前为止,我们已经能够避免这些问题,因为我们已经传递了该数据的很多副本,并且各线程对这些副本具有完全的所有权。如果没有共享数据,则无需考虑同步。如果您发现必须访问共享数据(也就是说,复制数据需要大量空间或非常费时),则需要研究在线程之间共享数据(查看“参考书目”一节以获得在此领域中我最喜欢的研究文章)。
然而,绝大部分多线程方案(尤其是当涉及到 UI 多线程时)似乎与我们目前一直使用的简单消息传递方案配合得最好。大多数时候,您不希望 UI 对正在后台进行处理的数据具有访问权限(例如正在打印的文档或正被枚举的对象集合)。对于这些情况,最好的选择是避免使用共享数据。
通过方法参数进行通信
我们已经将 ShowProgress 方法扩充为包含 out 参数了。为什么不让 ShowProgress 在 UI 线程上执行时检查 _state 变量的状态呢?如下所示:
void ShowProgress(..., out bool cancel) {
// 确认在 UI 线程上
if( _pi.InvokeRequired == false ) {
...
// 检查是否取消
cancel = (_state == CalcState.Canceled);
// 检查是否完成
if( cancel || (digitsSoFar == totalDigits) ) {
_state = CalcState.Pending;
_calcButton.Text = "Calc";
_calcButton.Enabled = true;
}
}
// 将控制传递给 UI 线程
else { ... }
}
由于只有 UI 线程访问 _state 成员变量,因此不需要同步。现在只需要按照上述方法将控制传递给 UI 线程,即可获得 ShowProgressDelegate 的 cancel out 参数。不幸的是,使用 Control.BeginInvoke 使情况变得有些复杂。问题在于 BeginInvoke 不会等待 ShowProgress 在 UI 线程上的调用结果,因此我们有两个选择。其中之一是向 BeginInvoke 传递另一个委托并在 ShowProgress 从 UI 线程返回后调用它,但这同时也会发生在线程池的其他线程上,所以我们还必须回到同步上来,这一次是在辅助线程和连接池中的另一个线程之间同步。另一个较为简单的方法是切换到同步的 Control.Invoke 方法并等待 cancel out 参数。然而,就算采用这种方法也会有一点点棘手,如以下代码所示:
void ShowProgress(..., out bool cancel) {
if( _pi.InvokeRequired == false ) { ... }
// 将控制传递给 UI 线程
else {
ShowProgressDelegate showProgress =
new ShowProgressDelegate(ShowProgress);
// 避免包装或丢失返回值
object inoutCancel = false;
// 同步显示进度(这样我们可以检查是否取消)
Invoke(showProgress, new object[] { ..., inoutCancel});
cancel = (bool)inoutCancel;
}
}
虽然直接向 Control.Invoke 简单传递一个布尔变量来获得 cancel 参数可能是一个理想的方法,但这同样存在问题。问题是 bool 是“值数据类型”,而 Invoke 采用对象数组作为参数,并且对象是“引用数据类型”。(您可以查看“参考书目”一节以获得有关讨论两者区别的书籍。)其结果是作为对象传递的 bool 将被复制而保持实际的 bool 不变,这意味着我们无法知道操作被取消了。为了避免出现这种情况,我们创建了自己的对象变量 (inoutCancel) 并传递它,这样就避免了复制。在同步调用 Invoke 后,我们将 object 变量转换为 bool 以查看是否应该取消操作。
任何时候调用带有 out 或 ref 参数的 Control.Invoke(或 Control.BeginInvoke)时,都必须注意值类型和引用类型数据之间的区别。(这里的 out 或 ref 是值类型,例如 int 或 bool 等原始类型以及枚举和结构类型等。)当然,即便您使用自定义的引用类型(也叫做类)传递更加复杂的数据,也不需要专门再做其他工作。然而,即使在处理 Invoke/BeginInvoke 的数据类型时会有些麻烦,但相比让多线程代码在竞争状态或使用死锁-释放方法的情况下访问共享数据而言,这算不上是个大问题,所以我认为付出这点小代价是值得的。
小结
我们又一次使用了一个很小的示例来探讨一些复杂的问题。我们不仅利用了多线程从长时间运行的操作中分离 UI,而且还将用户的进一步输入传递给辅助线程以调整其行为。尽管我们原本可以使用共享数据来避免复杂的同步问题(这只有在您的上司试用您的代码时才会产生),但最终我们还是使用了消息传递方案来进行稳定而正确的多线程处理。
参考书目
- 本文的源代码
- Safe, Simple Multithreading in Windows Forms(英文)
- Win32 Multithreaded Programming(英文),Aaron Cohen 和 Mike Woodring 著
- Applied Microsoft .NET Framework Programming(英文),Jeffrey Richter 著
- Essential .NET, Volume 1: The Common Language Runtime(英文),Don Box 著