扫一扫
分享文章到微信
扫一扫
关注官方公众号
至顶头条
使用多线程,可以使应用程序同时执行多项任务。使用多线程,可以让一个线程运行用户界面,让另一个线程进行复杂运算或在后台操作。由于 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.
Worker 类中的实例变量可以存放其数据。后台线程可以安全地使用这些变量(mInner 和 mOuter),还可以确保其他线程不会同时访问这些变量。
我们可以用其中包含的 constructor 方法使用任何起始数据初始化该对象。实际启动后台线程之前,我们的主应用程序代码会创建此对象的实例,并使用后台线程将要操作的数据对其进行初始化。
后台线程将获取对象的 Work 方法的地址,然后开始启动。此线程将立即在对象内部运行代码,并使用该对象的专用数据。
由于对象是自包含的,因此我们可以创建多个对象,每个对象在其自身的线程上运行并且对象之间相对独立。
但是,此实现方案并不理想。UI 无法获得后台进程的状态。我们也未实现任何机制,使 UI 能够请求终止后台进程。
要解决以上两个问题,后台线程与 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 对象的引用,而使用
由于线程的工作方式,
请注意,IWorker 接口中不存在 Cancel 或 Stop 方法。我们不能强制辅助代码停止,同时也没有这个必要;但是辅助代码可以使用 IController 接口询问 Controller 对象是否存在取消请求。
IController 接口定义了辅助代码可以在 Controller 对象上调用的方法。它允许辅助代码检查 Running 标志。如果存在取消请求,Running 标志即为 False。它还允许辅助代码在工作完成或无法完成时告诉 Controller,并允许使用状态消息和完成百分比值(0 到 100 之间的 Integer)更新 Controller。
最后我们定义了 Controller 对象。该对象中包含一些可以被 UI 代码调用的方法。其中包括
Controller 类中包含的 constructor 方法接受 IClient 作为参数,还允许 UI 为 Controller 提供对窗体(用于处理 Worker 中的显示消息)的引用。
为了实现一系列动画点来显示线程的活动,我们将创建一个简单 Windows 窗体控件,该控件使用计时器以更改一系列 PictureBox 控件中的颜色。
实现方案
我们将在 Class Library(类库)项目中实现此架构,使其可用于需要运行后台进程的应用程序。
打开 Visual Studio .NET,然后创建一个名为 Background 的新 Class Library(类库)应用程序。由于此库将包含 Windows 窗体控件和窗体,因此需要使用 Add References(添加引用)对话框引用 System.Windows.Forms.dll 和 System.Windows.Drawing.dll。此外,我们可以使用项目的属性对话框在这些项目范围内导入命名空间。
此操作完成后,就可以开始编码了。让我们先从创建接口开始。
定义接口
在名为 IClient 的项目中添加一个类,并用以下代码替换其代码:
Public Interface IClient
Sub
Sub Display(ByVal Text As String)
Sub Failed(ByVal e As Exception)
Sub Completed(ByVal Cancelled As Boolean)
End Interface
然后添加名为 IWorker 的类,并用以下代码替换其代码:
Public Interface IWorker
Sub Initialize(ByVal Controller As IController)
Sub
End Interface
最后添加名为 IController 的类,代码如下:
Public Interface IController
ReadOnly Property Running() As Boolean
Sub Display(ByVal Text As String)
Sub SetPercent(ByVal Percent As Integer)
Sub Failed(ByVal e As Exception)
Sub Completed(ByVal Cancelled As Boolean)
End Interface
至此,我们已定义了本文前面所述的所有类图表中的接口。现在可以实现 Controller 类了。
Controller 类
现在,我们可以实现架构的核心,Controller 类。此类中包含的代码可用于启动辅助线程,以及在辅助线程完成之前充当 UI 线程和辅助线程之间的媒介。
在名为 Controller 的项目中添加一个新类。首先添加 Imports,并声明一些变量:
Imports System.Threading
Public Class Controller
Implements IController
Private mWorker As IWorker
Private mClient As Form
Private mRunning As Boolean
Private mPercent As Integer
然后需要声明一些委托。委托是方法的正式指针,而且方法的委托必须具有与方法本身相同的方法签名(参数类型等)。
如果您非常迫切的想了解IT领域最新产品与技术信息,那么订阅至顶网技术邮件将是您的最佳途径之一。
现场直击|2021世界人工智能大会
直击5G创新地带,就在2021MWC上海
5G已至 转型当时——服务提供商如何把握转型的绝佳时机
寻找自己的Flag
华为开发者大会2020(Cloud)- 科技行者