多线程程序是可行的,因为操作系统是多任务的,它有模拟同一时刻运行多个应用程序的能力。尽管多数个人计算机只有一个处理器,但是现在的操作系统还是通过在多个执行代码片断之间划分处理器时间提供了多任务。线程可能是整个应用程序,但通常是应用程序可以单独运行的一个部分。操作系统根据线程的优先级和离最近运行的时间长短给每一个线程分配处理时间。多线程对于时间密集型事务(例如文件输入输出)应用程序的性能有很大的提高。
但是也有必须细心的地方。尽管多线程能提高性能,但是每个线程还是需要用附加的内存来建立和处理器时间来运行,建立太多的线程可能降低应用程序的性能。当设计多线程应用程序时,应该比较性能与开销。
多任务成为操作系统的一部分已经很久了。但是直到最近Visual Basic程序员才能使用无文档记录特性(undocumented)或者间接使用COM组件或者操作系统的异步部分执行多线程事务。.NET框架组件为开发多线程应用程序,在System.Threading名字空间中提供了全面的支持。
本文讨论多线程的好处以及怎样使用Visual Basic .NET开发多线程应用程序。尽管Visual Basic .NET和.NET框架组件使开发多线程应用程序更容易,但是本文作了调整使其适合高级读者和希望从早期Visual Basic转移到Visual Basic .NET的开发人员。
多线程处理的优点
尽管同步应用程序易于开发,但是它们的性能通常比多线程应用程序低,因为一个新的事务必须等待前面的事务完成后才能开始。如果完成某个同步事务的时间比预想的要长,应用程序可能没有响应。多线程处理可以同时运行多个过程。例如,字处理程序能够在继续操作文档的同时执行拼写检查事务。因为多线程应用程序把程序分解为独立的事务,它们能通过下面的途径充分提高性能:
l 多线程技术可以使程序更容易响应,因为在其它工作继续时用户界面可以保持激活。
l 当前不忙的事务可以把处理器时间让给其它事务。
l 花费大量处理时间的事务可以周期性的把时间让给其它的事务。
l 事务可以在任何时候停止。
l 可以通过把单独事务的优先级调高或调低来优化性能。
明确地建立多线程应用程序的决定依赖于几个因素。多线程最适合下面的情况:
l 时间密集或处理密集的事务妨碍用户界面。
l 单独的事务必须等待外部资源,例如远程文件或Internet连接。
例如,某个应用程序跟随Web页面上的链接并下载符合特定条件的文件。这种应用程序可以同步一个接一个地下载文件或者使用多线程在同一时刻下载多个文件。多线程的方法比同步方法的效率高得多,因为即使某些线程从远程Web服务器上接收到的响应很慢,文件也可以被下载。
建立新线程
建立线程的最直接的方法是建立线程类的一个新的实例并且使用AddressOf语句替你希望运行的过程传递一个委托。例如下面的代码运行一个作为单独的线程的叫做SoMetask的子过程。
Dim Thread1 As New System.Threading.Thread(AddressOf SoMetask) Thread1.Start ' 这儿的代码立即运行 |
这就是建立和启动线程的全部工作。调用线程的Start方法后面的任何代码立即执行,不需要等待前面线程的结束。
下表是你能使用的控制单独线程的方法:
上面的大多数方法字面上容易理解,但是安全点(safe point)的概念对你来说可能是新的。安全点是代码中的某个位置,在这个位置通用语言运行时可以安全地执行自动无用单元收集(garbage collection,释放无用变量并恢复内存的过程)。当调用线程的Abort或Suspend方法时,通用语言运行时分析代码,决定线程停止运行的适当位置。
下表是线程的一些常用的属性:
当建立和管理线程时它的属性和方法很重要。本文的"线程同步"部分将讨论你怎样使用这些属性和方法控制和调整线程。
线程参数和返回值
前面例子中的线程调用没有参数和返回值。这是使用这种方法建立和运行线程的主要缺点之一。但是,你可以在类或结构体中包装线程,为运行在单独线程上的过程提供和返回参数。
Friend StrArg As String Friend RetVal As Boolean Sub SoMetask() ' StrArg字段是一个参数 MsgBox("The StrArg contains the string " & 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 = "Some Arg" ' 设置作为参数使用的字段 Thread1.Start() ' 启动新线程 Thread1.Join() ' 等待线程1结束 ' 显示返回值 MsgBox("Thread 1 returned the value " & 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 = "Some string" StObj2.IntArg = 100 StObj2.StrArg = "Some other string" ' 对一个事务进行排队 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 contains the string " & StObj.StrArg) MsgBox("IntArg contains the number " & CStr(StObj.IntArg)) ' 使用一个字段作为返回值 StObj.RetVal = "Return Value from SomeOtherTask" End Sub Sub AnotherTask(ByVal StateObj As Object) ' 使用状态对象作为参数。状态对象作为Object传递。把它转换为特定类型使使用更容易 Dim StObj As StateObj StObj = CType(StateObj,StateObj) MsgBox("StrArg contains the String " & StObj.StrArg) MsgBox("IntArg contains the number " & CStr(StObj.IntArg)) ' 使用一个字段作为返回值 StObj.RetVal = "Return Value from AnotherTask" End Sub |
通用语言运行时自动为排队的线程池事务建立线程,当这些事务完成时释放这些资源。一旦事务被排队了,这就不是取消事务的容易的方法了。ThreadPool线程使用多线程单元(MTA)线程模型运行。如果你希望线程使用单线程单元模型(STA)运行,必须手工建立线程。
线程同步
同步提供了多线程编程的无组织特性和同步处理的有组织次序之间一种折衷的方法。使用同步技术能够达到的目标:
l 在事务必须按特定次序执行的时候,明确地控制代码运行的次序。
l 当两个线程在同一时刻共享相同的资源的时候防止错误的发生。
例如,你可以使用同步来引发一个显示过程等待另一个线程上运行的数据检索过程结束。
有两种同步的途径,轮询(polling)和使用同步对象。轮询是从某个循环中周期性地检查异步调用的状态。轮询是管理线程的效率最低的方法,因为它周期性检查多样线程属性的状态,浪费了资源。
例如当轮询查看某个线程是否终止时会使用IsAlive属性。使用这个属性必须注意,因为有效的线程不是一定运行的。你可以使用ThreadState属性获得线程状态的更多详细信息。因为在给定的时刻线程可能有一个以上的状态,ThreadState中存储的值可能是System.Threading.Threadstate枚举中值的组合。因此轮询时你必须仔细检查所有的相关线程状态。例如,如果线程的状态显示它不是Running的,它有可能结束了。另一方面,它也可能挂起或休眠了。
你可以想象,轮询为了换取对线程次序的控制牺牲了多线程的一些优点。效率更高的途径是使用Join方法控制线程。Join引发调用过程等待一个线程完成或者超时(如果指定了超时值)。Join这个名字基于建立新线程,它是执行路径中的分叉。你使用Join方法把单独的执行路径合并成单个线程。
图1.线程
有一点必须清楚,Join是同步的或阻塞的调用。一旦你调用Join或等待句柄的等待方法,调用过程会停止并等待线程发出完成信号。
Sub JoinThreads() Dim Thread1 As New System.Threading.Thread(AddressOf SoMetask) Thread1.Start() Thread1.Join() ' 等待该线程结束 MsgBox("Thread is done") End Sub |
这些简单的控制线程的方法对管理少量的线程是有用的,但是在大型项目中使用困难。下一部分讨论用于同步的一些高级技术。
高级同步技术
多线程应用程序通常使用等待处理和监视对象来同步多个线程。下表是.NET框架组件中能用于同步线程的一些类:
等待句柄
等待句柄是把某个线程的状态信号发送给另一个线程的对象。当线程需要独占访问某种资源时,它们可以使用等待句柄通知其它线程。其它线程必须等待这些资源,直到等待句柄不再使用。等待句柄有两种状态:signaled和nonsignaled。不属于任何线程的等待句柄状态为signaled。属于某个线程的等待句柄的状态是nonsignaled。
线程通过调用一个等待方法(例如WaitOne、 WaitAny或 WaitAll)来请求等待句柄的所有权。等待方法也是阻塞调用,与独立线程的Join方法类似。
l 如果其它线程没有拥有等待句柄,该调用立即返回True,等待线程的状态变为nonsignaled,拥有等待句柄的线程继续运行。
l 如果某个线程调用等待句柄的一个等待方法,但是等待句柄属于另一个线程,发出调用的线程要么等待一个特定时间(如果指定了超时值)或者等待不确定的时长(没有指定超时值)直到其它线程释放等待句柄。如果设置了超时值并且等待句柄在期满前被释放了,该调用将返回True。否则,该调用返回False,发送调用的线程继续运行。
当拥有等待句柄的线程完成后或者它们再也不需要等待句柄时,它们调用Set方法。其它线程可以通过调用Reset方法或WaitOne、WaitAll、 WaitAny把等待句柄的状态复位成nonsignaled,并且成功地等待某个线程调用Set。当某个等待线程被释放后系统自动把AutoResetEvent句柄复位成nonsignaled。如果没有线程在等待,该事件对象的状态仍然为signaled。
Visual Basic .NET中通常使用三类等待句柄:互斥对象、ManualResetEvent和AutoResetEvent。后两种通常用于同步事件。
互斥对象
互斥对象都是同步对象,它们只能在一个时刻由一个线程拥有。实际上,互斥这个名字衍生自互斥对象的所有权是相互排斥的。当线程请求独占访问某种资源时,它们请求互斥对象的所有权。因为在某个时刻只有一个线程能拥有一个互斥对象,其它线程在使用资源前必须等待互斥对象的所有权。
WaitOne方法引发一个调用线程等待互斥对象的所有权。如果拥有互斥对象的线程正常终止,该互斥对象的状态就被设置为signaled,下一个线程获得它的所有权。
同步事件
同步事件用于通知其它的线程发生了某种事情或者某种资源可用。不要被它使用了"事件"这个词迷惑了。同步事件与其它的Visual Basic事件不同,它是真正的等待句柄。与其它的等待句柄类似,同步事件有两种状态signaled 和nonsignaled。调用同步事件的某个等待方法的线程必须等待,直到其它线程调用Set方法给事件发信号。有两个同步事件类。线程使用Set方法把ManualResetEvent实例的状态设置为signaled。线程使用Reset方法或控制返回等待WaitOne调用把实例的状态设置为nonsignaled。AutoResetEvent类的实例也可以使用Set设置为signaled,但是只要通知等待线程事件变为signaled,它们自动返回到nonsignaled。
下面的例子使用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("Thread is done.") End Sub Sub Task(ByVal Arg As Object) MsgBox("Thread is starting.") System.Threading.Thread.Sleep(4000) ' 等待4秒. MsgBox("The state object contains the string " & CStr(Arg)) AsyncOpDone.Set() ' 发信号表明该线程完成了 End Sub End Class |
监视对象和同步锁
监视对象确保代码块的运行不被运行在其它线程中的代码打断。换句话说,其它线程中的代码不能运行,直到被同步的代码块结束。在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秒 End While StateObj.TimerCanceled = True ' 请求计时器对象的Dispose End Sub Sub TimerTask(ByVal StateObj As Object) Dim State As StateObjClass = CType(StateObj,StateObjClass) Dim x As Integer ' 使用interlocked类增加计数器变量的值 System.Threading.Interlocked.Increment(State.SomeValue) Debug.WriteLine("Launched new thread " & Now) If State.TimerCanceled Then ' 请求Dispose State.TimerReference.Dispose() Debug.WriteLine("Done " & Now) End If End Sub |
取消事务
多线程的优点之一是应用程序的用户界面保持响应时,事务可以在其它的线程上运行。同步事件和作为标记的字段通常用于通知其它线程你希望停止它。下面的例子使用同步事件取消一个事务。为了使用这个例子,给项目添加下面的模块。调用StartCancel.StartTask()启动一个线程,调用StartCancel.CancelTask()取消一个或多个正在运行的线程。
Module StartCancel Public CancelThread As New System.Threading.ManualResetEvent(False) Public ThreadisCanceled As New System.Threading.ManualResetEvent(False) Private Sub SomeLongTask() Dim LoopCount As Integer Dim Loops As Integer = 10 ' 在While循环中运行代码直到过10秒,或者设置CancelThread While Not CancelThread.WaitOne(0,False) And LoopCount < Loops ' 此处执行一些事务 System.Threading.Thread.Sleep(1000) ' 停止1秒 LoopCount += 1 End While If CancelThread.WaitOne(0,False) Then '设置了ManualResetEvent CancelThread ThreadisCanceled.Set() MsgBox("Canceling thread") Else MsgBox("Thread is done") End If End Sub Public Sub StartTask() '启动新线程 Dim th As New System.Threading.Thread(AddressOf SomeLongTask) CancelThread.Reset() ThreadisCanceled.Reset() th.Start() MsgBox("Thread Started") End Sub Public Sub CancelTask() ' 停止任何由StartTask过程启动的线程。注意这个线程同时接收和发送相应线程的同步事件 CancelThread.Set() ' 设置CancelThread 来请求线程停止 If ThreadisCanceled.WaitOne(4000,False) Then ' 等待4秒线程停止 MsgBox("The thread has stopped.") Else MsgBox("The thread could not be stopped.") End If End Sub End Module |
结论 多线程处理是可伸缩的、容易响应的应用程序的关键。Visual Basic .NET支持加强的、多线程开发模型,它使开发者迅速拥有了开发多线程应用程序的能力。 l Visual Basic .NET使用新的.NET框架组件类,它使建立多线程应用程序更容易。 l 记住尽管多线程能提高性能,但是每个线程有建立线程需要的附加内存和保持它运行需要的处理器时间的花消。 l 线程的属性和方法控制着线程间的交互操作,并且决定什么时候资源可以给运行的线程使用。 l 尽管多线程看起来带来了混乱,但是你可以使用同步技术控制正在运行的线程。 l 尽管多线程增加了应用程序的复杂性,但是它通过高效率分配可用资源提高了应用程序的可伸缩性。 使用本文讨论的技术,你可以开发和处理处理器密集型事务的专业应用程序。