创建和管理辅助线程

2016-06-22

在我的中我向你展示了如何用使用委托 以异步方式执行一个方法。在那里你学会了如何简单地使用委托对象的 BeginInvoke 来分配一个异步方法调用。使用委托来异步的执行方法是很容易的因为公共语言运行时(CLR)在幕后为你创建和管理了一个工作者线程池。当你调用BeginInvoke 时,CLR 负责将你的请求分配给其内建线程池中的工作者线程之一。

在绝大多数情况下,当你需要从桌面程序中异步的执行代码时,我推荐你使用异步委托以及我在2004年1月所展示的编程技术。 然而,但在某些场合使用异步委托不能为你提供所需要的灵活性和效率。

本月我将向你展示如何使用 CLR 的 Thread 类来创建和管理你自己的线程。我也将讨论一些情节,在这些情节中创建和管理一个辅助线程比使用委托来异步执行一个方法更好一点。

创建线程

当你想创建一个新线程时,你可以创建一个 Thread 类的实例,它在 System.Threading 名字空间中定义。Thread 类的构造函数 以一个 ThreadStart 委托对象的引用为参数。在 Figure 1 的代码中你可以看到如何创建一个线程的两个例子。

正如你所看到的,对于创建一个新线程对象有一个普通句法和一个速记句法。Figure 1 中创建ThreadA 的技术示范了如何显式的创建一个绑定于 MyAsyncTask 方法的 ThreadStart 委托对象。创建 ThreadB 的技术使用更少的 键盘敲击达到同样的目的。那是因为当你写出下面的一行代码时,Visual Basic 编译器隐式的为你创建了一个 ThreadStart 委托对象:

	Dim ThreadB As New Thread(AddressOf TedsCode.MyAsyncTask)	

在 CLR 1.0 和 1.1 版本中,每个 Thread 对象都有它自己的物理 Win32 线程。然而创建一个Thread 类对象实际上并没有创建一个物理线程。相反,你必须调用 Thread 对象的 Start 方法让 CLR 调用 Windows 操作系统来创建一个物理线程。注意在将来的 CLR 版本中很可能会提供一个优化而不必为每一个 Thread 对象创建一个单独的物理线程。

一旦 CLR 创建了物理线程,就使用它来执行绑定于 ThreadStart 委托对象的目标方法。物理线程的生命周期由目标方法的执行来控制。当方法执行完成时,CLR把物理线程的控制交还到 Windows。此时操作系统会 消毁物理线程。

你应该意识到创建和销毁一个物理线程会有一定的开销。这个开销对于桌面程序来说不是很大,因为一般被创建的线程是用来响应用户动作的。然而在服务器端为了性能和可伸缩 性,你应该避免编写创建新线程的代码。

记住利用CLR内置线程池,使用委托来异步执行方法。因此,在服务器方程序中使用委托来完成异步执行可获得更好的伸缩性,因为它不需要频繁地创建和销毁物理线程。

向辅助线程传递参数

当你用创建一个新的 Thread 对象来异步执行某个方法时,必须使用一个符合 ThreadStart 委托调用签名的方法。这意味着你必须创建一个方法,它是一个不带任何参数的 Sub 过程(见 Figure 1 的 MyAsyncTask 子程序)。对于向将要在新创建的线程中执行的方法传递参数来说这需要一点技巧。

有一个常见的方法你可以用来向辅助线程传递参数;它牵涉到创建一个自定义的线程类。查看Figure 2 所示的 TedsThreadClass 类定义。你可以看到该类如何被设计为带有自定义的实例域集合和一个参数化构造函数。当你从一个自定义的线程类,比如 TedsThreadClass,创建一个对象时,你可以按照你的特殊情况要求使用任何参数值来初始化它。

注意 TedsThreadClass 同 Figure 1 中的例子是不同的 ,因为 Figure 2 中的 MyAsyncTask 方法被定义为实例方法代替了共享方法。这意味着 Figure 2 中的 MyAsyncTask 方法可以访问自定义的实例域x和y。这个技术的关键点就是提供了一种向将要在新的辅助线程中执行的方法传递自定义参数集的手段。

TedsThreadClass 包含了一个名为 InnerThread 的 protected 类型的域及一个 public类型的 Start 方法,InnerThread 拥有一个绑定于 MyAsyncTask 实例方法的 Thread 对象。这就允许自定义线程类可以封装创建和管理 Thread 对象的实现细节。为了使用自定义线程类,可以简单 地实例化一个对象并调用它的 Start 方法,如下:

	Dim ThreadA As New TedsThreadClass(1, "Bob")
	ThreadA.Start();	

你已经看到了设计一个向异步方法调用传递参数的自定义类都牵涉到了什么。记住委托使得向一个异步方法传递参数更加容易,因为你可以自定义一个带有任何你喜欢的参数列表的委托类型。注意通过创建和管理一个线程来使用一个异步方法调用通常在设计和编码期间要求更多的工作。

你也应该考虑到使用委托异步执行一个方法提供了一个获得返回值和输出参数的简单途径:委托的 EndInvoke 方法。而使用新的 Thread 对象异步执行方法就不那么方便了。你必须设计一个定制方案来从辅助线程向应用程序主线程返回数据。

