一位牛人的多线程和异步调用文章
转自小顾问原文 一位牛人的多线程和异步调用文章
摘要:本章讨论与智能客户端应用程序中多线程的使用有关的问题。为了最大限度地提高智能客户端应用程序的响应能力,需要仔细考虑如何和何时使用多线程。线程可以大大提高应用程序的可用性和性能,但是当您确定它们将如何与用户界面交互时,需要对其进行非常仔细的考虑。
线程是基本执行单元。单线程执行一系列应用程序指令,并且在应用程序中从头到尾都经由单一的逻辑路径。所有的应用程序都至少有一个线程,但是您可以将它们设计成使用多线程,并且每个线程执行一个单独的逻辑。在应用程序中使用多线程,可以将冗长的或非常耗时的任务放在后台处理。即使在只有单处理器的计算机上,使用多线程也可以非常显著地提高应用程序的响应能力和可用性。
使用多线程来开发应用程序可能非常复杂,特别是当您没有仔细考虑锁定和同步问题时。当开发智能客户端应用程序时,需要仔细地评估应该在何处使用多线程和如何使用多线程,这样就可以获得最大的好处,而无需创建不必要的复杂并难于调试的应用程序。
本章研究对于开发多线程智能客户端应用程序最重要的一些概念。它介绍了一些值得推荐的在智能客户端应用程序中使用多线程的方法,并且描述了如何实现这些功能。
一、.NET Framework 中的多线程处理
所有的 .NET Framework 应用程序都是使用单线程创建的,单线程用于执行该应用程序。在智能客户端应用程序中,这样的线程创建并管理用户界面 (UI),因而称为 UI 线程。
可以将 UI 线程用于所有的处理,其中包括 Web 服务调用、远程对象调用和数据库调用。然而,以这种方式使用 UI 线程通常并不是 一个好主意。在大多数情况下,您不能预测调用 Web 服务、远程对象或数据库会持续多久,而且在 UI 线程等待响应时,您可能会导致 UI 冻结。
通过创建附加线程,应用程序可以在不使用 UI 线程的情况下执行额外的处理。当应用程序调用 Web 服务时,可以使用多线程来防止 UI 冻结或并行执行某些本地任务,以整体提高应用程序的效率。在大多数情况下,您应该坚持在单独的线程上执行任何与 UI 无关的任务。
1.1 同步和异步调用之间的选择
应用程序既可以进行同步调用,也可以进行异步调用。同步 调用在继续之前等待响应或返回值。如果不允许调用继续,就说调用被阻塞 了。
异步 或非阻塞 调用不等待响应。异步调用是通过使用单独的线程执行的。原始线程启动异步调用,异步调用使用另一个线程执行请求,而与此同时原始的线程继续处理。
对于智能客户端应用程序,将 UI 线程中的同步调用减到最少非常重要。在设计智能客户端应用程序时,应该考虑应用程序将进行的每个调用,并确定同步调用是否会对应用程序的响应和性能产生负面影响。
仅在下列情况下,使用 UI 线程中的同步调用:
• |
执行操纵 UI 的操作。 |
• |
执行不会产生导致 UI 冻结的风险的小的、定义完善的操作。 |
在下列情况下,使用 UI 线程中的异步调用:
• |
执行不影响 UI 的后台操作。 |
• |
调用位于网络的其他系统或资源。 |
• |
执行可能花费很长时间才能完成的操作。 |
1.2 前台线程和后台线程之间的选择
.NET Framework 中的所有线程都被指定为前台线程或后台线程。这两种线程唯一的区别是 — 后台线程不会阻止进程终止。在属于一个进程的所有前台线程终止之后,公共语言运行库 (CLR) 就会结束进程,从而终止仍在运行的任何后台线程。
在默认情况下,通过创建并启动新的 Thread 对象生成的所有线程都是前台线程,而从非托管代码进入托管执行环境中的所有线程都标记为后台线程。然而,通过修改 Thread.IsBackground 属性,可以指定一个线程是前台线程还是后台线程。通过将 Thread.IsBackground 设置为 true,可以将一个线程指定为后台线程;通过将 Thread.IsBackground 设置为 false,可以将一个线程指定为前台线程。
注有关 Thread 对象的详细信息,请参阅本章后面的“使用 Thread 类”部分。
在大多数应用程序中,您会选择将不同的线程设置成前台线程或后台线程。通常,应该将被动侦听活动的线程设置为后台线程,而将负责发送数据的线程设置为前台线程,这样,在所有的数据发送完毕之前该线程不会被终止。
只有在确认线程被系统随意终止没有不利影响时,才应该使用后台线程。如果线程正在执行必须完成的敏感操作或事务操作,或者需要控制关闭线程的方式以便释放重要资源,则使用前台线程。
1.3 处理锁定和同步
有时在构建应用程序时,创建的多个线程都需要同时使用关键资源(例如数据或应用程序组件)。如果不仔细,一个线程就可能更改另一个线程正在使用的资源。其结果可能就是该资源处于一种不确定的状态并且呈现为不可用。这称为 争用情形。在没有仔细考虑共享资源使用的情况下使用多线程的其他不利影响包括:死锁、线程饥饿和线程关系问题。
为了防止这些影响,当从两个或多个线程访问一个资源时,需要使用锁定和同步技术来协调这些尝试访问此资源的线程。
使用锁定和同步来管理线程访问共享资源是一项复杂的任务,只要有可能,就应该通过在线程之间传送数据而不是提供对单个实例的共享访问来避免这样做。
假如不能排除线程之间的资源共享,则应该:
• |
使用 Microsoft Visual C# 中的 lock 语句和 Microsoft Visual Basic .NET 中的 SyncLock 语句来创建临界区,但要小心地从临界区内调用方法来防止死锁。 |
• |
使用 Synchronized 方法获得线程安全的 .NET 集合。 |
• |
使用 ThreadStatic 属性创建逐线程成员。 |
• |
使用重新检查 (double-check) 锁或 Interlocked.CompareExchange 方法来防止不必要的锁定。 |
• |
确保静态声明是线程安全的。 |
有关锁定和同步技术的详细信息,请参阅 http://msdn.microsoft.com/library/en-us/cpgenref/html/cpconthreadingdesignguidelines.asp 上的 .NET Framework General Reference 中的“Threading Design Guidelines”。
1.4 使用计时器
在某些情况下,可能不需要使用单独的线程。如果应用程序需要定期执行简单的与 UI 有关的操作,则应该考虑使用进程计时器。有时,在智能客户端应用程序中使用进程计时器,以达到下列目:
• |
按计划定期执行操作。 |
• |
在使用图形时保持一致的动画速度(而不管处理器的速度)。 |
• |
监视服务器和其他的应用程序以确认它们在线并且正在运行。 |
.NET Framework 提供三种进程计时器:
• |
System.Window.Forms.Timer |
• |
System.Timers.Timer |
• |
System.Threading.Timer |
如果想要在 Windows 窗体应用程序中引发事件,System.Window.Forms.Timer 就非常有用。它经过了专门的优化以便与 Windows 窗体一起使用,并且必须用在 Windows 窗体中。它设计成能用于单线程环境,并且可以在 UI 线程上同步操作。这就意味着该计时器从来不会抢占应用程序代码的执行(假定没有调用 Application.DoEvents),并且对与 UI 交互是安全的。
System.Timers.Timer 被设计并优化成能用于多线程环境。与 System.Window.Forms.Timer 不同,此计时器调用从 CLR 线程池中获得的辅助线程上的事件处理程序。在这种情况下,应该确保事件处理程序不与 UI 交互。System.Timers.Timer 公开了可以模拟 System.Windows.Forms.Timer 中的行为的 SynchronizingObject 属性,但是除非需要对事件的时间安排进行更精确的控制,否则还是应该改为使用 System.Windows.Forms.Timer。
System.Threading.Timer 是一个简单的轻量级服务器端计时器。它并不是内在线程安全的,并且使用起来比其他计时器更麻烦。此计时器通常不适合 Windows 窗体环境。表 6.1 列出了每个计时器的各种属性。
表 6.1 进程计时器属性
属性 | System.Windows.Forms | System.Timers | System.Threading |
计时器事件运行在什么线程中? |
UI 线程 |
UI 线程或辅助线程 |
辅助线程 |
实例是线程安全的吗? |
否 |
是 |
否 |
需要 Windows 窗体吗? |
是 |
否 |
否 |
最初的计时器事件可以调度吗? |
否 |
否 |
是 |
二、何时使用多线程
在许多常见的情况下,可以使用多线程处理来显著提高应用程序的响应能力和可用性。
应该慎重考虑使用多线程来:
• |
通过网络(例如,与 Web 服务器、数据库或远程对象)进行通信。 |
• |
执行需要较长时间因而可能导致 UI 冻结的本地操作。 |
• |
区分各种优先级的任务。 |
• |
提高应用程序启动和初始化的性能。 |
非常详细地分析这些使用情况是非常有用的。
2.1 通过网络进行通信
智能客户端可以采用许多方式通过网络进行通信,其中包括:
• |
远程对象调用,例如,DCOM、RPC 或 .NET 远程处理 |
• |
基于消息的通信,例如,Web 服务调用和 HTTP 请求。 |
• |
分布式事务处理。 |
许多因素决定了网络服务对应用程序请求的响应速度,其中包括请求的性质、网络滞后时间、连接的可靠性和带宽、单个服务或多个服务的繁忙程度。
这种不可预测性可能会引起单线程应用程序的响应问题,而多线程处理常常是一种好的解决方案。应该为网络上的所有通信创建针对 UI 线程的单独线程,然后在接收到响应时将数据传送回 UI 线程。
为网络通信创建单独的线程并不总是必要的。如果应用程序通过网络进行异步通信,例如使用 Microsoft Windows 消息队列(也称为 MSMQ),则在继续执行之前,它不会等待响应。然而,即使在这种情况下,您仍然应该使用单独的线程来侦听响应,并且在响应到达时对其进行处理。
2.2 执行本地操作
即使在处理发生在本地的情况下,有些操作也可能花费很长时间,足以对应用程序的响应产生负面影响。这样的操作包括:
• |
图像呈现。 |
• |
数据操纵。 |
• |
数据排序。 |
• |
搜索。 |
不应该在 UI 线程上执行诸如此类的操作,因为这样做会引起应用程序中的性能问题。相反,应该使用额外的线程来异步执行这些操作,防止 UI 线程阻塞。
在许多情况下,也应该这样设计应用程序,让它报告正在进行的后台操作的进程和成功或失败。可能还会考虑允许用户取消后台操作以提高可用性。
2.3 区分各种优先级的任务
并不是应用程序必须执行的所有任务都具有相同的优先级。一些任务对时间要求很急,而一些则不是。在其他的情况中,您或许会发现一个线程依赖于另一个线程上的处理结果。
应该创建不同优先级的线程以反映正在执行的任务的优先级。例如,应该使用高优先级线程管理对时间要求很急的任务,而使用低优先级线程执行被动任务或者对时间不敏感的任务。
2.4 应用程序启动
应用程序在第一次运行时常常必须执行许多操作。例如,它可能需要初始化自己的状态,检索或更新数据,打开本地资源的连接。应该考虑使用单独的线程来初始化应用程序,从而使得用户能够尽快地开始使用该应用程序。使用单独的线程进行初始化可以增强应用程序的响应能力和可用性。
如果确实在单独的线程中执行初始化,则应该通过在初始化完成之后,更新 UI 菜单和工具栏按钮的状态来防止用户启动依赖于初始化尚未完成的操作。还应该提供清楚的反馈消息来通知用户初始化的进度。
三、创建和使用线程
在 .NET Framework 中有几种方法可以创建和使用后台线程。可以使用 ThreadPool 类访问由 .NET Framework 管理的给定进程的线程池,也可以使用 Thread 类显式地创建和管理线程。另外,还可以选择使用委托对象或者 Web 服务代理来使非 UI 线程上发生特定处理。本节将依次分析各种不同的方法,并推荐每种方法应该在何时使用。
3.1 使用 ThreadPool 类
到现在为止,您可能会认识到许多应用程序都会从多线程处理中受益。然而,线程管理并不仅仅是每次想要执行一个不同的任务就创建一个新线程的问题。有太多的线程可能会使得应用程序耗费一些不必要的系统资源,特别是,如果有大量短期运行的操作,而所有这些操作都运行在单独线程上。另外,显式地管理大量的线程可能是非常复杂的。
线程池化技术通过给应用程序提供由系统管理的辅助线程池解决了这些问题,从而使得您可以将注意力集中在应用程序任务上而不是线程管理上。
在需要时,可以由应用程序将线程添加到线程池中。当 CLR 最初启动时,线程池没有包含额外的线程。然而,当应用程序请求线程时,它们就会被动态创建并存储在该池中。如果线程在一段时间内没有使用,这些线程就可能会被处置,因此线程池是根据应用程序的要求缩小或扩大的。
注每个进程都创建一个线程池,因此,如果您在同一个进程内运行几个应用程序域,则一个应用程序域中的错误可能会影响相同进程内的其他应用程序域,因为它们都使用相同的线程池。
线程池由两种类型的线程组成:
• |
辅助线程。辅助线程是标准系统池的一部分。它们是由 .NET Framework 管理的标准线程,大多数功能都在它们上面执行。 |
• |
完成端口线程.这种线程用于异步 I/O 操作(通过使用 IOCompletionPorts API)。 |
注,如果应用程序尝试在没有 IOCompletionPorts 功能的计算机上执行 I/O 操作,它就会还原到使用辅助线程。
对于每个计算机处理器,线程池都默认包含 25 个线程。如果所有的 25 个线程都在被使用,则附加的请求将排入队列,直到有一个线程变得可用为止。每个线程都使用默认堆栈大小,并按默认的优先级运行。
下面代码示例说明了线程池的使用。
private void ThreadPoolExample() { WaitCallback callback = new WaitCallback( ThreadProc ); ThreadPool.QueueUserWorkItem( callback ); }
在前面的代码中,首先创建一个委托来引用您想要在辅助线程中执行的代码。.NET Framework 定义了 WaitCallback 委托,该委托引用的方法接受一个对象参数并且没有返回值。下面的方法实现您想要执行的代码。
private void ThreadProc( Object stateInfo ) { // Do something on worker thread. }
可以将单个对象参数传递给 ThreadProc 方法,方法是将其指定为 QueueUserWorkItem 方法调用中的第二个参数。在前面的示例中,没有给 ThreadProc 方法传递参数,因此 stateInfo 参数为空。
在下面的情况下,使用 ThreadPool 类:
• |
有大量小的独立任务要在后台执行。 |
• |
不需要对用来执行任务的线程进行精细控制。 |
3.2 使用 Thread 类
使用 Thread 类可以显式管理线程。这包括 CLR 创建的线程和进入托管环境执行代码的 CLR 以外创建的线程。CLR 监视其进程中曾经在 .NET Framework 内执行代码的所有线程,并且使用 Thread 类的实例来管理它们。
只要有可能,就应该使用 ThreadPool 类来创建线程。然而,在一些情况下,您还是需要创建并管理您自己的线程,而不是使用 ThreadPool 类。
在下面的情况下,使用 Thread 对象:
• |
需要具有特定优先级的任务。 |
• |
有可能运行很长时间的任务(这样可能阻塞其他任务)。 |
• |
需要确保只有一个线程可以访问特定的程序集。 |
• |
需要有与线程相关的稳定标识。 |
Thread 对象包括许多属性和方法,它们可以帮助控制线程。可以设置线程的优先级,查询当前的线程状态,中止线程,临时阻塞线程,并且执行许多其他的线程管理任务。
下面的代码示例演示了如何使用 Thread 对象创建并启动一个线程。
static void Main() { System.Threading.Thread workerThread = new System.Threading.Thread( SomeDelegate ); workerThread.Start(); } public static void SomeDelegate () { Console.WriteLine( "Do some work." ); }
在这个示例中,SomeDelegate 是一个 ThreadStart 委托 — 指向将要在新线程中执行的代码的引用。Thread.Start 向操作系统提交请求以启动线程。
如果采用这种方式实例化一个新线程,就不可能向 ThreadStart 委托传递任何参数。如果需要将一个参数传递给要在另一个线程中执行的方法,应该用所需的方法签名创建一个自定义委托并异步调用它。
有关自定义委托的详细信息,请参阅本章后面的“使用委托”部分。
如果需要从单独的线程中获得更新或结果,可以使用回调方法 — 一个委托,引用在线程完成工作之后将要调用的代码 — 这就使得线程可以与 UI 交互。有关详细信息,请参阅本章后面的“使用任务处理 UI 线程和其他线程之间的交互”部分。
3.3 使用委托
委托是指向方法的引用(或指针)。在定义委托时,可以指定确切的方法签名,如果其他的方法想要代表该委托,就必须与该签名相匹配。所有委托都可以同步和异步调用。
下面的代码示例展示了如何声明委托。这个示例展示了如何将一个长期运行的计算实现为一个类中的方法。
delegate string LongCalculationDelegate( int count );
如果 .NET Framework 遇到像上面一样的委托声明,就隐式声明了一个从 MultiCastDelegate 类继承的隐藏类,正如下面的代码示例中所示。
Class LongCalculationDelegate : MutlicastDelegate { public string Invoke( count ); public void BeginInvoke( int count, AsyncCallback callback, object asyncState ); public string EndInvoke( IAsyncResult result ); }
委托类型 LongCalculationDelegate 用于引用接受单个整型参数并返回一个字符串的方法。下面的代码示例举例说明了一个这种类型的委托,它引用带有相关签名的特定方法。
LongCalculationDelegate longCalcDelegate = new LongCalculationDelegate( calculationMethod );
在本示例中,calculationMethod 是实现您想要在单独线程上执行的计算的方法的名称。
可以同步或异步调用委托实例所引用的方法。为了同步调用它,可以使用下面的代码。
string result = longCalcDelegate( 10000 );
该代码在内部使用上面的委托类型中定义的 Invoke 方法。因为 Invoke 方法是同步调用,所以此方法只在调用方法返回之后才返回。返回值是调用方法的结果。
更常见的情况是,为了防止调用线程阻塞,您将选择通过使用 BeginInvoke 和 B>EndInvoke 方法来异步调用委托。异步委托使用 .NET Framework 中的线程池化功能来进行线程管理。.NET Framework 实现的标准异步调用 模式提供 BeginInvoke 方法来启动线程上所需的操作,并且它提供 EndInvoke 方法来允许完成异步操作以及将任何得到的数据传送回调用线程。在后台处理完成之后,可以调用回调方法,其中,可以调用 EndInvoke 来获取异步操作的结果。
当调用 BeginInvoke 方法时,它不会等待调用完成;相反,它会立即返回一个 IAsyncResult 对象,该对象可以用来监视该调用的进度。可以使用 IAsyncResult 对象的 WaitHandle 成员来等待异步调用完成,或使用 IsComplete 成员轮询是否完成。如果在调用完成之前调用 EndInvoke 方法,它就会阻塞,并且只在调用完成之后才返回。然而,您应该慎重,不要使用这些技术来等待调用完成,因为它们可能阻塞 UI 线程。一般来说,回调机制是通知调用已经完成的最好方式。
异步执行委托引用的方法
1. |
定义代表长期运行的异步操作的委托,如下面的示例所示: delegate string LongCalculationDelegate( int count ); |
2. |
定义一个与委托签名相匹配的方法。下面的示例中的方法模拟需要消耗较多时间的操作,方法是使线程返回之前睡眠 count 毫秒。 private string LongCalculation( int count ) { Thread.Sleep( count ); return count.ToString(); } |
3. |
定义与 .NET Framework 定义的 AsyncCallback 委托相对应的回调方法,如下面的示例所示。 private void CallbackMethod( IAsyncResult ar ) { // Retrieve the invoking delegate. LongCalculationDelegate dlgt = (LongCalculationDelegate)ar.AsyncState; // Call EndInvoke to retrieve the results. string results = dlgt.EndInvoke(ar); } |
4. |
创建一个委托实例,它引用您想要异步调用的方法,并且创建一个 AsyncCallback 委托来引用回调方法,如下面的代码示例所示。 LongCalculationDelegate longCalcDelegate = new LongCalculationDelegate( calculationMethod ); AsyncCallback callback = new AsyncCallback( CallbackMethod ); |
5. |
从调用线程中开始异步调用,方法是调用引用您想要异步执行的代码的委托中的 BeginInvoke 方法。 longCalcDelegate.BeginInvoke( count, callback, longCalcDelegate ); 方法 LongCalculation 是在辅助线程上调用的。当它完成时,就调用 CallbackMethod 方法,并且获取计算的结果。 注回调方法是在非 UI 线程上执行的。要修改 UI,需要使用某些技术来从该线程切换到 UI 线程。有关详细信息,请参阅本章后面的“使用任务处理 UI 线程和其他线程之间的交互”部分。 |
可以使用自定义委托来将任意参数传送给要在单独的线程上执行的方法(有时当您直接使用 Thread 对象或线程池创建线程时,您无法这样做)。
当需要在应用程序 UI 中调用长期运行的操作时,异步调用委托非常有用。如果用户在 UI 中执行预期要花很长时间才能完成的操作,您肯定并不希望该 UI 冻结,也不希望它不能刷新自己。使用异步委托,可以将控制权返回给主 UI 线程以执行其他操作。
在以下情况中,您应该使用委托来异步调用方法:
• |
需要将任意参数传递给您想要异步执行的方法。 |
• |
您想要使用 .NET Framework 提供的异步调用 模式。 |
注有关如何使用 BeginInvoke 和 EndInvoke 进行异步调用的详细信息,请参阅http://msdn.microsoft.com/library/en-us/cpguide/html/cpovrasynchronousprogrammingoverview.asp 上的 .NET Framework Developer's Guide 中的“Asynchronous Programming Overview”。
3.4 异步调用 Web 服务
应用程序常常使用 Web 服务与网络资源进行通信。一般来说,不应该从 UI 线程同步调用 Web 服务,这是因为 Web 服务调用的响应时间变化很大,正如网络上所有交互的响应时间的情况一样。相反,应该从客户端异步调用所有的 Web 服务。
要了解如何异步调用 Web 服务,可以考虑使用下面简单的 Web 服务,它睡眠一段时间,然后返回一个字符串,指示它已经完成了它的操作。
[WebMethod] public string ReturnMessageAfterDelay( int delay ) { System.Threading.Thread.Sleep(delay); return "Message Received"; }
当在 Microsoft Visual Studio .NET 开发系统中引用 Web 服务时,它会自动生成一个代理。代理是一个类,它允许使用 .NET Framework 实现的异步调用 模式异步调用 Web 服务。如果您分析一下生成的代理,您就会看到下面三个方法。
public string ReturnMessageAfterDelay( int delay ) { object[] results = this.Invoke( "ReturnMessageAfterDelay", new object[] {delay} ); return ((string)(results[0])); } public System.IAsyncResult BeginReturnMessageAfterDelay( int delay, System.AsyncCallback callback, object asyncState ) { return this.BeginInvoke( "ReturnMessageAfterDelay", new object[] {delay}, callback, asyncState ); } public string EndReturnMessageAfterDelay( System.IAsyncResult asyncResult ) { object[] results = this.EndInvoke( asyncResult ); return ((string)(results[0])); } 第一个方法是调用 Web 服务的同步方法。第二个和第三个方法是异步方法。可以如下所示异步调用 Web 服务。 private void CallWebService() { localhost.LongRunningService serviceProxy = new localhost.LongRunningService(); AsyncCallback callback = new AsyncCallback( Completed ); serviceProxy.BeginReturnMessageAfterDelay( callback, serviceProxy, null ); } 这个示例非常类似于使用自定义委托的异步回调示例。当 Web 服务返回时,用将要调用的方法定义一个 AsyncCallback 对象。用指定回调和代理本身的方法调用异步 Web 服务,正如下面的代码示例所示: void Completed( IAsyncResult ar ) { localhost.LongRunningService serviceProxy = (localhost.LongRunningService)ar.AsyncState; string message = serviceProxy.EndReturnMessageAfterDelay( ar ); } 当 Web 服务完成时,就调用完成的回调方法。然后,可以通过调用代理上的 EndReturnMessageAfterDelay 来获取异步结果。
3.5 使用任务处理 UI 线程和其他线程之间的交互
设计多线程应用程序最复杂的一个方面是处理 UI 线程和其他线程之间的关系。用于应用程序的后台线程并不直接与应用程序 UI 交互,这一点相当关键。如果后台线程试图修改应用程序的 UI 中的控件,该控件就可能会处于一种未知的状态。这可能会在应用程序中引起较大的问题,并难于诊断。例如,当另一个线程正在给动态生成的位图传送新数据时,它或许不能显示。或者,当数据集正在刷新时,绑定到数据集的组件可能会显示冲突信息。
为了避免这些问题,应该从不允许 UI 线程以外的线程更改 UI 控件或绑定到 UI 的数据对象。您应该始终尽力维护 UI 代码和后台处理代码之间的严格分离。
将 UI 线程与其他线程分离是一个良好的做法,但是您仍然需要在这些线程之间来回传递信息。多线程应用程序通常需要具有下列功能:
• |
从后台线程获得结果并更新 UI。 |
• |
当后台线程执行它的处理时向 UI 报告进度。 |
• |
从 UI 控制后台线程,例如让用户取消后台处理。 |
从处理后台线程的代码中分离 UI 代码的有效方法是,根据任务构造应用程序,并且使用封装所有任务细节的对象代表每个任务。
任务是用户期望能够在应用程序内完成的一个工作单元。在多线程处理的环境中,Task 对象封装了所有的线程细节,这样它们就可以从 UI 中清晰地分离出来。
通过使用 Task 模式,在使用多线程时可以简化代码。Task 模式将线程管理代码从 UI 代码中清晰地分离出来。UI 使用 Task 对象提供的属性和方法来执行行动,比如启动和停止任务、以及查询它们的状态。Task 对象也可以提供许多事件,从而允许将状态信息传送回 UI。这些事件都应该在 UI 线程内激发,这样,UI 就不需要了解后台线程。
使用 Task 对象可以充分简化线程交互,Task 对象虽然负责控制和管理后台线程,但是激发 UI 可以使用并且保证在 UI 线程上的事件。Task 对象可以在应用程序的各个部分中重用,甚至也可以在其他的应用程序中重用。
图 6.1 说明了使用 Task 模式时代码的整体结构。
图 6.1 使用 Task 模式时的代码结构
注Task 模式可以用来在单独的线程上执行本地后台处理任务,或者与网络上的远程服务异步交互。在后者的情况下,Task 对象常常称为服务代理。服务代理可以使用与 Task 对象相同的模式,并且可以支持使其与 UI 交互更容易的属性和事件。
因为 Task 对象封装了任务的状态,所以可以用它来更新 UI。要这样做,无论何时发生更改,都可以让 Task 对象针对主 UI 线程激发 PropertyChanged 事件。这些事件提供一种标准而一致的方法来传递属性值更改。
可以使用任务来通知主 UI 线程进度或其他状态改变。例如,当任务变得可用时,可以将其设置为已启用的标志,该标志可用于启用相应的菜单项和工具栏按钮。相反,当任务变得不可用(例如,因为它还在进行中),可以将已启用标志设置为 false,这会导致主 UI 线程中的事件处理程序禁用适当的菜单项和工具栏按钮。
也可以使用任务来更新绑定到 UI 的数据对象。应该确保数据绑定到 UI 控件的任何数据对象在 UI 线程上更新。例如,如果将 DataSet 对象绑定到 UI 并从 Web 服务检索更新信息,就可以将新数据传递给 UI 代码。然后,UI 代码将新数据合并到 UI 线程上绑定的 DataSet 对象中。
可以使用 Task 对象实现后台处理和线程控制逻辑。因为 Task 对象封装了必要的状态和数据,所以它可以协调在一个或更多线程中完成任务所需的工作,并且在需要时传递更改和通知到应用程序的 UI。可以实现所有必需的锁定和同步并将其封装在 Task 对象中,这样 UI 线程就不必处理这些问题。
3.6 定义 Task 类
下面的代码示例显示了管理长期计算任务的类定义。
注虽然该示例比较简单,但是它可以很容易地扩展为支持在应用程序的 UI 中集成的复杂后台任务。
public class CalculationTask { // Class Members… public CalculationTask(); public void StartCalculation( int count ); public void StopCalculation(); private void FireStatusChangedEvent( CalculationStatus status ); private void FireProgressChangedEvent( int progress ); private string Calculate( int count ); private void EndCalculate( IAsyncResult ar ); } CalculationTask 类定义一个默认的构造函数和两个公共方法来启动和停止计算。它还定义了帮助器方法来帮助 Task 对象激发针对 UI 的事件。Calculate 方法实现计算逻辑,并且运行在后台线程上。EndCalculate 方法实现回调方法,它是在后台计算线程完成之后调用的。 类成员如下: private CalculationStatus _calcState; private delegate string CalculationDelegate( int count ); public delegate void CalculationStatusEventHandler( object sender, CalculationEventArgs e ); public delegate void CalculationProgressEventHandler( object sender, CalculationEventArgs e ); public event CalculationStatusEventHandler CalculationStatusChanged; public event CalculationProgressEventHandler CalculationProgressChanged; CalculationStatus 成员是一个枚举,它定义了在任何一个时刻计算可能处于的三个状态。 public enum CalculationStatus { NotCalculating, Calculating, CancelPending } Task 类提供两个事件:一个通知 UI 有关计算状态的事件,另一个通知 UI 有关计算进度的事件。委托签名与事件本身都要定义。 这两个事件是在帮助器方法中激发的。这些方法检查目标的类型,如果目标类型是从 Control 类派生的,它们就使用 Control 类中的 Invoke 方法来激发事件。因此,对于 UI 事件接收器,可以保证事件是在 UI 线程上调用的。下面的示例展示了激发事件的代码。 private void FireStatusChangedEvent( CalculationStatus status ) { if( CalculationStatusChanged != null ) { CalculationEventArgs args = new CalculationEventArgs( status ); if ( CalculationStatusChanged.Target is System.Windows.Forms.Control ) { Control targetForm = CalculationStatusChanged.Target as System.Windows.Forms.Control; targetForm.Invoke( CalculationStatusChanged, new object[] { this, args } ); } else { CalculationStatusChanged( this, args ); } } } 这段代码首先检查事件接收器是否已经注册,如果它已经注册,就检查目标的类型。如果目标类型是从 Control 类派生的,就使用 Invoke 方法激发该事件以确保在 UI 线程上处理它。如果目标类型不是从 Control 类派生的,就正常激发事件。在 FireProgressChangedEvent 方法中,以相同的方式激发事件以向 UI 报告计算进度,如下列的示例所示。 private void FireProgressChangedEvent( int progress ) { if( CalculationProgressChanged != null ) { CalculationEventArgs args = new CalculationEventArgs( progress ); if ( CalculationStatusChanged.Target is System.Windows.Forms.Control ) { Control targetForm = CalculationStatusChanged.Target as System.Windows.Forms.Control; targetForm.Invoke( CalculationProgressChanged, new object[] { this, args } ); } else { CalculationProgressChanged( this, args ); } } } CalculationEventArgs 类定义了两个事件的事件参数,并且包含计算状态和进度参数,以便将它们发送给 UI。CalculationEventArgs 类的定义如下所示。 public class CalculationEventArgs : EventArgs { public string Result; public int Progress; public CalculationStatus Status; public CalculationEventArgs( int progress ) { this.Progress = progress; this.Status = CalculationStatus.Calculating; } public CalculationEventArgs( CalculationStatus status ) { this.Status = status; } } StartCalculation 方法负责启动后台线程上的计算。委托 CalculationDelegate 允许使用委托异步调用 (Delegate Asynchronous Call) 模式在后台线程上调用 Calculation 方法,如下面的示例所示。 public void StartCalculation( int count ) { lock( this ) { if( _calcState == CalculationStatus.NotCalculating ) { // Create a delegate to the calculation method. CalculationDelegate calc = new CalculationDelegate( Calculation ); // Start the calculation. calc.BeginInvoke( count, new AsyncCallback( EndCalculate ), calc ); // Update the calculation status. _calcState = CalculationStatus.Calculating; // Fire a status changed event. FireStatusChangedEvent( _calcState ); } } } StopCalculation 方法负责取消计算,如下面的代码示例所示。 public void StopCalculation() { lock( this ) { if( _calcState == CalculationStatus.Calculating ) { // Update the calculation status. _calcState = CalculationStatus.CancelPending; // Fire a status changed event. FireStatusChangedEvent( _calcState ); } } } 当调用 StopCalculation 时,计算状态被设置为 CancelPending,以通知后台停止计算。向 UI 激发一个事件,以通知已经接收到取消请求。 这两个方法都使用 lock 关键字来确保对计算状态变量的更改是原子的,这样应用程序就不会遇到争用情形。这两个方法都激发状态改变事件来通知 UI 计算正在启动或停止。 计算方法定义如下。 private string Calculation( int count ) { string result = ""; for ( int i = 0 ; i < count ; i++ ) { // Long calculation… // Check for cancel. if ( _calcState == CalculationStatus.CancelPending ) break; // Update Progress FireProgressChangedEvent( count, i ); } return result; } 注为清楚起见,计算的细节已经忽略。 每次传递都是通过循环进行的,这样就可以检查计算状态成员,以查看用户是否已经取消了计算。如果这样,循环就退出,从而完成计算方法。如果计算继续进行,就使用 FireProgressChanged 帮助器方法来激发事件,以向 UI 报告进度。 在计算完成之后,就调用 EndCalculate 方法,以便通过调用 EndInvoke 方法来完成异步调用,如下面的示例所示。 private void EndCalculate( IAsyncResult ar ) { CalculationDelegate del = (CalculationDelegate)ar.AsyncState; string result = del.EndInvoke( ar ); lock( this ) { _calcState = CalculationStatus.NotCalculating; FireStatusChangedEvent( _calcState ); } } EndCalculate 将计算状态重置为 NotCalculating,准备开始下一次计算。同时,它激发一个状态改变事件,这样就可以通知 UI 计算已经完成。
3.7 使用 Task 类
Task 类负责管理后台线程。要使用 Task 类,必须做的事情就是创建一个 Task 对象,注册它激发的事件,并且实现这些事件的处理。因为事件是在 UI 线程上激发的,所以您根本不必担心代码中的线程处理问题。
下面的示例展示了如何创建 Task 对象。在这个示例中,UI 有两个按钮,一个用于启动计算,一个用于停止计算,还有一个进度栏显示当前的计算进度。
// Create new task object to manage the calculation. _calculationTask = new CalculationTask(); // Subscribe to the calculation status event. _ calculationTask.CalculationStatusChanged += new CalculationTask.CalculationStatusEventHandler( OnCalculationStatusChanged ); // Subscribe to the calculation progress event. _ calculationTask.CalculationProgressChanged += new CalculationTask.CalculationProgressEventHandler( OnCalculationProgressChanged ); 用于计算状态和计算进度事件的事件处理程序相应地更新 UI,例如通过更新状态栏控件。 private void CalculationProgressChanged( object sender, CalculationEventArgs e ) { _progressBar.Value = e.Progress; } 下面的代码展示的 CalculationStatusChanged 事件处理程序更新进度栏的值以反映当前的计算进度。假定进度栏的最小值和最大值已经初始化。 private void CalculationStatusChanged( object sender, CalculationEventArgs e ) { switch ( e.Status ) { case CalculationStatus.Calculating: button1.Enabled = false; button2.Enabled = true; break; case CalculationStatus.NotCalculating: button1.Enabled = true; button2.Enabled = false; break; case CalculationStatus.CancelPending: button1.Enabled = false; button2.Enabled = false; break; } } 在这个示例中,CalculationStatusChanged 事件处理程序根据计算状态启用和禁用启动和停止按钮。这可以防止用户尝试启动一个已经在进行的计算,并且向用户提供有关计算状态的反馈。 通过使用 Task 对象中的公共方法,UI 为每个按钮单击实现了窗体事件处理程序,以便启动和停止计算。例如,启动按钮事件处理程序调用 StartCalculation 方法,如下所示。 private void startButton_Click( object sender, System.EventArgs e ) { calculationTask.StartCalculation( 1000 ); } 类似地,停止计算按钮通过调用 StopCalculation 方法来停止计算,如下所示。 private void stopButton_Click( object sender, System.EventArgs e ) { calculationTask.StopCalculation(); }
四、小结
多线程处理是创建可以响应的智能客户端应用程序的重要部分。应该分析多线程适合于应用程序的什么地方,并且注意在单独线程上进行不直接涉及 UI 的所有处理。在大多数情况下,可以使用 ThreadPool 类创建线程。然而,在某些情况下,必须使用 Thread 类来作为代替,在另外一些情况下,需要使用委托对象或 Web 服务代理来使特定的处理在非 UI 线程上进行。
在多线程应用程序中,必须确保 UI 线程负责所有与 UI 有关的任务,这样就可以有效地管理 UI 线程和其他线程之间的通信。Task 模式可以帮助大大简化这种交互。
PS:
为了方便大家学习,本作者特将上面Task的代码进行整理,写成一个可以方便使用的程序,现公布出代码,欢迎尝试! Thread.rar
没有整理与归纳的知识,一文不值!高度概括与梳理的知识,才是自己真正的知识与技能。 永远不要让自己的自由、好奇、充满创造力的想法被现实的框架所束缚,让创造力自由成长吧! 多花时间,关心他(她)人,正如别人所关心你的。理想的腾飞与实现,没有别人的支持与帮助,是万万不能的。