摘要: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 SubEnd 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 并完成所有操作。
但是,现在我们创建一个“辅助线程”进行某些后台操作,让 UI 线程集中处理用户界面。这样即使辅助线程繁忙,UI 线程也可以对用户保持响应状态。
我们在 UI 线程和辅助线程之间插入一层代码,使其充当 UI 和辅助代码之间的接口。此代码实质上是一个“控制器”,用来管理和控制辅助线程及其与 UI 之间的交互。
图 1:UI 线程、控制器和辅助线程
控制器包含的代码可以安全地启动辅助线程,将任何状态消息从辅助线程中转给 UI 线程,以及将任何取消请求从 UI 线程中转回辅助线程。UI 代码和辅助代码不能直接交互,它们通常要通过控制器的代码进行交互。
但是辅助线程被激活“之前”和“之后”的时间段除外,这时 UI 代码可以与 Worker 对象进行交互。启动辅助线程之前,UI 可以创建并初始化 Worker 对象。终止辅助线程之后,UI 可以从 Worker 对象中检索任何值。从 UI 的角度看,将形成以下事件流:
除了在辅助线程处于激活状态时 UI 代码无法与 Worker 对象直接交互的限制外,对 UI 没有特殊的编码要求。即使正在运行后台操作,UI 也会对用户保持激活和响应状态。
从 Worker 对象的角度看,将形成以下事件流:
由于辅助代码只与 Controller 交互,因此我们不必担心辅助线程会意外地与 UI 组件交互(这无疑会使应用程序不稳定)。现在,辅助代码依靠 Controller 与 UI 线程进行正确通信,因此各项操作都很安全。
这意味着,只要处理好 Worker 对象中的实例变量,就无需处理辅助代码中的任何线程问题。
使用图表通常能够很好地了解不同组件(尤其是不同线程上的组件)之间的交互。Microsoft® Visio® 支持创建 UML(通用建模语言)图表,对理解很有帮助。
以下是说明 UI、Worker 对象和 Controller 之间事件流的 UML 序列图表。此图表假设不存在任何取消操作请求。Worker 和 Controller 对象下面重叠在垂直线上的垂直活动栏突出了辅助线程上运行的代码。其他所有代码都在 UI 线程上运行。
图 2:说明进程流的序列图表
使用 UML 活动图表也可以查看事件流。这种图表形式的着重点在于任务而不是对象,因此其中显示了发生的一系列步骤以及各步骤之间的流程。我们很容易看出 UI 代码如何停留在左侧的线程中,而 Worker 对象如何在右侧的线程上工作。Worker 对象在其他线程中运行之前和运行之后可以直接由 UI 使用,以便初始化值,然后再检索结果。
图 3:显示进程流的活动图表
使用这样的图表可以帮助我们找出后台线程处于激活状态时,UI 与辅助线程(或反过来)无意中进行直接交互的位置。任何这样的交互都需要额外地进行编码,以避免出现可能使应用程序不稳定的错误。理想状态下,这种交互通过 Controller 组件来实现,我们可以在其中包含所有编码,使交互安全进行。
下图说明了 UI 发出取消请求时的事件序列。
图 4:显示取消请求的序列图表
请注意,取消请求从 UI 发送到 Controller,然后 Worker 线程与 Controller 进行核实,确定是否发生了取消请求。UI 和 Controller 都不会强制辅助代码终止,而是允许辅助代码自己正常安全地终止。