当你创建和管理辅助线程时,你也需要花费更多的努力来协调线程同步。举个例子,当你需要应用程序的主线程等待直到辅助线程完成其工作时你应该如何做?你应该调用 Thread 类提供的 Join 方法,如下面的代码:

	Dim ThreadA As Thread
	ThreadA = New Thread(AddressOf TedsCode.MyAsyncTask)

	''*** do some work on primary thread

	''*** now block until secondary thread is done
	ThreadA.Join()	

Join 方法是一个阻塞调用。调用 Join 的线程会被阻塞直到其它的线程完成其工作。换句话说,Join 方法被用来把两个线程表示的并行执行路径连接为一个单个的路径。

何时创建新线程

我已经阐述了为什么在一个新的 Thead 对象上异步执行一个方法比使用异步委托要难和不那么有效的几点原因。因为异步委托更加容易使用并且它们提供了使用内置线程池的效 率,当你必须进行异步方法调用时,我推荐你使用它们。

然而在某些设计情况下就不应该使用委托。下面列出了一些情况,在这些情况下应该尽可能使用辅助线程而不是委托来进行异步执行。

需要执行一个长期运行的任务

需要调整某个线程的优先级

需要一个前台线程,它将保持托管桌面程序处于活动状态

需要一个单线程单元(STA)线程同单元线程COM对象一起工作

当你把线程用到一个需要花费很长时间的任务时,你应该创建一个新线程。比如,假设在整个应用程序生命周期你都需要一个专门的辅助线程来监视文件的更新或者监听 来自网络套接字上的数据,此时使用委托会被认为是一种不良的风格,因为你主动从 CLR 线程池中取走一个线程而从来不交还它。使用委托的异步方法执行只适用于相对较短时间运行的任务。

当你需要改变线程的优先级时你应该创建一个新线程。Thread 类暴露了一个 Priority public 属性,它允许你增加或者减少一个线程的优先级。然而你不能改变来自于 CLR 线程池的线程优先级。你只能改变通过 New Thread 类创建得到线程的优先级。

让我们看一个关于桌面程序的简单例子。想像你想在一个较低优先级的辅助线程里运行一个异步方法以便减少对应用程序用户界面响应的影响。在调用 Start 方法之前你可以调整 Thread 对象的Priority 属性:

	Dim ThreadA As Thread
	ThreadA = New Thread(AddressOf TedsCode.MyAsyncTask)
	ThreadA.Priority = ThreadPriority.BelowNormal
	ThreadA.Start()	

线程优先级别的设置有 Highest、AboveNoraml、Normal、BelowNormal 和 Lowest。一般你应该降低线程的优先级并尽量避免提高它们。小心的选择 Highest 和 AboveNormal 设置 ,因为它们会对应用程序和整个系统有着不可预料的影响。

当一个辅助线程正在运行而又想保持托管桌面程序处于活动状态时,创建一个新线程也是一个好注意。想像这样一种情景,在一个 Windows 窗体程序中用户关闭了主窗体而辅助线程仍在后台执行一个任务。辅助线程仍然在运行的事实对于保持 主应用程序处于活动状态足够重要么?如果辅助线程是后台线程,答案就是否。程序会立即关掉。然而如果辅助线程不是后台线程,程序仍会保持运行。

每个 Thread 对象有一个 IsBackground 属性指出它是否是一个后台线程。所有在 CLR 线程池中的线程都是后台线程而且不能被修改。这意味着调用委托的 BeginInvoke 来异步执行一个任务对于保持程序运行从来不那么重要。

当你创建一个新的 Thread 对象时,缺省情况下,它是一个前台线程,因为它的 IsBackground 属性被设置为 false。这意味着当辅助线程在执行时 ,你的程序可以继续运行。如果你想创建一个新线程并让它成为后台线程,你可以在调用 Start 方法之前为其 IsBackground 属性分配一个 True值,就像 下面这样:

	Dim ThreadA As Thread
	ThreadA = New Thread(AddressOf TedsCode.MyAsyncTask)
	ThreadA.IsBackground = True
	TheadA.Start()	

记住,一个 Thread 对象是否是前台或者后台线程实际上只对编写基于 EXE 的托管程序非常重要,比如控制台程序或者 Windows 窗体程序。对于 由非托管 EXE 启动的程序,比如 ASP.NET 工作者进程,Thread 对象的 IsBackground 属性对其没有影响。

最后,当你需要初始化某个线程以便 COM 互操作性运行在单线程单元中时,你需要创建一个新线程。为了解释这为什么如此重要,我将提供一些关于COM的背景知识。

在COM中有单线程单元(STAs)和多线程单元(MTA)线程。大多数COM组件,包括所有使用 Visaul Basic 5.0 和 Visual Basic 6.0 创建的 组件,都是单元线程的。单元线程组件由于关系到线程安全和亲合性问题只能运行在STA线程上。只有一小部分面向性能的组件能够安全地运行在MTA线程上。

