在.NET客户端程序中使用多线程(二)

来源:互联网 发布:一流交响乐队知乎 编辑:程序博客网 时间:2024/05/16 14:48

最简单的线程同步

在本栏目开始我就称保持线程同步而不互相冲突是一门艺术。Figure 3 所示的FlawedMultiThreadForm.cs应用程序有一个问题:用户可以通过单击按钮引发一个很长的响铃操作,他们可以继续单击按钮而引发更多的响铃操作。如果不是响铃,该长操作是数据库查询或者在进程的内存中进行数据结构操作,你一定不想在同一时间内,有一个以上的线程做同样的工作。最好的情况下这是系统资源的一种浪费,最坏的情况下会导致数据毁灭。

最容易的解决办法就是禁止按钮一类的用户交互元素;两个进程间的通信稍微有点难度。过一会我将给你看如何做这些事情。但首先,让我指出所有线程同步使用的一些线程间通信的形式-从一个线程到另一个线程通信的一种手段。稍后我将讨论大家所熟知的AutoResetEvent对象类型,它仅用在线程间通信。

现在让我们首先看一下为Figure 3 中FlawedMultiThreadedForm.cs程序中加入的线程同步代码。再一次的,Figure 4 CorrectMultiThreadedForm.cs程序中红色部分表示的是其先前程序的较小的改动部分。 如果你运行这个程序你将看到当一个长响铃操作在进行时用户交互被禁止了(但没有挂起),响铃完成的时候又被允许了。这次这些代码的变化已经足够了,我将逐个运行他们。

Figure 4 CorrectMultiThreadedForm.cs

using System;
using System.Drawing;
using System.Threading;
using System.Windows.Forms;
using Microsoft.VisualBasic;

class App {

// Application entry point
public static void Main() {

// Run a Windows Forms message loop
Application.Run(new CorrectMultiThreadedForm());
}
}

// A Form-derived type
class CorrectMultiThreadedForm : Form{

// Constructor method
public CorrectMultiThreadedForm() {

// Create a textbox
text.Location = new Point(10, 10);
text.Size = new Size(50, 20);
Controls.Add(text);

// Create a button
button.Text = "Beep";
button.Size = new Size(50, 20);
button.Location = new Point(80, 10);

// Register Click event handler
button.Click += new EventHandler(OnClick);

Controls.Add(button);

// Cache a delegate for repeated reuse
enableControls = new BooleanCallback(EnableControls);
}

// Method called by the button's Click event
void OnClick(Object sender, EventArgs args) {
// Get an int from a string
Int32 count = 0;
try { count = Int32.Parse(text.Text); } catch (FormatException) {}

// Count to that number
EnableControls(false);
WaitCallback async = new WaitCallback(Count);
ThreadPool.QueueUserWorkItem(async, count);
}

// Async method beeps once per second
void Count(Object param) {

Int32 seconds = (Int32) param;
for (Int32 index = 0; index < seconds; index++) {
Interaction.Beep();
Thread.Sleep(1000);
}

Invoke(enableControls, new Object[]{true});
}

void EnableControls(Boolean enable) {
button.Enabled = enable;
text.Enabled = enable;
}

// A delegate type and matching field
delegate void BooleanCallback(Boolean enable);
BooleanCallback enableControls;

// Some private fields by which to reference controls
Button button = new Button();
TextBox text = new TextBox();
}

在Figure 4 的末尾处有一个EnableControls的新方法,它允许或禁止窗体上的文本框和按钮控件。在Figure 4 的开始我加入了一个EnableControls调用,在后台响铃操作排队等候之前立即禁止文本框和按钮。到这里线程的同步工作已经完成了一半,因为禁止了用户交互,所以用户不能引发更多的后台冲突操作。在Figure 4 的末尾你将看到一个名为BooleanCallback的委托类型被定义,其签名是同EnableControls方法兼容的。在那个定义之前,一个名为EnableControls的委托域被定义(见例子),它引用了该窗体的EnableControls方法。这个委托域在代码的开始处被分配。

你也将看到一个来自主线程的回调,该主线程为窗体和其控件拥有和提取消息。这个调用通过向EnableControls传递一个true参数来使能控件。这通过后台线程调用窗体的Invoke方法来完成,当其一旦完成其长响铃操时。代码传送的委托引用EnableControls去Invoke,该方法的参数带有一个对象数组。Invoke方法是线程间通信的一个非常灵活的方式,特别是对于Windows Forms类库中的窗口或窗体。在这个例子中,Invoke被用来告诉主GUI线程通过调用EnableControls方法重新使能窗体上的控件。

