在.NET客户端程序中应用多线程
原著:Jason Clark
翻译:lxhui
原文出处:MSDN Magazine Jan 2004(.NET)
原代码下载: NET0401.exe (118KB)
通常认为在编写程序中用到多线程是一个高级的编程任务,容易发生错误。在本月的栏目中,我将在一个Windows®窗体应用程序中使用多线程,它具有实际的意义,同时尽量使事情简单。我的目标是在一个普通的需求描述中用最好的办法讲解多线程;客户仍然比较喜欢使用户交互方式的应用程序。
多线程通常和服务器端软件,可扩展性及性能技术联系在一起。 然而,在微软.NET框架中,许多服务器端应用程序都驻留在ASP.NET体系结构中。同样,这些应用程序在逻辑上是单线程的, 因为IIS和ASP.NET在ASP.NET Web Form或Web服务程序中执行了许多或所有的多线程。 在ASP.NET应用程序中你一般可以忽略线程性。 这就是为什么在.NET框架中,多线程更倾向于在客户端使用的一个原因,比如在保证同用户交互的同时而执行一个很长的操作。
线程背景
线程执行代码。它们由操作系统实现,是CPU本身的一种抽象。许多系统都只有一个CPU, 线程是把CPU快速的处理能力分开而执行多个操作的一种方法,使它们看起来好像同步似的。即使一个系统由多个CPU, 但运行的线程一般要比处理器多。
在一个Windows为基础的应用程序中,每一个进程至少要有一个线程,它能够执行机器语言指令。 一旦一个进程的所有线程都中止了,进程本身和它所占用的资源将会被Windows清除。
许多应用程序都被设计为单线程程序,这意味着该程序实现的进程从来不会有超过一个线程在执行,即使在系统中有多个同样的处理在进行。一般一个进程不会关心系统中其他进程的线程的执行。
然而,在单个进程里的所有线程不仅共享虚拟地址空间,而且许多进程级的资源也被共享, 比如文件和窗口句柄等。由于进程资源共享的特征,一个线程必须考虑同一进程中其它线程正在做什么。线程同步是在多线程的进程中保持各线程互不冲突的一门艺术。这也使得多线程比较困难。
最好的方式是只有在需要时才使用多线程,尽量保持事情简单。而且要避免线程同步的情况。在本栏目中,我将向你展示如何为一个普通的客户应用程序做这些事情。
为什么使用多个线程?
已经有许多单线程的客户端应用程序,而且每天还有许多正在被写。在许多情况下,单线程的行为已经足够了。
然而,在某些特定的应用程序中加入一些异步行为可以提高你的经验。典型的数据库前端程序是一个很好的例子。
数据库查询需要花费大量时间完成。在一个单线程的应用程序里,这些查询会导致window消息处理能力阻塞,导致程序的用户交互被冻结。解决办法就是,这个我将要详细描述,用一个线程处理来自操作系统的消息,而另外一个线程做一个很长的工作。在你的代码中使用第二个线程的重要原因就是即使在幕后有一个繁忙的工作在进行,也要保证你的程序的用户交互有响应。
我们首先看一下执行一长串操作的单线程的GUI程序。然后我们将用额外的线程整理该程序。
Figure 1 是用C#写的一个程序的完整源代码。它创建了一个带有文本框和按钮的窗体。如果你在文本框中键入了一个数字,然后按下按钮,这个程序将处理你输入的那个数字,它表示秒数,每秒钟响铃一次代表后台的处理。除了Figure 1 的代码外,你可以从本文开头的链接中下载完整的代码。下载或键入Figure 1 所示的代码,在读之前编译运行它,(编译前,在Visual Studio.NET中右击你的工程,加入Microsoft Visual Basic运行时引用)当你试着运行Figure 1 中的
SingleThreadedForm.cs应用程序时,你马上就会看到几个问题。
在你第一次测试运行时,在文本框中输入20,按下按钮。你将看到程序的用户交互变得完全没有响应了。你不能单击按钮或者编辑文本框,程序也不能被从容的关闭,如果你覆盖该窗体接着会显示一个窗口的部分区域,它将不再重绘自己(见 Figure 2),这个程序被锁定足足20秒, 然而它还可以继续响铃,证明它还没有真正的死掉。这个简单的程序解释了单线程GUI程序的问题。
我将用多线程解决第一个问题:未响应的用户交互,但首先我将解释是什么导致了这种现象。
线程和Windows用户界面
Windows Forms类库建立在大家所熟知的User32 Win32 API 基础上。User32实现了GUI的基本元素,例如窗体,菜单及按钮之类等。所有由User32实现的窗体和控件都使用了事件驱动型结构。
这里简单的讲讲它们如何工作。发生在窗体上的事情,例如鼠标单击,坐标变化,大小变化和重绘请求,都称作事件。在User32 API模型中的事件是由窗体消息表示的。每一个窗体有一个函数,叫做窗口过程或WndProc,它由应用程序实现。WndProc为窗体负责处理窗体消息。
但是WndProc不是神奇的被系统调用。相反,应用程序必须调用GetMessage主动地从系统中得到窗体消息。该消息被应用程序调用DispatchMethod API方法分配到它们的目标窗体的WndProc方法中。应用程序只是简单的循环接收和分配窗口消息,一般叫做消息泵或消息循环。线程拥有所有窗体,这样它就可以提取消息,WndProc函数也被同样的线程所调用。
现在回到Windows Forms类来。Windows Forms在应用程序中对User32的消息结构进行了大约95%的抽象。代替了WndProc函数,Windows Forms程序定义了事件处理器和虚拟函数重载来处理与窗体(窗口)或控件有关的不同系统事件。然而消息提取必须要运行,它在Windows Forms API的Application.Run方法里面实现。
Figure 1 所示的代码似乎仅仅调用了Application.Run接着就退出了。 然而这缺少了透明性:应用程序的主线程在其生命周期里只对Application.Run进行一次调用进行消息提取,其结果却为用应用程序其它部分创造了不同事件处理器的调用。当窗体上的按钮被单击时,在Figure 1 中的OnClick方法被主线程调用,该线程同样要负责在Application.Run中提取消息。
这解释了为什么在一个长操作发生时,用户交互没有响应。如果在一个事件处理器中一个很长的操作 (如数据库查询)发生了,那么主线程就被占用,它又需要不断提取消息。没有能力提取消息并发送到窗口或窗体上, 就没有能力响应调整大小,重绘自己,处理单击或响应用户的任何交互。
在接下来的部分为了执行长操作我将使用公共语言运行时的线程池来修改Figure 1 所示的例子代码,这样主线程仍然可以提取消息。
托管线程池
CLR为每一个托管进程维护了一个线程池,这意味着当你的应用程序主线程需要进行某些异步处理时,你可以很容易的从线程池中借助某个线程实现特定的处理。一旦 处理工作完成,线程被归还到线程池以便以后使用。让我们看一个例子,修改使用线程池。
注意Figure 3 中FlawMultiThreadForm.cs中红色部分表示的行;它们是由Figure 1 中的单线程变为多线程程序 时唯一要修改的代码。如果你编译Figure 3 所示的代码,并设置运行20秒,你将看到当处理20个响铃的请求时,仍然能够响应用户的交互。在客户端程序中使用多线程来响应用户交互是一个吸引人的原因。
然而,在Figure 3 中所做的变化,却引入了一个新问题(如 Figure 3 的名字一样);现在用户可以启动多个同时响铃的长操作。在许多实时应用中这会导致线程间的冲突。为了修正这个线程同步请求,我将讲述这些,但首先熟悉一下CLR''''s线程池。
类库中的System.Threading.ThreadPool类提供了一个访问CLR''''s线程池的API接口, ThreadPool类型不能被实例化,它由静态成员组成。ThreadPool类型最重要的方法是对ThreadPool.QueueUserWorkItem的两个重载。这两种方法让你定义一个你愿意被线程池中的一个线程进行回调的函数。通过使用类库中的WaitCallback委托类型的一个实例来定义你的方法。一种重载让你对异步方法定义一个参数;这是Figure 3 所使用的版本。
下面的两行代码创建一个委托实例,代表了一个Count方法,接下来的调用排队等候让线程池中的方法进行回调。
WaitCallback async = new WaitCallback(Count);
ThreadPool.QueueUserWorkItem(async, count);
ThreadPool.QueueUserWorkItem 的两个方法让你在队列中定义一个异步回调方法,然后立即返回。 同时线程池监视这个队列,接着出列方法,并使用线程池中的一个或多个线程调用该方法。这是CLR''''s线程池的主要用法。
CLR''''s线程池也被系统的其它APIs所使用。例如, System.Threading.Timer对象在定时间隔到来时将会在线程池中排队等候回调。 ThreadPool.RegisterWaitForSingleObject 方法当响应内核系统同步对象有信号时会在线程池中排队等候调用。最后,回调由类库中的不同异步方法执行,这些异步方法又由CLR''''s线程池来执行。
一般来说,一个应用程序仅仅对于简单的异步操作需要使用多线程时毫无疑问应该使用线程池。相比较手工创建一个线程对象,这种方法是被推荐的。调用ThreadPool.QueueUserWorkItem执行简单,而且相对于重复的手动创建线程来说能够更好的利用系统资源。
最简单的线程同步 在本栏目开始我就称保持线程同步而不互相冲突是一门艺术。
Figure 3 所示的FlawedMultiThreadForm.cs应用程序有一个问题:用户可以通过单击按钮引发一个很长的响铃操作,他们可以继续单击按钮而引发更多的响铃操作。如果不是响铃,该长操作是数据库查询或者在进程的内存中进行数据结构操作,你一定不想在同一时间内,有一个以上的线程做同样的工作。最好的情况下这是系统资源的一种浪费,最坏的情况下会导致数据毁灭。
最容易的解决办法就是禁止按钮一类的用户交互元素;两个进程间的通信稍微有点难度。过一会我将给你看如何做这些事情。但首先,让我指出所有线程同步使用的一些线程间通信的形式-从一个线程到另一个线程通信的一种手段。稍后我将讨论大家所熟知的AutoResetEvent对象类型,它仅用在线程间通信。
现在让我们首先看一下为
Figure 3 中FlawedMultiThreadedForm.cs程序中加入的线程同步代码。再一次的,
Figure 4 CorrectMultiThreadedForm.cs程序中红色部分表示的是其先前程序的较小的改动部分。 如果你运行这个程序你将看到当一个长响铃操作在进行时用户交互被禁止了(但没有挂起),响铃完成的时候又被允许了。这次这些代码的变化已经足够了,我将逐个运行他们。
在
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时,你就不会惊奇了,在名字上与窗体类中的控件类上名称一样的目的是说明他们在逻辑作用上是一致的。
作者简介
Jason Clark 为微软和Wintellect公司提供培训和咨询,他是Windows NT和Windows 2000服务器团队的开发前辈。他是Windows 2000服务器程序编程一书的合著者。与Jason的联系方式:
[email protected]
本文地址:http://com.8s8s.com/it/it44739.htm