代码改变世界

使用VB.NET开发多线程

2011-06-07 23:31  bingcaihuang  阅读(29764)  评论(0编辑  收藏  举报

摘要:.NET 框架提供了新的类,可以方便地创建多线程应用程序。本文介绍如何使用 Visual Basic® .NET 的多线程编程技术来开发效率更高、响应速度更快的应用程序。

目录

  • 简介
  • 多线程处理的优点
  • 创建新线程
  • 同步线程
  • 线程计时器
  • 取消任务
  • 总结

简介

过去,Visual Basic 开发人员创建的应用程序都是程序任务依次执行的同步应用程序。虽然多线程应用程序因多个任务几乎同时运行而具有更高的效率,但使用早期版本的 Visual Basic 来创建这样的应用程序却很困难。

一项称为多任务处理的操作系统功能使多线程程序成为可能,它能模拟同时运行多个应用程序的功能。虽然多数个人计算机都只安装了一个处理器,但现代操作系统通过将处理器时间分配给多段可执行代码(称为线程),提供了多任务处理功能。线程可以代表整个应用程序,但通常只代表应用程序中可单独运行的一部分。操作系统根据线程的优先级、上次运行线程后经过的时间等因素为每个线程分配处理时间。在执行耗时的任务(如文件输入和输出)时,多线程能够显著提高性能。

但要注意一个问题。虽然多线程可以提高性能,但每个线程都需要额外的内存来创建线程,还需要处理器时间来运行线程。如果创建的线程过多,反而会降低应用程序的性能。在设计多线程应用程序时,应在添加更多线程所获得的好处及其成本之间进行权衡。

多任务处理成为操作系统的一部分已经很长时间了。但直到最近,Visual Basic 程序员也只能通过非正式发布的功能,来执行多线程任务,或者通过使用 COM 组件或操作系统的异步组件,来间接实现此功能。而 .NET 框架在 System.Threading 命名空间中为开发多线程应用程序提供了全面的支持。

本文讨论多线程的一些优点以及如何使用 Visual Basic .NET 来开发多线程应用程序。虽然 Visual Basic .NET 和 .NET 框架使多线程应用程序的开发变得很简单,但本文主要面向中高级开发人员,以及正在从 Visual Basic 的早期版本过渡到 Visual Basic .NET 的开发人员。对于 Visual Basic .NET 的初学者,请首先阅读 Visual Basic Language Tour(英文)中的相应主题。

本文并非是对多线程编程的全面讨论。要获得更多的信息,请参阅本文最后列出的其他资源。

多线程处理的优点

同步应用程序的开发比较容易,但由于需要在上一个任务完成后才能开始新的任务,所以其效率通常比多线程应用程序低。如果完成同步任务所用的时间比预计时间长,应用程序可能会不响应。多线程处理可以同时运行多个过程。例如,文字处理器应用程序在您处理文档的同时,可以检查拼写(作为单独的任务)。由于多线程应用程序将程序划分成独立的任务,因此可以在以下方面显著提高性能:

  • 多线程技术使程序的响应速度更快,因为用户界面可以在进行其他工作的同时一直处于活动状态。
  • 当前没有进行处理的任务可以将处理器时间让给其他任务。
  • 占用大量处理时间的任务可以定期将处理器时间让给其他任务。
  • 可以随时停止任务。
  • 可以分别设置各个任务的优先级以优化性能。

是否需要创建多线程应用程序取决于多个因素。在以下情况下,最适合采用多线程处理:

  • 耗时或大量占用处理器的任务阻塞用户界面操作。
  • 各个任务必须等待外部资源(如远程文件或 Internet 连接)。

例如,用于跟踪 Web 页上的链接并下载满足特定条件的文件的 Internet 应用程序“robot”。这种应用程序可以依次同步下载各个文件,也可以使用多线程同时下载多个文件。多线程方法比同步方法的效率高很多,因为即使在某些线程中远程 Web 服务器的响应非常慢,也可以下载文件。

创建新线程

创建线程最直接的方法是创建新的线程类实例,并使用 AddressOf 语句为要运行的过程传递委托。例如,以下代码将名为 SomeTask 的子过程作为单独的线程运行。

Dim Thread1 As New System.Threading.Thread(AddressOf SomeTask)
Thread1.Start
' 此处的代码立即运行。

以上所述就是创建和启动线程的方法。在线程 Start 方法调用之后的任何代码将立即运行,而无需等待前一个线程运行结束。

下表列出了用于控制各个线程的一些方法。

方法操作
Start 使线程开始运行。
Sleep 使线程暂停一段指定的时间。
Suspend 使线程在到达安全点后暂停。
Abort 使线程在到达安全点后停止。
Resume 重新启动挂起的线程。
Join 使当前线程等待其他线程运行结束。如果使用超时值,且线程在分配的时间内结束,此方法将返回 True

多数方法都无需再加以说明,但“安全点”可能是个新的概念。安全点是指代码中的某些位置,在这些位置公共语言运行时可以安全地执行自动垃圾回收,即释放未使用的变量并回收内存。调用线程的 Abort 或 Suspend 方法时,公共语言运行时将分析代码并确定线程停止运行的适当位置。

线程还包含许多有用的属性,如下表所示:

属性
IsAlive 如果线程处于活动状态,则包含值 True
IsBackground 获取或设置布尔值,指示线程是否是后台线程或是否应该是后台线程。后台线程与前台线程类似,但后台线程并不阻止进程的终止。当进程的所有前台线程都终止后,公共语言运行时将对仍处于活动状态的后台线程调用 Abort 方法,以结束该进程。
Name 获取或设置线程的名称。常用于在调试时查找各个线程。
Priority 获取或设置操作系统用来确定线程优先级安排的值。
ApartmentState 获取或设置用于特定线程的线程模型。当线程调用非托管的代码时,线程模型将非常重要。
ThreadState 包含说明线程状态的值。

线程属性和方法对创建和管理线程非常有用。本文的线程同步部分将介绍如何使用这些属性和方法控制和协调线程。

线程参数和返回值

前面示例中的方法调用不能包含任何参数或返回值。这一限制是使用此方法创建和运行线程的主要缺点之一。然而,可以通过将在单独的线程中运行的过程包装到类或结构中,为它们提供参数,并使之能返回参数。

Class TasksClass
   Friend StrArg As String
   Friend RetVal As Boolean
   Sub SomeTask()
      ' 将 StrArg 字段用作参数。
      MsgBox("StrArg 包含字符串" & StrArg)
      RetVal = True ' 设置返回参数的返回值。
   End Sub
End Class
' 要使用类,请设置存储参数的属性或字段,
' 然后,根据需要异步调用方法。
Sub DoWork()
   Dim Tasks As New TasksClass()
   Dim Thread1 As New System.Threading.Thread( _
       AddressOf Tasks.SomeTask)
   Tasks.StrArg = "某个参数" ' 设置用作参数的字段。
   Thread1.Start() ' 启动新线程。
   Thread1.Join() ' 等待线程 1 运行结束。
   ' 显示返回值。
   MsgBox("线程 1 返回值" & Tasks.RetVal)
End Sub

手动创建和管理线程最适合需要控制细节(例如线程优先级和线程模型)的应用程序。可以想象,使用这种方法管理大量线程将是非常困难的。如果需要很多线程,可以考虑使用线程池以降低复杂程度。

线程池

线程池是多线程的一种形式。在线程池中,当创建线程时任务被添加到队列并自动启动。使用线程池,可以使用要运行的过程的委托来调用 Threadpool.QueueUserWorkItem 方法,Visual Basic .NET 将创建线程并运行该过程。以下示例说明了如何使用线程池启动多个任务。

Sub DoWork()
   Dim TPool As System.Threading.ThreadPool
   ' 将一个任务排队
   TPool.QueueUserWorkItem(New System.Threading.WaitCallback _
                            (AddressOf SomeLongTask))
   ' 将另一个任务排队
   TPool.QueueUserWorkItem(New System.Threading.WaitCallback _
                            (AddressOf AnotherLongTask))
End Sub

如果要启动很多单独的任务,但并不需要单独设置每个线程的属性,则线程池将非常有用。每个线程都以默认的堆栈大小和优先级启动。默认情况下,每个系统处理器上最多可以运行 25 个线程池线程。超过该限制的其他线程会被排队,直至其他线程运行结束后它们才能开始运行。

线程池的一个优点是可以将状态对象中的参数传递到任务过程。如果正在调用的过程需要多个参数,则可以将类的结构或实例强制转换为 Object 数据类型。

参数和返回值

从线程池线程返回值有点复杂。不允许使用从函数调用返回值的标准方法,因为只有 Sub 过程可以排队进入线程池。提供参数和返回值的一种方法是将参数、返回值和方法包装到包装类中,如线程参数和返回值中所述。一种更简单的提供参数和返回值的方法,是使用 QueueUserWorkItem 方法的 ByVal 状态对象变量(可选)。如果使用此变量将引用传递给类的实例,则该实例的成员便可以由线程池线程修改并用作返回值。您可以修改由变量(通过值传递)引用的对象,这在开始可能并非显而易见,但的确是可能的,因为只有对象引用是通过值传递的。对由对象引用所引用的对象成员进行更改之后,这些更改将应用于实际的类实例。

不能使用结构返回状态对象中的值。因为结构是值类型,异步进程所作的更改并不更改原始结构的成员。如果不需要返回值,则可以使用结构提供参数。

Friend Class StateObj
   Friend StrArg As String
   Friend IntArg As Integer
   Friend RetVal As String
End Class

Sub ThreadPoolTest()
   Dim TPool As System.Threading.ThreadPool
   Dim StObj1 As New StateObj()
   Dim StObj2 As New StateObj()
   ' 设置一些字段,用作状态对象中的参数。
   StObj1.IntArg = 10
   StObj1.StrArg = "某个字符串"
   StObj2.IntArg = 100
   StObj2.StrArg = "另一个字符串"
   ' 将一个任务排队
   TPool.QueueUserWorkItem(New System.Threading.WaitCallback _
                          (AddressOf SomeOtherTask), StObj1)
   ' 将另一个任务排队
   TPool.QueueUserWorkItem(New System.Threading.WaitCallback _
                          (AddressOf AnotherTask), StObj2)
End Sub

Sub SomeOtherTask(ByVal StateObj As Object)
   ' 将状态对象字段用作参数。
   Dim StObj As StateObj
   StObj = CType(StateObj, StateObj)   ' 强制转换为正确的类型。
   MsgBox("StrArg 包含字符串" & StObj.StrArg)
   MsgBox("IntArg 包含数字" & CStr(StObj.IntArg))
   ' 将字段用作返回值。
   StObj.RetVal = "SomeOtherTask 的返回值"
End Sub

Sub AnotherTask(ByVal StateObj As Object)
   ' 将状态对象字段用作参数。
   ' 状态对象作为 Object 进行传递。
   ' 将其强制转换为特定的类型以使其更易于使用。
   Dim StObj As StateObj
   StObj = CType(StateObj, StateObj)
   MsgBox("StrArg 包含字符串 " & StObj.StrArg)
   MsgBox("IntArg 包含数字" & CStr(StObj.IntArg))
   ' 将字段用作返回值。
   StObj.RetVal = "AnotherTask 的返回值"
End Sub

公共语言运行时自动为排队的线程池任务创建线程,然后,当任务完成后释放这些资源。将任务排队后,很难再将其取消。ThreadPool 线程始终使用多线程单元 (MTA) 线程模型来运行。如果需要使用单线程单元 (STA) 模型的线程,则应手动创建线程。

同步线程

同步在多线程编程的非结构化性质与同步处理的结构化次序之间提供了一个折衷的办法。

使用同步技术,可以完成以下操作:

  • 在必须以特定顺序执行任务时,显式控制代码运行的次序。

    - 或者 -

  • 当两个线程同时共享相同的资源时,避免可能出现的问题。

例如,可以使用同步使显示过程处于等待状态,直至在另一线程中运行的数据检索过程结束。

同步的方法有两种:轮询和使用同步对象。轮询反复从循环中检查异步调用的状态。使用轮询管理线程的效率最低,因为反复检查各种线程属性的状态会浪费大量资源。

例如,如果轮询要查看线程是否已结束,可以使用 IsAlive 属性。使用此属性时要很小心,因为活动的线程不一定正在运行。可以使用线程的 ThreadState 属性来获得有关线程状态的详细信息。由于在任意给定时间,线程都可能处于多种状态,因此 ThreadState 中存储的值可以是 System.Threading.Threadstate 枚举中的值的组合。因此,在轮询时应当仔细检查所有相关的线程状态。例如,如果线程的状态表明它没有运行,则该线程可能已经完成。另一方面,它也可能被挂起或处于休眠状态。

可以想象,轮询为控制运行线程的次序,牺牲了多线程的部分优点。为此,可以使用效率较高的 Join 方法来控制线程。Join 使调用过程处于等待状态,直至线程完成或调用超时(如果指定了超时)。“Join”这个名称来自这一想法,即创建的新线程是执行路径的一个分支。使用 Join 可以再次将单独的执行路径合并成一个线程。

图 1:线程

有一点需要清楚:Join 是同步调用或阻塞调用。调用 Join 或等待句柄的等待方法后,调用过程将停止并等待线程发出信号通知它已经完成。

Sub JoinThreads()
   Dim Thread1 As New System.Threading.Thread(AddressOf SomeTask)
   Thread1.Start()
   Thread1.Join()      ' 等待线程运行结束。
   MsgBox("线程运行结束")
End Sub

这些控制线程的简单方法在管理少量线程时非常有用,但不适合大型项目。下一节将讨论可用于同步线程的一些高级技术。

高级同步技术

多线程应用程序通常使用等待句柄和监视器对象来同步多个线程。下表介绍了可用于同步线程的部分 .NET 框架类。

用途
AutoResetEvent 等待句柄,用于通知一个或多个等待线程发生了一个事件。AutoResetEvent 在等待线程被释放后自动将状态更改为已发出信号。
Interlocked 为多个线程共享的变量提供原子操作。
ManualResetEvent 等待句柄,用于通知一个或多个等待线程发生了一个事件。手动重置事件的状态将保持为已发出信号,直至 Reset 方法将其设置为未发出信号状态。同样,该状态将保持为未发出信号,直至 Set 方法将其设置为已发出信号状态。当对象的状态为已发出信号时,任意数量的等待线程(即通过调用一个等待函数开始对指定事件对象执行等待操作的线程)都可以被释放。
Monitor 提供同步访问对象的机制。Visual Basic .NET 应用程序调用 SyncLock 以使用监视器对象。
Mutex 等待句柄,可用于进程间同步。
ReaderWriterLock 定义用于实现单个写入者和多个读取者的锁定。
Timer 提供按指定间隔运行任务的机制。
WaitHandle 封装操作系统特有的、等待对共享资源进行独占访问的对象。

等待句柄

等待句柄是将一个线程的状态通知另一个线程的对象。线程可以使用等待句柄,通知其他线程它们需要对资源进行独占访问。然后,其他线程必须等到没有线程在使用等待句柄时才能使用此资源。等待句柄有两种状态:已发出信号和未发出信号。不属于任何线程的等待句柄处于已发出信号状态。属于某线程的等待句柄处于未发出信号状态。

线程通过调用一种等待方法(例如 WaitOneWaitAny 或 WaitAll)来请求等待句柄的所有权。等待方法是与单独线程的 Join 方法相类似的阻塞调用。

  • 如果没有其他线程拥有该等待句柄,则调用将立即返回 True,等待句柄的状态将更改为未发出信号,而拥有等待句柄的线程将继续运行。
  • 如果线程调用了等待句柄的一种等待方法,但该等待句柄归另一线程所有,则调用线程将等待指定的时间(如果指定了超时),或者无限期地等待(未指定超时),直至其他线程释放等待句柄。如果指定了超时,并且在超时到期前释放等待句柄,则调用返回 True。否则,调用返回 False,并且进行调用的线程将继续运行。

拥有等待句柄的线程在运行结束后,或不再需要等待句柄时将调用 Set 方法。其他线程通过调用 Reset 方法,或者调用 WaitOneWaitAll 或 WaitAny 以及成功地等待某一线程调用 Set 方法之后,可以将等待句柄的状态重置为未发出信号。在单个等待线程被释放后,系统将 AutoResetEvent 句柄自动重置为未发出信号。如果没有线程处于等待状态,则事件对象的状态将保持为已发出信号。

方法用途
WaitOne 接受一个等待句柄作为参数,并使调用线程处于等待状态,直至另一个进程调用 Set 将当前的等待句柄设置为已发出信号。
WaitAny 接受一个等待句柄数组作为参数,并使调用线程处于等待状态,直至任一指定的等待句柄已通过调用 Set 设置为已发出信号。
WaitAll 接受一个等待句柄数组作为参数,并使调用线程处于等待状态,直至所有指定的等待句柄已通过调用 Set 设置为已发出信号。
Set 将指定的等待句柄的状态设置为已发出信号,并使任何等待线程继续运行。
Reset 将指定事件的状态设置为未发出信号。

Visual Basic .NET 常用的等待句柄有三种:互斥对象、ManualResetEvent 和 AutoResetEvent。后两种通常称为同步事件。

互斥对象

互斥对象是一次只能由一个线程拥有的同步对象。实际上,“互斥”这个名称来自互斥对象的所有权相互排斥这一事实。如果线程要对资源进行独占访问,则需要请求互斥对象的所有权。由于在任何时刻,只能有一个线程拥有互斥对象,因此其他线程必须等待,直至获得互斥对象的所有权后才能使用资源。

WaitOne 方法使调用线程等待获得互斥对象的所有权。如果拥有互斥对象的线程正常终止,则互斥对象的状态将设置为已发出信号,下一个等待线程将获得所有权。

同步事件

同步事件用于通知其他线程某件事情已发生或某个资源已可用。不要被这些使用“事件”一词的项误导。同步事件与其他 Visual Basic 事件不同,它们实际上是等待句柄。与其他等待句柄类似,同步事件也有两种状态:已发出信号和未发出信号。调用同步事件的一种等待方法的线程必须等待,直至另一个线程通过调用 Set 方法向事件发出通知。有两种同步事件类。线程使用 Set 方法将 ManualResetEvent 实例的状态设置为已发出信号。线程使用 Reset 方法,或者在控制返回到一个等待 WaitOne 的调用时,将 ManualResetEvent 实例的状态设置为未发出信号。还可以使用 Set 将 AutoResetEvent 类的实例设置为已发出信号,但是只要等待线程被通知事件已发出信号,这些实例就自动返回到未发出信号状态。

以下示例使用 AutoResetEvent 类来同步线程池任务。

Sub StartTest()
   Dim AT As New AsyncTest()
   AT.StartTask()
End Sub

Class AsyncTest
   Private Shared AsyncOpDone As New _
      System.Threading.AutoResetEvent(False)

   Sub StartTask()
      Dim Tpool As System.Threading.ThreadPool
      Dim arg As String = "SomeArg"
      Tpool.QueueUserWorkItem(New System.Threading.WaitCallback( _
         AddressOf Task), arg)  ' 将一个任务排队。
      AsyncOpDone.WaitOne() ' 等待线程调用 Set。
      MsgBox("线程运行结束。")
   End Sub

   Sub Task(ByVal Arg As Object)
      MsgBox("线程正在启动。")
      System.Threading.Thread.Sleep(4000) ' 等待 4 秒钟。
      MsgBox("状态对象包含字符串 " & CStr(Arg))
      AsyncOpDone.Set()   ' 通知线程运行结束。
   End Sub
End Class

监视器对象和 SyncLock

监视器对象用于确保代码块在运行时不会被其他线程运行的代码中断。换句话说,直到同步代码块中的代码运行结束后,其他线程中的代码才能运行。在 Visual Basic .NET 中,SyncLock 关键字用于简化对监视器对象的访问。在 Visual C#® .NET 中则使用 Lock 关键字。

例如,假设有一个反复异步读取数据并显示结果的程序。如果操作系统使用抢占式多任务处理技术,则可以中断正在运行的线程而将时间用于运行其他某个线程。如果不进行同步,则如果在显示数据时,代表数据的对象被其他线程修改,则可能会看到被部分更新的数据。SyncLock 语句可以保证代码段在运行时不会被中断。以下示例说明了如何使用 SyncLock 为显示过程提供数据对象的独占访问权限。

Class DataObject
   Public ObjText As String
   Public ObjTimeStamp As Date
End Class

Sub RunTasks()
   Dim MyDataObject As New DataObject()
   ReadDataAsync(MyDataObject)
   SyncLock MyDataObject
      DisplayResults(MyDataObject)
   End SyncLock
End Sub

Sub ReadDataAsync(ByRef MyDataObject As DataObject)
   ' 添加代码以异步读取和处理数据。
End Sub

Sub DisplayResults(ByVal MyDataObject As DataObject)
   ' 添加代码以显示结果。
End Sub

如果需要确保代码段不会被在其它线程中运行的代码中断,请使用 SyncLock

Interlocked 类

为避免在多个线程尝试同时更新或比较相同的值时可能出现的问题,可以使用 Interlocked 类的方法。此类的方法使您能够安全地递增、递减、交换和比较任何线程中的值。以下示例说明了如何使用 Increment 方法来递增由在其它线程中运行的过程所共享的变量。

Sub ThreadA(ByRef IntA As Integer)
   System.Threading.Interlocked.Increment(IntA)
End Sub

Sub ThreadB(ByRef IntA As Integer)
   System.Threading.Interlocked.Increment(IntA)
End Sub

ReaderWriter 锁定

在某些情况下,可能希望只在写入数据时锁定资源,而在不更新数据时则允许多个客户端同时读取数据。ReaderWriterLock 类在线程修改资源时强制独占访问资源,但在读取资源时允许进行非独占访问。ReaderWriter 锁定是独占锁定的一个很有用的替代选择,因为独占锁定使其他线程一直处于等待状态,即使那些线程并不需要更新数据。以下示例说明了如何使用 ReaderWriter 来协调多个线程的读写操作。

Class ReadWrite
' 可以从多个线程中安全地调用
' ReadData 和 WriteData 方法。
   Public ReadWriteLock As New System.Threading.ReaderWriterLock()
   Sub ReadData()
      ' 此过程从某个来源读取信息。
      ' 读取锁定禁止在线程完成读取之前写入数据, 
      ' 同时允许其他线程调用 ReadData。
      ReadWriteLock.AcquireReaderLock(System.Threading.Timeout.Infinite)
      Try
         ' 此处执行读取操作。
      Finally
         ReadWriteLock.ReleaseReaderLock() ' 释放读取锁定。
      End Try
   End Sub

   Sub WriteData()
      ' 此过程将信息写入某个来源。
      ' 写入锁定禁止在线程完成写入操作前
      ' 读取或写入数据。
      ReadWriteLock.AcquireWriterLock(System.Threading.Timeout.Infinite)
      Try
         ' 此处执行写入操作。
      Finally
         ReadWriteLock.ReleaseWriterLock() ' 释放写入锁定。
      End Try
   End Sub
End Class

死锁

线程同步在多线程应用程序中十分重要,但在多个线程相互等待时总是存在死锁的危险。就象四个方向上都停有汽车的情况,每个人都在等待另一个人走,死锁使一切操作终止。显然,避免死锁非常重要。有许多情况会导致死锁,同样,避免死锁的方法也很多。虽然本文没有足够篇幅来讨论与死锁相关的所有问题,但有一点很重要,即认真规划是避免死锁的关键。在开始编码之前,通过图解多线程应用程序,通常可以预测死锁。

线程计时器

Threading.Timer 类对在单独线程中定期运行任务十分有用。例如,可以使用线程计时器检查数据库的状态和完整性,或者备份重要文件。以下示例每两秒钟启动一个任务,并使用标志来启动使计时器停止的 Dispose 方法。本例将状态发送到输出窗口,因此在测试代码之前,应按 CONTROL+ALT+O 键以使此窗口可见。

Class StateObjClass
' 用于保留调用 TimerTask 所需的参数
      Public SomeValue As Integer
      Public TimerReference As System.Threading.Timer
      Public TimerCanceled As Boolean
End Class

Sub RunTimer()
   Dim StateObj As New StateObjClass()
   StateObj.TimerCanceled = False
   StateObj.SomeValue = 1
   Dim TimerDelegate As New Threading.TimerCallback(AddressOf TimerTask)
   ' 创建每隔 2 秒钟调用过程的计时器。
   ' 注意:这里没有 Start 方法;创建实例之后, 
   ' 计时器就开始运行。
   Dim TimerItem As New System.Threading.Timer(TimerDelegate, StateObj, _
                                               2000, 2000)
   StateObj.TimerReference = TimerItem  ' 为 Dispose 保存一个引用。

   While StateObj.SomeValue < 10 ' 运行 10 个循环。
      System.Threading.Thread.Sleep(1000)  ' 等待 1 秒钟。
转自:http://blog.csdn.net/wuhuwy/archive/2009/04/23/4102824.aspx