Figure 4 中的CorrectMultiThreadedForm.cs的变化实现了我早先的建议――当响铃操作在执行时你不想运行,就禁止引发响铃操作的用户交互部分。当操作完成时,告诉主线程重新使能被禁止的部分。对Invoke的调用是唯一的,这一点应该注意。

Invoke方法在 System.Windows.Forms.Controls类型中定义,包含Form类型让类库中的所有派生控件都可使用该方法。Invoke方法的目的是配置了一个从任何线程对为窗体或控件实现消息提取线程的调用。

当访问控件派生类时,包括Form类,从提取控件消息的线程来看你必须这样做。这在单线程的应用程序中是很自然的事情。但是当你从线程池中使用多线程时,要避免从后台线程中调用用户交互对象的方法和属性是很重要的。相反,你必须使用控件的Invoke方法间接的访问它们。Invoke是控件中很少见的一个可以安全的从任何线程中调用的方法,因为它是用Win32的PostMessage API实现的。

使用Control.Invoke方法进行线程间的通信有点复杂。但是一旦你熟悉了这个过程,你就有了在你的客户端程序中实现多线程目标的工具。本栏目的剩余部分将覆盖其它一些细节,但是Figure 4 中的CorrectMultiThreadedForm.cs应用程序是一个完整的解决办法:当执行任意长的操作时仍然能够响应用户的其它操作。尽管大多数的用户交互被禁止,但用户仍然可以重新配置和调整窗口,也可以关闭程序。然而,用户不能任意使用程序的异步行为。这个小细节能够让你对你的程序保持自信心。

在我的第一个线程同步程序中,没有使用任何传统的线程结构,例如互斥或信号量,似乎一钱不值。然而,我却使用了禁止控件的最普通的方法。

细节-实现一个取消按钮

有时你想为你的用户提供一种取消长操作的方法。你所需要的就是你的主线程同后台线程之间的一些通信方法,通知后台线程操作不再被需要,可以停止。System.Threading名字空间为这个方法提供了一个类:AutoResetEvent。

AutoResetEvent是线程间通信的一种简单机制。一个AutoResetEvent对象可以有两种状态中的一个:有信号的和无信号的。当你创建一个AutoResetEvent实例时,你可以通过构造函数的参数来决定其初始状态。然后感知该对象的线程通过检查AutoResetEvent对象的状态,或者用 AutoResetEvent对象的Set或Reset方法调整其状态,进行相互通信。

在某种程度上AutoResetEvent很像一个布尔类型,但是它提供的特征使其更适合于在线程间进行通信。这样的一个例子就是它有这种能力:一个线程可以有效的等待直到一个AutoResetEvent对象从一个无信号的状态变为有信号的状态。它是通过在该对象上调用WaitOne实现的。任何一个线程对一个无信号的AutoResetEvent对象调用了WaitOne,就会被有效的阻塞直到其它线程使该对象有信号。使用布尔变量线程必须在一个循环中登记该变量,这是无效率的。一般来说没有必要使用Reset来使一个AutoResetEvent变为无信号,因为当其它线程感知到该对象为有信号时,它会被立即自动的设为无信号的。

现在你需要一种让你的后台线程无阻塞的测试AutoResetEvent对象的方法,你会有许多工具实现线程的取消。为了完成这些,调用带有WaitOne的重载窗体并指出一个零毫秒的超出时间,以零毫秒为超出时间的WaitOne会立即返回,而不管AutoResetEvent对象的状态是否为有信号。如果返回值为true,这个对象是有信号的;否则由于时间超出而返回。

我们整理一下实现取消的特点。如果你想实现一个取消按钮,它能够取消后台线程中的一个长操作,按照以下步骤:

在你的窗体上加入AutoResetEvent域类型

通过在AutoResetEvent的构造函数中传入false参数,设置该对象初始状态为无信号的。 接着在你的窗体上保 存该对象的引用域,这是为了能够在窗体的整个生命周期内可以对后台线程的后台操作实现取消操作。

在你窗体上加入一个取消按钮。

在取消按钮的Click事件处理器中,通过调用AutoResetEvent对象的Set方法使其有信号。

同时,在你的后台线程的逻辑中周期性地在AutoResetEvent对象上调用WaitOne来检查用户是否取消了。

if(cancelEvent.WaitOne(0, false)){
// cancel operation
}

你必须记住使用零毫秒参数,这样可以避免在后台线程操作中不必要的停顿。

