再谈 Windows 窗体多线程

原创 2005年05月01日 18:03:00

再谈 Windows 窗体多线程


Chris Sells
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.InvokeControl.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 线程,即可获得 ShowProgressDelegatecancel 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 以查看是否应该取消操作。

任何时候调用带有 outref 参数的 Control.Invoke(或 Control.BeginInvoke)时,都必须注意值类型和引用类型数据之间的区别。(这里的 outref 是值类型,例如 intbool 等原始类型以及枚举和结构类型等。)当然,即便您使用自定义的引用类型(也叫做类)传递更加复杂的数据,也不需要专门再做其他工作。然而,即使在处理 Invoke/BeginInvoke 的数据类型时会有些麻烦,但相比让多线程代码在竞争状态或使用死锁-释放方法的情况下访问共享数据而言,这算不上是个大问题,所以我认为付出这点小代价是值得的。

小结

我们又一次使用了一个很小的示例来探讨一些复杂的问题。我们不仅利用了多线程从长时间运行的操作中分离 UI,而且还将用户的进一步输入传递给辅助线程以调整其行为。尽管我们原本可以使用共享数据来避免复杂的同步问题(这只有在您的上司试用您的代码时才会产生),但最终我们还是使用了消息传递方案来进行稳定而正确的多线程处理。

参考书目

再谈Python多线程--避免GIL对性能的影响

GIL是CPython中特有的全局解释器锁(其它实现版本因为有自己线程调度机制,所以没有GIL机制)。本质上讲它就是Python进程中的一把超大锁。这把锁在解释器进程中是全局有效的,它主要锁定Pyth...
  • five3
  • five3
  • 2017年11月17日 17:25
  • 107

再谈消息总线客户端的多线程实现

这篇文章系统得谈论了消息总线客户端在多线程模型下遇到的问题,之前的解决方案以及缺陷,并提出了新的设计方案以及实现方式。...

再谈Python多线程--正确的使用场景

多线程是编程过程中经常会使用到的手段,其目的是为了能提高任务执行的效率。在Python中,我们都知道实现多线程主要有2种方式: 使用threading.Thread()方法继承threading.T...
  • five3
  • five3
  • 2017年11月16日 16:24
  • 96

再谈Python多线程--threading各类锁

使用多线程的好处是提高执行效率,但同时带来了数据同步的问题。即多个线程同时对一个对象进行操作时,可能会出现资源冲突的问题;在不加锁的情况下,代码可能并未像我们想向的那样工作。举个栗子: import...
  • five3
  • five3
  • 2017年11月16日 18:06
  • 74

黑马程序员——多线程4:再谈单例设计模式

------- android培训、java培训、期待与您交流! ----------    我们在前面的博客《设计模式1:单例设计模式》(简称《设计模式1》,下同)一文中简单介绍过单例设计模式...

多线程003 - 再谈CyclicBarrier

java.util.concurrent.CyclicBarrier也是JDK 1.5提供的一个同步辅助类(为什么用也呢?参见再谈CountDownLatch),它允许一组线程互相等待,直到到达某个临...

多线程应用和无边框的窗体移动

  • 2009年05月06日 18:43
  • 393KB
  • 下载

C#多线程实现等待提示窗体

等等窗体代码,UI只有一个lbl 显示提示信息 using System; using System.Collections.Generic; using System.ComponentModel...
  • gykthh
  • gykthh
  • 2014年12月24日 14:54
  • 10593
内容举报
返回顶部
收藏助手
不良信息举报
您举报文章:再谈 Windows 窗体多线程
举报原因:
原因补充:

(最多只允许输入30个字)