Rockford Lhotka
Magenic Technologies
2002年10月1日
从 MSDN Code Center 下载 VBbackground.exe 示例文件(英文)。(请注意,在示例文件中,程序员的注释使用的是英文,本文中将其译为中文是为了便于读者进行理解。)
摘要:Rocky Lhotka 建议并实现了一个结构化架构示例,该架构可用于充当辅助线程和 UI 线程之间的媒介,从而简化编写多线程辅助代码和 UI 以对其进行控制的过程。该架构包括可下载的代码示例,可以根据您的应用需要进行调整。
使用多线程,可以使应用程序同时执行多项任务。使用多线程,可以让一个线程运行用户界面,让另一个线程进行复杂运算或在后台操作。由于 Microsoft® Visual Basic® .NET 支持多线程,因此我们很容易获得此功能。
但多线程也有其不足之处。当应用程序使用多个线程时,我们总会遇到这样的问题:多个线程同时尝试与相同的数据或资源进行交互。出现这种情况时,问题就会变得非常复杂并且难以调试。
更糟糕的是,多线程代码通常在最初开发期间似乎运行正常,但在生产过程中则会因为出现意外的情况(多个线程同时与相同的数据或资源进行交互)而导致失败。这样就增大了多线程编程的危险性!
由于设计和调试多线程应用程序非常困难,因此 Microsoft 在 COM 中创建了“单线程单元”(STA) 概念。Visual Basic 6 代码始终在 STA 中运行,因而代码只需考虑单线程即可。这样即可彻底避免共享数据或资源所带来的问题,但是同时也意味着,我们必须采取严格的措施才能利用多线程的优势。
.NET 中不会出现 STA 中的这种常见问题。所有 .NET 代码都在允许多线程操作的 AppDomain 中运行。这意味着 Visual Basic .NET 代码也在 AppDomain 中运行,因此可以使用多线程操作。显然,任何时候进行此操作都必须小心编写代码,以避免线程之间的冲突。
要避免线程之间发生冲突,最简单的方法就是确保多个线程永远不会与相同的数据或资源进行交互。尽管不太可能,但是对于任何多线程应用程序来说,应该在设计时尽量避免使用或尽量少使用共享数据或共享资源。
这样不仅能简化编码和调试过程,还能提高性能。要解决线程之间的冲突,必须使用能够在某个线程完成操作之前阻止或暂停其他线程的同步技术。阻止线程也就是使线程处于空闲状态,不进行任何操作,因此会降低性能。
取消按钮和状态显示
在应用程序中使用多线程的原因有多种,但最常见的原因是我们一方面需要执行一个长时间运行的任务,另一方面又希望某些或所有用户界面对用户来说始终处于响应状态。
至少我们应该使 Cancel(取消)按钮始终保持响应状态,使用户能够通过它告诉系统,他们希望终止长时间运行的任务。
在 Visual Basic 6 中,我们尝试使用 DoEvents、计时器控件和许多其他方法进行该操作。Visual Basic .NET 中的操作则简单得多,因为我们可以使用多线程。而且,只要我们小心谨慎,就可以完成此操作且不会使代码或调试复杂化。
要在多线程环境中成功实现 Cancel(取消)按钮,关键是要记住 Cancel(按钮)的作用只是“请求”取消任务。由后台任务决定何时停止。
如果我们实现一个能够直接停止后台进程的 Cancel(取消)按钮,则可能会在执行某些敏感性操作的过程中将其停止,或者在后台进程关闭重要资源(例如,文件处理程序或数据库连接)之前将其停止。而这有可能导致严重后果,引起死机、应用程序行为不稳定或应用程序完全崩溃。
因此,Cancel(取消)按钮的作用应该只是请求停止后台任务。后台任务可以检查某一时间点上是否存在取消操作的请求。如果检测到取消操作的请求,后台线程则可以释放所有资源,停止所有重要操作并正常终止。
虽然请求取消操作非常重要,但是我们更希望能够通过 UI 为用户显示后台进程的状态信息。状态信息可以是文本格式的消息,也可以是完成任务的百分比,或者同时显示两种消息。
要在 Visual Basic .NET 中实现 Cancel(取消)按钮或状态显示,我们所面对的最复杂的问题在于 Windows 窗体库不是对于线程并不安全。这意味着只有创建窗体的线程可以与该窗体或其控件进行交互。其他线程均不能安全地与该窗体或其控件进行交互。
但是,我们却无法避免编写多线程与给定窗体进行交互的代码。因此,运行时可能会产生不可预知的后果,甚至可能会导致应用程序崩溃。
这要求我们在编码时必须小心谨慎,还要确保只有我们的 UI 线程与 UI 进行交互。为此,我们可以建立一个简单的架构,管理后台辅助线程和 UI 线程之间的交互。如果能够实现,则可以使 UI 代码和长时间运行的任务的代码都相对清楚地了解到我们正在使用多线程。
线程和对象
如果要创建一个后台进程并使其可以使用它自己的数据在它自己的线程上运行,最简单的方法是创建专门用于该后台进程的对象。虽然不一定能实现,但它是一个积极的目标,因为它能够大大简化多线程应用程序的创建过程。
如果后台线程在其自身的对象中运行,则后台线程可以使用该对象的实例变量(在类中声明的变量),而无须担心这些变量会被其他线程使用。例如,请考虑下面的类:
Public Class Worker
Private mInner As Integer
Private mOuter As Integer
Public Sub New(ByVal InnerSize As Integer, ByVal OuterSize As Integer)
mInner = InnerSize
mOuter = OuterSize
End Sub
Public Sub Work()
Dim innerIndex As Integer
Dim outerIndex As Integer
Dim value As Double
For outerIndex = 0 To mOuter
For innerIndex = 0 To mInner
' do some cool calculation here
value = Math.Sqrt(CDbl(innerIndex - outerIndex))
Next
Next
End Sub
End Class
这个类适合在后台线程中运行,并且可以使用以下代码启动:
Dim myWorker As New Worker(10000000, 10)
Dim backThread As New Thread(AddressOf myWorker.Work)
backThread.Start()
Worker 类中的实例变量可以存放其数据。后台线程可以安全地使用这些变量(mInner 和 mOuter),还可以确保其他线程不会同时访问这些变量。
我们可以用其中包含的 constructor 方法使用任何起始数据初始化该对象。实际启动后台线程之前,我们的主应用程序代码会创建此对象的实例,并使用后台线程将要操作的数据对其进行初始化。
后台线程将获取对象的 Work 方法的地址,然后开始启动。此线程将立即在对象内部运行代码,并使用该对象的专用数据。
由于对象是自包含的,因此我们可以创建多个对象,每个对象在其自身的线程上运行并且对象之间相对独立。
但是,此实现方案并不理想。UI 无法获得后台进程的状态。我们也未实现任何机制,使 UI 能够请求终止后台进程。
要解决以上两个问题,后台线程与 UI 线程之间需要以某种方式进行交互。这种交互方式非常复杂,因此最好能够以某种方式将交互放到一个类中,这样 UI 和辅助代码就不必为细节而担心。
体系结构
我们可以创建使 UI 和辅助代码无需进行线程交互操作的体系结构。实际上我们可以实现此目标,还能实现一个能够通过某种方式实现复杂代码的架构,可以用来管理或控制后台线程及其 UI 交互。
我们先来讨论体系结构,然后再讨论如何设计和实现代码。从本文的相关链接可以下载此代码以及说明如何使用此代码的示例应用程序。
通常情况下,应用程序中首先会启动一个单一线程,来打开用户界面。我们将其命名为“UI 线程”以便于理解。“UI 线程”是许多应用程序中的唯一线程,因此它要处理 UI 并完成所有操作。
但是,现在我们创建一个“辅助线程”进行某些后台操作,让