在使用 Visual Basic .NET 编码创建和操作一个COM对象之前,CLR首先必须为COM互操作初始化调用线程。特别是 CLR 必须进行一个系统调用来 将线程初始化为 MTA 或者 STA。

如果你使用MTA线程创建单元线程对象,你将经历线程切换。线程切换牵涉到一定的性能冲击,因为对单元线程来说,COM对象的每个调用都要从MTA线程被封送到对象的STA线程。通过使用STA线程来代替MTA线程可以避免线程切换。因此当从单元线程COM组件创建对象时 ,你总是应该使用STA线程。

缺省情况下,使用 Visual Basic .NET 创建的应用程序的主线程会被初始化为一个STA线程。当你编译一个 Windows 窗体程序或者一个控制台程序 时,Visual Basic .NET编译器会自动的把STAThread 属性加入到程序进入点 Main 方法中:

	Class MyApp
  	    Shared Sub Main()
               ''*** application code here
           End Sub
	End Class	

STAThread 属性的出现强制应用程序的主线程初始化为STA线程,这是应用程序第一次与 COM 进行互操作工作。当同COM组件协同工作时,这正是你想要的,因为很可能它们是单元线程的。在很少一些情况下你需要用线程自由的COM组件写一个基于Visual Basic .NET程序时,通过在程序的Main方法中显式的加入MTAThread属性你可以强制程序的主线程初始化为MTA线程。

	Class MyApp
 	   
  	   Shared Sub Main()
    	      ''*** application code here
  	   End Sub
	End Class	

我已经讨论了一个应用程序的主线程是否被初始化为STA或者MTA线程的问题影响。现在看一下当有辅助线程时事情是如何工作的。首先重要的一点要明白你不能控制来自于CLR线程池中的线程。它们总是被初始化为MTA线程。由于这个原因当执行代码要同单元线程COM组件互操作时你应该避免使用异步委托,因为随着线程切换的发生会有性能的退化。

当创建和管理一个将要同COM组件进行互操作的辅助线程时,你可以控制它是否被初始化为STA线程或者MTA线程。在你从Thread类创建一个对象后,调用Start之前你可以显式的设置其ApartmentState属性为STA,就像下面看到的:

	Dim ThreadA As Thread
	ThreadA = New Thread(AddressOf TedsCode.MyAsyncTask)
	ThreadA.ApartmentState = ApartmentState.STA
	ThreadA.Start()

	''*** When you create a COM object, the CLR
	''*** will initialize ThreadA as an STA thread	

现在你有了一个辅助线程可以创建和调用单元线程COM对象而不会招致线程切换的负荷。这就使得以更有效的方式由辅助线程同单元线程COM组件的交互成为可能。

结论

本月我们考查了为什么以及如何创建辅助线程。你看到了创建和管理辅助线程来异步执行方法只是当你不能使用委托时才要做的事情。这些情况包括处理长时间运行的任务,调整线程优先级,使用前台线程,以及使用STA线程来更加有效地同单元线程COM组件进行互操作。

在我最近的三个专栏里已经讨论了如何使用异步委托和如何创建辅助线程。简而言之,我已经教你如何在程序里引入多线程行为来陷入麻烦。在接下来的 Basic Instincts 专栏里,我将向你展示通过设计和编写线程安全的代码来如何摆脱麻烦,它们面临并发问题时也很健壮,并且在多线程世界里能够可信任的运行。

发送你的问题和评论到Ted的信箱:instinct@microsoft.com

作者简介

Ted Pattison 是教育培训公司 Barracuda .NET 的创立人之一, 该公司帮助其它公司使用 Microsoft 的技术实现协同应用。Ted 也是《Building Applications and Components with Visual Basic .NET》(Addison-Wesley, 2003)等书的作者。

译者注:

关于 single-threaded apartment(STA)和 multithreaded apartment(MTA):

Apartment就是线程的容器,线程中有关 COM 的操作必须在 Apartment 中进行。Apartment 分为 STA和 MTA 两种,STA 是只能容纳一个线程的容器,MTA 是能容纳多个线程的容器。COM 规定,一个进程中可以有多个 STA,但最多只能有一个 MTA。一个线程不能同时进入两个 Apartment。设计 COM 对象时设定的“Apartment 模型”就是指这个 COM 对象可以呆在那种 Apartment 中。一个线程建立的 COM 对象自动地呆在这个线程所在的 Apartment 中。一个线程可以直接访问它所在的 Apartment中的 COM 对象,但要访问另一个 Apartment 中的 COM 对象就必须经过调度。因为 STA 中只有一个线程,别的线程要访问这个线程建立的 COM 对象就必须通过这个线程访问,如此一来,对这个 Apartment 中所有的 COM 对象的访问都是序列化的,这些 COM 对象就不用担心有好几个线程同时访问它。MTA 中的 COM 对象就不一样了,它们必须考虑到可能会有好几个线程同时访问它们。MTA 之外的一个线程访问 MTA 中的一个 COM 对象时,系统会从 COM 系统线程池中取出一个线程进入 MTA,由它来代表客户线程访问这个 COM 对象。