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 代码和长时间运行的任务的代码都相对清楚地了解到我们正在使用多线程。
这个类适合在后台线程中运行,并且可以使用以下代码启动:
Worker 类中的实例变量可以存放其数据。后台线程可以安全地使用这些变量(mInner 和 mOuter),还可以确保其他线程不会同时访问这些变量。
我们可以用其中包含的 constructor 方法使用任何起始数据初始化该对象。实际启动后台线程之前,我们的主应用程序代码会创建此对象的实例,并使用后台线程将要操作的数据对其进行初始化。
后台线程将获取对象的 Work 方法的地址,然后开始启动。此线程将立即在对象内部运行代码,并使用该对象的专用数据。
由于对象是自包含的,因此我们可以创建多个对象,每个对象在其自身的线程上运行并且对象之间相对独立。
但是,此实现方案并不理想。UI 无法获得后台进程的状态。我们也未实现任何机制,使 UI 能够请求终止后台进程。
要解决以上两个问题,后台线程与 UI 线程之间需要以某种方式进行交互。这种交互方式非常复杂,因此最好能够以某种方式将交互放到一个类中,这样 UI 和辅助代码就不必为细节而担心。
我们先来讨论体系结构,然后再讨论如何设计和实现代码。从本文的相关链接可以下载此代码以及说明如何使用此代码的示例应用程序。
通常情况下,应用程序中首先会启动一个单一线程,来打开用户界面。我们将其命名为“UI 线程”以便于理解。“UI 线程”是许多应用程序中的唯一线程,因此它要处理 UI 并完成所有操作。
但是,现在我们创建一个“辅助线程”进行某些后台操作,让 UI 线程集中处理用户界面。这样即使辅助线程繁忙,UI 线程也可以对用户保持响应状态。
我们在 UI 线程和辅助线程之间插入一层代码,使其充当 UI 和辅助代码之间的接口。此代码实质上是一个“控制器”,用来管理和控制辅助线程及其与 UI 之间的交互。
控制器包含的代码可以安全地启动辅助线程,将任何状态消息从辅助线程中转给 UI 线程,以及将任何取消请求从 UI 线程中转回辅助线程。UI 代码和辅助代码不能直接交互,它们通常要通过控制器的代码进行交互。
但是辅助线程被激活“之前”和“之后”的时间段除外,这时 UI 代码可以与 Worker 对象进行交互。启动辅助线程之前,UI 可以创建并初始化 Worker 对象。终止辅助线程之后,UI 可以从 Worker 对象中检索任何值。从 UI 的角度看,将形成以下事件流:
创建 Worker 对象。 初始化 Worker 对象。 调用 Controller 以启动辅助线程。 Worker 对象将通过 Controller 将状态信息发送给 UI。 UI 可以通过 Controller 将取消请求发送给 Worker 对象。 Worker 对象在完成操作后通过 Controller 通知 UI。 值可以直接从 Worker 对象中检索。 除了在辅助线程处于激活状态时 UI 代码无法与 Worker 对象直接交互的限制外,对 UI 没有特殊的编码要求。即使正在运行后台操作,UI 也会对用户保持激活和响应状态。
从 Worker 对象的角度看,将形成以下事件流:
UI 代码创建 Worker 对象。 UI 代码使用所需的数据初始化 Worker 对象。 Controller 创建后台线程并调用 Worker 对象的方法。 Worker 对象运行辅助代码。 Worker 对象将状态信息传递给 Controller,以便 Controller 将状态信息中转给 UI。 Worker 对象适时检查是否存在取消请求,如果存在,则停止运行。 Worker 对象完成后,告诉 Controller 已完成,以便 Controller 将该信息中转给 UI。 现在辅助线程已终止,因此 UI 可以与 Worker 对象直接交互。 由于辅助代码只与 Controller 交互,因此我们不必担心辅助线程会意外地与 UI 组件交互(这无疑会使应用程序不稳定)。现在,辅助代码依靠 Controller 与 UI 线程进行正确通信,因此各项操作都很安全。
这意味着,只要处理好 Worker 对象中的实例变量,就无需处理辅助代码中的任何线程问题。
使用图表通常能够很好地了解不同组件(尤其是不同线程上的组件)之间的交互。Microsoft® Visio® 支持创建 UML(通用建模语言)图表,对理解很有帮助。
以下是说明 UI、Worker 对象和 Controller 之间事件流的 UML 序列图表。此图表假设不存在任何取消操作请求。Worker 和 Controller 对象下面重叠在垂直线上的垂直活动栏突出了辅助线程上运行的代码。其他所有代码都在 UI 线程上运行。
使用 UML 活动图表也可以查看事件流。这种图表形式的着重点在于任务而不是对象,因此其中显示了发生的一系列步骤以及各步骤之间的流程。我们很容易看出 UI 代码如何停留在左侧的线程中,而 Worker 对象如何在右侧的线程上工作。Worker 对象在其他线程中运行之前和运行之后可以直接由 UI 使用,以便初始化值,然后再检索结果。
使用这样的图表可以帮助我们找出后台线程处于激活状态时,UI 与辅助线程(或反过来)无意中进行直接交互的位置。任何这样的交互都需要额外地进行编码,以避免出现可能使应用程序不稳定的错误。理想状态下,这种交互通过 Controller 组件来实现,我们可以在其中包含所有编码,使交互安全进行。
下图说明了 UI 发出取消请求时的事件序列。
请注意,取消请求从 UI 发送到 Controller,然后 Worker 线程与 Controller 进行核实,确定是否发生了取消请求。UI 和 Controller 都不会强制辅助代码终止,而是允许辅助代码自己正常安全地终止。
Controller 类。为了使此架构能够在多数方案中应用,我们还会定义一些正式接口,可以由 Controller 在与 UI(或客户端)和辅助线程交互时使用。
通过为客户端和辅助线程定义正式接口,我们可以在不同的情况下使用相同的 Controller 对象,还可以根据需要使用不同的 UI 要求和不同的 Worker 对象。
下面的 UML 类图表显示了 Controller 类以及 IClient 和 IWorker 接口。它还显示了 IController 接口,辅助代码将通过它与 Controller 对象交互。
IClient 接口定义的方法将由 Controller 对象调用,用于向客户端 UI 通报 Worker 的开始时间、结束时间和任何中间状态消息。它还包含一个指示辅助代码失败的方法。
多数情况下,我们可以将这些方法作为由 Controller 对象发出而由 UI 处理的事件来实现。但是,从辅助线程发出事件然后由 UI 线程正确处理并非易事,因而我们将其作为一组方法来进行实现。
使控制器代码(在辅助代码上运行)调用 UI 中的这些方法并由 UI 线程进行处理,这样相对要简单得多。
同样,IWorker 接口定义了由 Controller 对象调用的、使其可以与辅助代码交互的方法。使用 Initialize 方法可以为辅助代码提供对 Controller 对象的引用,而使用 Start 方法可以启动后台线程上的操作。
由于线程的工作方式,Start 方法无法包含任何参数。启动新线程时,必须将不接受任何参数的方法的地址传递给线程。
请注意,IWorker 接口中不存在 Cancel 或 Stop 方法。我们不能强制辅助代码停止,同时也没有这个必要;但是辅助代码可以使用 IController 接口询问 Controller 对象是否存在取消请求。
IController 接口定义了辅助代码可以在 Controller 对象上调用的方法。它允许辅助代码检查 Running 标志。如果存在取消请求,Running 标志即为 False。它还允许辅助代码在工作完成或无法完成时告诉 Controller,并允许使用状态消息和完成百分比值(0 到 100 之间的 Integer)更新 Controller。
最后我们定义了 Controller 对象。该对象中包含一些可以被 UI 代码调用的方法。其中包括 Start 方法,该方法可以通过为 Controller 对象提供对 Worker 对象的引用来启动后台操作。还包括 Cancel 方法,该方法用于请求取消操作。UI 也可以检查 Running 属性,查看是否存在取消请求;还可以检查 Percent 属性,查看任务完成的百分比。
Controller 类中包含的 constructor 方法接受 IClient 作为参数,还允许 UI 为 Controller 提供对窗体(用于处理 Worker 中的显示消息)的引用。
为了实现一系列动画点来显示线程的活动,我们将创建一个简单 Windows 窗体控件,该控件使用计时器以更改一系列 PictureBox 控件中的颜色。
打开 Visual Studio .NET,然后创建一个名为 Background 的新 Class Library(类库)应用程序。由于此库将包含 Windows 窗体控件和窗体,因此需要使用 Add References(添加引用)对话框引用 System.Windows.Forms.dll 和 System.Windows.Drawing.dll。此外,我们可以使用项目的属性对话框在这些项目范围内导入命名空间,如图 6 所示。
此操作完成后,就可以开始编码了。让我们先从创建接口开始。
IClient 的项目中添加一个类,并用以下代码替换其代码:
然后添加名为 IWorker 的类,并用以下代码替换其代码:
最后添加名为 IController 的类,代码如下:
至此,我们已定义了本文前面所述的所有类图表中的接口。现在可以实现 Controller 类了。
现在,我们可以实现架构的核心,Controller 类。此类中包含的代码可用于启动辅助线程,以及在辅助线程完成之前充当 UI 线程和辅助线程之间的媒介。
在名为 Controller 的项目中添加一个新类。首先添加 Imports,并声明一些变量:
然后需要声明一些委托。委托是方法的正式指针,而且方法的委托必须具有与方法本身相同的方法签名(参数类型等)。
委托的用途很广。在我们的示例中,委托非常重要,因为委托使我们可以让一个线程调用窗体上的方法,使其在该窗体的 UI 线程上运行。正如 IClient 所定义的那样,要在窗体上调用的三个方法都需要委托:
IClient 还定义了 Start 方法,但是该方法可以从 UI 线程调用,因此不需要委托。
下面编写将从 UI 线程调用的代码。代码中包括 constructor 方法、Start 和 Cancel 方法以及 Percent 属性。我将这些内容放入 Region 中,便于大家清楚地了解它们是从 UI 线程调用的。
此处唯一比较特殊的代码位于 Start 方法中,我们可以在该方法中创建辅助线程然后启动该线程:
要创建线程,需要在 Worker 对象的 IWorker 接口上传递 Start 方法的地址。然后,只需调用线程对象的 Start 方法即可开始操作。此时我们要特别注意,UI 不应直接与 Worker 交互,Worker 也不应直接与 UI 交互。
请注意,Cancel 方法只设置一个标志,表明我们不希望继续运行。辅助代码应定期查看此标志,以确定是否应该停止运行。
现在,我们可以实现 Worker 对象运行时将由辅助线程调用的代码。此代码比较有趣,因为它必须将 Display 和 Completed 从辅助线程中转至 UI 线程,同时还要在 UI 线程上完成此操作。
要完成此操作,我们可以使用 Form 对象的 Invoke 方法。此方法接受窗体应该调用的方法的委托指针,以及包含该方法的参数的 Object 类型数组。
Invoke 方法不直接调用窗体上的方法,而是请求窗体返回并使用窗体的 UI 线程调用该方法。此操作可通过向窗体发送 Windows 消息在后台完成。这说明窗体获得这些方法调用的方式与从操作系统中获得 click 或 keypress 事件的方式基本相同。
通常,这些细节不会影响大局。结果由 Invoke 方法触发一个进程,通过该进程窗体将终止其 UI 线程上运行的方法,这就是我们要实现的目标。
再次重申,此代码位于 Region 内,目的是为了明确它将在辅助线程上调用:
Failed 和 Completed 方法利用窗体的 Invoke 方法。例如,Failed 方法可以执行以下操作:
首先创建一个委托,从 IClient 接口指向客户端窗体的 Failed 方法。然后声明包含向方法传递参数值的 Object 类型数组。最后调用客户端窗体的 Invoke 方法,将委托指针和参数数组传递给窗体。
窗体将在 UI 线程(窗体在这里可以安全运行以更新显示)上使用这些参数调用此方法。
整个进程是同步进行的,即对窗体进行调用时辅助线程将停止。尽管可以在显示错误消息或完成消息时停止辅助线程,但我们并不希望显示每个小状态时都停止辅助线程。
为了避免显示状态时停止辅助线程,Display 方法将使用 BeginInvoke,而不使用 Invoke。BeginInvoke 使窗体上的方法调用异步进行,这样辅助线程可以一直保持运行状态,不需要等待窗体上的显示方法完成:
以这种方式使用 BeginInvoke 可以防止辅助线程停止,使辅助线程具有尽可能高的性能。
最后,我们来创建显示动画点的 ActivityBar 控件。
在名为 ActivityBar 的项目中添加一个用户控件。
将该控件的宽度调整为约 110,高度调整为约 20。可以通过拖动边界进行调整,也可以通过在 Properties(属性)窗口中设置 Size 属性进行调整。
其余的操作将通过代码完成。要创建一系列在显示时不停闪烁的动画“灯”,可以使用带有 Timer 控件的一系列 PictureBox 控件。每次 Timer 控件关闭时,我们将使下一个 PictureBox 呈绿色显示,并将已经呈绿色显示的 PictureBox 更改为窗体的背景色。
将 Windows Forms(Windows 窗体)选项卡中的 Timer 控件放入窗体中,然后将其名称更改为 tmAnim。同时将 Interval 属性设置为 300,以获得较好的动画速度。
顺便说一句,Components(组件)选项卡中有一个不同的 Timer 控件。它是一个多线程计时器。也就是说,该计时器将在后台线程中引发 Elapsed 事件,而不是象 Windows 窗体计时器那样在 UI 线程上引发 Elapsed 事件。建立 UI 时这种方法通常会产生相反的效果,因为 Elapsed 事件中的代码显然不能直接与我们的 UI 进行交互。
现在,在控件中添加以下代码:
窗体的 Load 事件创建 PictureBox 控件并将它们放入数组,这样便于我们在它们之间循环。Timer 控件的 Tick 事件循环显示,使各个控件依次呈绿色。
所有操作由 Start 方法开始,由 Stop 事件结束。由于 Stop 是一个保留字,因此把这个方法名放在方括号内:[Stop]。Stop 方法不仅可以停止计时器,还可以灰显所有框,告诉用户这些框中当前没有活动。
本文前面已简单介绍了 Worker 类。因为我们已经定义了 IWorker 接口,所以可以增强该类,以利用我们创建的 Controller。
首先创建 Background.dll 文件。此步骤很重要,因为如果不完成此步骤,ActivityBar 控件将无法在我们建立测试窗体时显示在工具箱上。
在解决方案中添加名为 bgTest 的 Windows Forms Application(Windows 窗体应用程序)。在 Solution Explorer(解决方案资源浏览器)中用右键单击该项目并选择相应的菜单项,将该程序设置为启动项目。
然后使用 Add References(添加引用)对话框中的 Projects(项目)选项卡,添加对 Background 项目的引用。
现在,在名为 Worker 的项目中添加一个类。其中部分代码与前面所述的代码相同,但还包含一些不同的代码,用以实现 IWorker 接口(此处突出显示的部分):
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 Dim innerIndex As Integer Dim outerIndex As Integer Dim value As Double For outerIndex = 0 To mOuter For innerIndex = 0 To mInner ' 此处进行一些有意思的计算 value = Math.Sqrt(CDbl(innerIndex - outerIndex)) Next Next End SubEnd Class
我们添加了能够实现 IWorker.Initialize 的 Init 方法。Controller 将调用此方法,因此以后我们可以引用 Controller 对象。
我们还将 Work 方法更改为 Private,只是为了实现 IWorker.Start 方法。此方法将在辅助线程上运行。
我们增强了 Work 方法,使其可以使用 Try..Catch 块。这样我们可以使用 Controller 上的 Failed 方法捕捉任何错误并将其返回给 UI。
假设代码正在运行,我们调用 Controller 对象的 Display 和 SetPercent 方法,使它们随着代码的运行更新其状态和完成的百分比。
我们还定期检查 Controller 对象的 Running 属性,查看是否存在取消请求。如果存在取消请求,则停止进程,并指示由于取消请求而停止操作。
btnStart 和 btnRequestCancel)、两个标签(Label1 和 Label2)、一个 ProgressBar (ProgressBar1) 和一个 ActivityBar (ActivityBar1),如图 7 所示。
该窗体需要实现 IClient,以便 Controller 对象与之交互:
Public Class Form1 Inherits System.Windows.Forms.Form
该窗体还需要 Controller 对象和一个标志,用以跟踪后台操作是处于活动状态还是处于完成状态。
然后,我们可以添加方法,以实现由 IClient 定义的接口。建议将这些方法放在 Region 中,以表示它们实现的是辅助接口:
请注意,这一段代码中的所有内容均与线程无关,其中的每一部分代码都可以在我们得知后台操作的状态时做出相应的响应。每次响应后,我们都会更新显示以表明进程的状态和完成百分比(以文字的形式或通过 ProgressBar 显示),并启动和停止 ActivityBar 控件。
mActive 标志非常重要。如果用户在辅助线程处于活动状态时关闭窗体,应用程序可能会挂起或变得不稳定。要防止出现这种情况,我们可以打断窗体的 Closing 事件并取消关闭尝试(如果后台进程处于活动状态)。
我们还可以选择在这种情况下初始化取消操作,但是这取决于特定的应用程序要求。
其余的代码都是为了实现按钮的 Click 事件。
Start(开始)按钮只调用 Controller 对象的 Start 方法,并将 Worker 对象的实例传递给它。
您可能需要调整用于初始化 Worker 对象的值,以便在您的计算机上获得所需的结果。这些特定的值提供了双处理器 P3/450 计算机上的一个良好示例。显然,这只是用于测试目的。真正的 Worker 对象将实现更有意义、运行时间更长的进程。
Cancel(取消)按钮将调用 Controller 对象的 Cancel 方法,同时还会更新显示,以表明已请求取消。请记住,这只是一个取消“请求”,在辅助线程真正停止运行之前可能需要等待一些时间。最好能够为用户提供即时反馈,至少应让用户知道系统已经注意到用户的单击按钮操作。
现在,我们可以运行应用程序了。单击 Start(开始)按钮时,Worker 就应该开始运行,而且显示的内容会在运行时更新。您可以将窗体移动到屏幕上的任意位置,也可以与其交互,因为 UI 线程本质上还处于空闲状态,可以随时与您交互。
同时,辅助线程在后台进行大量复杂的工作,并定期将状态更新信息发送给 UI 线程以进行显示。