如果用户取消了操作,通过主线程AutoResetEvent会被设为有信号的。 当WaitOne返回true时你的后台线程会 得到警告,并停止操作。同时在后台线程中由于调用了WaitOne该事件会被自动的置为无信号状态。

为了能够看到取消长操作窗体的例子,你可以下载CancelableForm.cs文件。这个代码是一个完整的程序,它与Figure 4 中的CorrectMultiThreadedForm.cs只有稍微的不同。

注意在CancelableForm.cs也采用了比较高级的用法Control.Invoke, 在那里EnableControls方法被设计用来调用它自己如果当它被一个错误的线程所调用时。在它使用窗体上的任何GUI对象的方法或属性时要先做这个检查。 这样能够使得EnableControls能够从任何线程中直接安全的调用,在方法的实现中有效的隐藏了Invoke调用的复杂性。这些可以使应用程序更加有维护性。注意在这个例子中同样使用了Control.BeginInvoke, 它是Control.Invoke的异步版本。

你也许注意到取消的逻辑依赖于后台线程通过WaitOne调用周期性的取消检查的能力。 但是如果正在讨论的问题不能被取消怎么办?如果后台操作是一个单个调用,像DataAdapter.Fill,它会花很长时间?有时会有解决办法的,但并不总是。

如果你的长操作根本不能取消,你可以使用一个伪取消的方法来完成你的操作,但在你的程序中不要影响你的操作结果。这不是技术上的取消操作,它把一个可忍受的操作帮定到一个线程池中,但这是在某种情况下的一种折中办法。如果你实现了类似的解决办法,你应该从你的取消按钮事件处理器中直接使能你已禁止的UI元素,而不要还依赖于被绑定的后台线程通过Invoke调用使能你的控件。同样重要的使设计你的后台操作线程,当其返回时测试一下它是否被取消,以便它不影响现在被取消的操作的结果。

这种长操作取消是比较高级的方法,它只在某些情况下才可行。例如,数据库查询的伪取消就是这样,但是一个数据库的更新,删除,插入伪取消是一个滞后的操作。有永久的操作结果或与反馈有关的操作,像声音和图像,就不容易使用伪取消方法,因为操作的结果在用户取消以后是非常明显的。

更多细节-有关定时器

在应用程序中需要一个定时器来引发一个定期的任务一定不一般。例如,如果你的程序在窗体的状态条上显示当前时间,你可能每5秒钟更新一次时间。System.Threading 名字空间包括了一个名为Timer多线程定时器类。

当你创建一个定时器类的实例时,你为定时器回调指明了一个以毫秒为单位的周期,而且你也传递给该对象一个委托用来每过一个时钟周期调用你。回调发生在线程池中的线程上。事实上,每次时钟周期到来时真正发生的是一个工作条目在线程池中排队;一般来说一个调用会马上发生的,但是如果线程池比较忙,这个回调也许会在稍后的一个时间点发生。

如果你考虑在你的程序中使用多线程,你也许会考虑使用定时器类。然而,如果你的程序使用了Windows窗体,你不必使用多线程的行为,在System.Windows.Forms名字空间中有另外一个也叫Timer的定时器类。

System.Windows.Forms.Timer与其多线程的同伴比起来有一个明显的好处:因为它不是多线程的,所以不会在其它线程中对你进行回调,而且更适合为应用程序提取窗口消息的主线程。实际上System.Windows.Forms.Timer的实现是在系统中使用了WM_TIMER的一个窗口消息。这种方法在你的System.Windows.Forms.Timer的事件处理器中不必担心线程同步,线程间通信之类的问题。

对于Windows窗体类程序,作为一个很好的技巧就是使用System.Windows.Forms.Timer类, 除非你特别需要线程池中的线程对你进行回调。既然这种要求很少见,为了使事情简单,把使用System.Windows.Forms.Timer作为一个规则,即使在你的程序的其它地方使用了多线程。

展望将来

微软最近展示了一个即将出现的GUI API,代号为“Avalon”,本期MSDN杂志的问题列表中(见70页)Charles Petzold''''s的文章描述了其特点。在Avalon框架中用户接口元素没有被系与一个特殊的线程;作为更换每个用户接口元素与一个单独的逻辑线程上下文相关联,在UIContext类中实现。但是当你发现UIContext类中包含了Invoke方法,及其姊妹BeginInvoke时,你就不会惊奇了,在名字上与窗体类中的控件类上名称一样的目的是说明他们在逻辑作用上是一致的。