用Visual Basic.Net创建多线程应用程序 |
||||||||
这篇文章假设读者已经拥有以下的编程经验:VB,Windows环境,基于事件的编程,基本的HTML和脚本知识。这篇文章是基于微软.NET的Beta2版本。 VB.NET的其中一个最令人期待的特性是可以创建和管理线程。虽然在VB6的应用中,我们可以通过Win32 CreateThread API来创建一个多线程的应用,或者通过欺骗COM库在一个独立的线程中创建一个组件,不过这些技术都是难以调试和维护的。 造成这些困难的主要原因是由于VB 6.0并不是用来处理多线程应用的,这样会导致访问违例和内存错误。不同的是,Common Language Runtime(CLR)是为多线程的环境设计的,实际上Services架构在基本委派体系中就暗中集成了这个功能。其实,通过使用System.Threading命名空间,Services架构还支持显式使用线程API。 对于那些不熟悉线程的读者,这里简单介绍一下,它可让你的应用分成多个单元执行,这些单元都是被抢先型的操作系统(例如Windows 2000)分配在不同时间运行,并且拥有不同的优先权。根据线程的优先权和特别的调度算法,操作系统分配每个线程运行一段的时间,称为time slice。当这段time slice过去时,线程就会挂起并且放回到队列中,接着另一个线程又会被分配一段time slice运行。在线程挂起时,它的状态就会被保存下来,以便下一次可以由停下来的地方开始工作。CLR支持线程的方式是通过启动每个带有一个主线程的AppDomain,并且允许它创建多个工作线程,每个工作线程都拥有自己的例外处理和状态数据。 在一个应用中使用超过一个线程的明显好处是你的应用看来正在同时执行几个任务,这是由于不同的线程都得到了CPU的运行时间。实际上,在一台拥有多个处理器的机器上,来自一个AppDomain的线程可分配在所有的处理器上运行,从而允许同时地运作。在分布式的应用时,这样可提升扩展性,因为更多的客户可以分享一个服务器上的CPU资源,而对于桌面的应用,例如电子表格和word等也能够从线程中得到好处,可执行后台的操作例如重新计算和打印。不过,在使用VB.NET写分布式的应用时,如何应用这个概念呢? 对于初学者,在你建立分布式的应用时,实际上你已经使用了一个多线程的体系。这是由于应用服务,例如IIS、组件服务和SQL Server全部都是多线程的。例如,在客户端请求网页时,它们的请求被由IIS控制的工作线程运载。这些线程中的其中之一可能会执行一个ASP.NET页面,该页面会调用组件服务中的一个组件。组件应该被配置为作为Server应用运行,这样它就会被该应用的一个线程池中的一个线程执行。组件也可能使用一个数据库连接,该连接是由SQL Server引擎分配的工作线程池中得到的。结果是,多个用户请求网页,要初始化组件和访问数据库时,它们的活动并不是连续的,因此不会受到单线程执行的限制。 由于你在分布式的应用中所写的大部分代码都是在中层执行的,因此有些情形你需要显式地创建线程。这些情形包括有长时间的操作,例如文件IO,数据库维护任务,在一个Windows服务应用中为多个客户服务,以及由一个Microsoft Message Queue监听信息。这里只是会为你介绍使用线程的一些基本点,要得到更多的信息和其它的例子你可以查看其它的文档。 要注意的问题:由于操作系统要跟踪和确定线程的进度,因此线程的系统开销会比较大,因此你不应该在应用的任何地方都创建新的线程。由于必须为每个线程分配内存,太多的线程将会令整个系统的性能受到影响。此外,线程还会带来一些VB的开发者没有遇到过的问题,例如同步访问和同享资源。因此,你必须经过仔细考虑才加入多线程的支持。 在下面的部分,我们将会讨论使用线程和线程池。 使用线程 用来创建和维护线程的基类是Thread。它拥有Start, Stop, Resume, Abort, Suspend和Join (wait for)等方法让你操纵线程,还可以通过如Sleep, IsAlive, IsBackground, Priority, ApartmentState和ThreadState等方法查询和设置线程状态。 注意:要记住大部分的Thread成员都是虚成员,因此只可以由一个特定Thread类的实例访问。要维护一个特定的线程,你可以创建一个新的Thread类实例,或者通过CurrentThread属性得到当前Thread的一个引用。例外的是Sleep方法,它可让当前的线程挂起指定的毫秒数。 为了启动一个新的线程,你必须指定一个入口以便开始执行该线程。要求是该方法(可以是一个对象上的方法或者是一个模块中的方法)没有参数,并且要定义为一个Sub过程。在同一个对象内,以一个独立的线程来执行一个方法也是可能的。 例如,看以下的代码段。在这个例子中,Instructors类的GetPhotos方法在一个独立的线程上执行。这个方法(没有显示)向数据库查询全部的教师图象,并且将每幅图象以文件的方式保存下来,在这里,数据库访问和文件访问在一个分开的线程上执行。
在上面的代码中,你可以看到启动一个线程包括实例化一个ThreadStart委派,并且通过AddressOf操作符将入口地址传送给它。该委派然后就会传送给Thread类的构造器。在线程真正开始执行前,优先权被设置为BelowNormal,这样主线程将可更迅速地响应请求。虽然Win32 API支持30个优先权级别,不过在ThreadPriority枚举中,你只有4个其它的优先级可以设置 (AboveNormal, Highest, Lowest和Normal) 。 注意:ThreadPriority枚举对象和Win32 API的32个级别是有对应关系的,实际上,最低的优先权(Lowest)对应6,而最高的(Highest)为10。 然后代码就设置了线程的Name属性,开始看来有点奇怪,因为一个线程或者是它的名字应该永远都不会在用户的界面上出现,这个名字其实是出现在调试器中,也可用作日志的用途。接着就是执行Start方法来真正开始执行。 技巧 有时得到线程的一个数字标识来作日志和汇报目的是非常方便的。你可以调用CurrentThread属性或者Thread类上的GetHashCode方法。这将会返回一个数字,你可以用它来在应用中作记录或者事件日志。 启动线程后,代码就进入一个循环等待,检查ThreadState属性的值是否为Unstarted(这是线程的初始状态),直到线程启动。ThreadState枚举还包括有9个其它的状态,由Running到Stopped。要注意的是调用Thread类的共享方法Sleep将会令该线程休眠指定的毫秒数,在这里是主线程而不是tPhoto表示的线程。最后,在执行一些其它的工作后,主线程通过检查IsAlive属性来看tPhoto是否仍然运行。如果是的话,就会在调用Join方法前,向用户展示相应的信息。该方法通过阻塞来同步两个线程(挂起当前执行的线程)。直到调用该方法的线程停下来为止。 技巧 与上面提到的Priority属性无关,CLR会区分前台运行的线程和后台运行的线程。如果一个线程被标识为后台线程,CLR在AppDomain关闭的时候并不会等待它完成。如前面讨论的那样,在使用异步文件IO时,运行时创建的线程都是后台的线程,因此你要确保代码的主线程不会在I/O完成前退出。默认的情况下,上面创建的线程被标识为前台,同时它们的IsBackgropu属性被设置为False。 虽然在代码中并没有展示,不过在线程执行的时候它可以通过Suspend方法挂起,然后通过Resume继续执行。此外线程还可以通过使用Abort方法退出,这时将会在线程内抛出一个例外。 对资源的同步访问 一般来说,你希望在独立的线程中运行各种处理,而不需要访问共享的资源。建议的方法如下: 1、封装要运行的处理到一个类中,并且留一个入口来启动该处理,例如Public Sub Start()并且初始化变量来处理状态 2、创建一个独立的类实例 3、设置处理需要的实例变量 4、在一个独立的线程中调用入口 5、不要引用该类的实例变量 只要使用这个方法,全部的实例变量对于线程都是“私有的”,因此可以无需担心同步的问题。 不过,有时这种情况是不能避免的,例如数据库连接或者文件处理。为了确保某线程在访问这些资源时其它线程处于等待状态,你可以使用Monitor类和它的相关方法,包括有Enter, Exit, TryEnter, Wait, Pulse和PulseAll。 例如,假定上面代码中的Instructors类包含了一个类级的SqlConnection对象,该对象被所有的方法共享,并且用来连接数据库。这就是一个资源共享的例子,它被类中的所有方法所共享。 注意: 虽然使用连接池可提供一个更富扩展性的方案,不过这个例子满足我们当前的需要,它让所有的数据库访问通过一个单一的数据库连接进行。这种方式对于需要一个持久的数据库连接的应用是适合的,不过不适合用在分布式的应用。 这个例子中,我们假设在调用GetPhotos后,客户端继续调用一个使用该连接对象的方法。由于连接可能正在被GetPhotos使用,如果SqlConnection正在忙于处理其它的结果,该方法将会抛出一个例外。 要避免这种情形,GetPhotos方法可以使用Monitor的共享方法在其代码中创建critical section。简单说来,critical section就是调用Monitor类的Enter和Exit方法所构成的代码块,通过它,访问的同步是基于传送至Enter方法的对象。也就是说,如果GetPhotos方法要独立地使用SqlConnection,它必须要创建一个critical section,在该section的开始部分,通过传送SqlConnection到Monitor的Enter方法中,并且在结束的时候调用Exit方法。被传送的对象可以是任何继承System.Object的对象。 如果该对象正在被其它的线程使用,Enter方法将会阻塞直到对象被释放。你也可以调用TryEnter方法,该方法不会阻塞,它只会返回一个布尔值指示该对象是否在使用中。一旦进入critical section,GetPhotos方法可以使用SqlConnection执行一个存储过程,并且将结果写出来。在关闭结果集SqlDataReader后,就会调用Monitor类的Pulse方法,以通知等待队列中的下个线程该对象已经释放了。然后就会将线程移动到ready队列中,以便准备开始处理。PulseAll方法则通知全部的等待线程该对象准备被释放。最后就会调用Exit,从而释放monitor并且结束critical section部分。这部分代码的框架见下。 同步的资源。以下的例子展示了GetPhotos方法将使用Monitor类来确保两个线程不会同时使用SqlConnection对象
很明显,critical sections仅应该在需要的时候创建,因为它们会阻塞线程,从而会影响整体的吞吐量。 要同步线程间共享的实例变量,有一个很简单的技巧,这就是使用Interlocket类。该类包含有共享的Increment和Decrement方法,可以将修改变量和检查结果的操作结合成一个单一的操作。这样做是必需的,因为一个线程可以修改变量的值,在接着检查结果之前,它的运行时间就结束了。在该线程再次运行时,变量的值就有可能被其它的线程修改了。 例如下面的代码增加Instructors类的mPhotosProcessed实例级变量的值:
Interlocked类还支持Exchange和CompareExchange的方法,它们的作用分别是设置变量为特定的值,或者在该变量等于某个值时才这样做。 使用线程本地存储 虽然在理想的情况下你的线程将使用私有的实例变量,不过在许多时候,当你的线程运行一个对象的方法,而该方法可能被其它的线程共享时,这样你的线程可能需要存储和接收它自己的真正私有数据。例如,当一个线程池中的线程监视一个MSMQ队列,并且需要取得队列中数据,然后存储下来作以后处理用时,就会出现这种情形。 在Windows操作系统中,每个线程都拥有自己的线程本地存储(thread local storage,TLS),以用来跟踪状态信息。方便的是,Thread类拥有一套方法,可方便地创建和维护TLS中的内存区域(该区域称为data slots)。 值得一提的是,Thread类拥有一个共享的AllocateNamedDataSlot方法,可以使用指定的名字为AppDomain中的所有线程创建一个新的data slot。该slot可以在随后通过使用SetData和GetData方法设置和读取。例如,假定有一个称为WorkerClass类执行一些处理活动,并且我们想创建一定数量的线程来执行该工作。以下的代码段为所有的线程创建了一个称为“ID”的data slot,然后通过objWorker实例的StartWork方法,执行相应数量的线程:
要注意的是由于所有的新线程将会共享objWorker上的实例变量,因此StartWorker方法和任何通过Start调用的方法将需要使用同步以防止对这些变量的同时访问。不过,如果每个线程需要它们自己的数据在方法间共享,它们可以将一个拷贝放到TLS的“ID”slot中,如下所示。
当NextProcess方法被调用时,数据可以再次通过使用Getdata由slot中读取。 再次提醒一下,上面提到的设计模式在需要时才使用。只有在你的设计是很复杂而且需要从多个线程中访问同样的对象时,你才需要使用TLS。 使用线程工具 你可以通过Thread类来创建和管理自己的线程,System.Threading命名空间还提供了一个简单的方式来使用线程,这些线程由CLR分配的一个池得到。这样做是可能的,因为CLR自动在每个进程创建和管理一个线程池,这样做是为了用来处理异步的操作,例如I/O和事件。在池中,一个线程被分配Highest优先权利,它是用来监视队列中其它线程的状态的。使用ThreadPool类,你的代码可接进这个池,并且可以更有效地使用这个在运行时已经配置的体系。实际上,ThreadPool类可允许你提交工作项目(例如要执行的方法)到池中,它们会被随后的工作线程执行。 如前所述,只有在应用需要的时候才使用线程,并且要经过仔细的分析。例如,使用线程池的一个很好的情形是,一个用来监听由一个或者多个信息队列中进入的新信息的Windows服务应用。虽然System.Messaging命名空间支持异步的操作,但是创建一个线程池可允许你控制一些特别的方面,例如有多少线程在处理信息和线程的生存时间。 下面例子是一个经过简化的类,它使用ThreadPool类,用来监听一个MSMQ队列。 列表11.9 QueueListener类,该类使用ThreadPool类来监听一个MSMQ队列
要注意该列表包含有两个类:EventState,它是一个protected的子类,还有QueueListener。EventState包含有一个称为ResetEvent的字段,它的类型是ManualResetEvent,用来确保所有的工作线程可以无中断地完成它的工作,这是通过使用ResetEvent字段得到其状态。该类还包含有一个ThreadName字段,用来设置与该类相关的线程的名字,以便作调试用。 技巧 下图展示了VS.NET在调试模式时运行这个多线程监听应用的情形。要注意的是下拉的窗口显示了每个线程的名字。选择线程后,代码窗口就会移动到该线程正在执行的地方。要注意的是一个线程的名字只可以设置一次。因此,当工作项目使用同一个线程时,如果设置了Name属性,代码就会抛出一个例外。
QueueListener是真正由多个线程上取回MSMQ队列的类,它还包含有一个构造器,该构造器接收机器名字,并且以队列的形式将名字送至监视器。public Listen方法由队列中接收信息,而public Monitor方法初始化处理并且创建线程池。private ProcessMsg方法则是用来处理接收信息的。最后是public Finish方法,它可以接收一个超时参数,可让QueueListener类使用的线程在一个指定的时间内完成工作。 首先,要注意到Listen方法接收一个状态对象作为参数。该对象将包含有一个EventState的实例,该实例将被Listen用来检查该方法是否正在处理信息还是已经完成处理。通过这样做可确保Finish方法阻塞直到所有的线程完成它们当前的处理。在设置ThreadPriority和Name,以及接收EventState后,你将会注意到该方法仅包含有一个放在Try块中的While循环。该循环反复调用MessageQueue类的Receive方法,方法将返回在指定的超时时间内的第一个得到的信息。如果没有信息,在返回前,就会使用一个TimeSpan对象来通知Receive方法阻塞一秒。如果没有信息接收,将会抛出一个MessageQueueException对象。要注意的是如果有信息到达,该方法将会继续运行并调用Reset方法,Reset方法属于EventState对象内的ResetEvent字段。无论是哪种情况,Finally块都会调用ResetEvent字段的Set方法,表示线程已经完成这个循环处理。 前面已经提及,EventState的ResetEvent字段包含有一个ManualResetEvent的实例,该实例是一个事件对象,它的signaled和non-signaled状态都是可以通过Reset和Set方法手工修改的。在调用Reset方法时,状态就会变为non-signaled,它表明该线程正忙。当状态通过Set事件设置为signaled时,则表明该线程已经完成处理,因此可以安全地破坏。 其中有意思的部分是由Monitor方法完成的。在这个方法中,会创建一个类级别的ManualResetEvent数组,该数组的大小和池将要服务的工作项目的数目一样。 注意 要记住的是,在这篇文章中,工作项目和线程并不是一件事情。工作项目是由线程完成的,但是在应用中,工作项目的数目可以比线程更多。当前runtime支持的线程池大小是30,因此如果提交超过30个工作项目到池中将自动令一些工作项目必须等待其它的工作项目完成。在这个例子中,如果有超过30个工作项目的话,那将永远不会运行,因为每个工作项目调用Listen,它将一直控制线程,直到Finish方法被调用。因此,为了确保runtime还有其它的线程作其它用途,WorkItems(工作项目)不要超过15个。 工作项目的数目可以通过QueueListener类的WorkItems属性设置,它的默认值是7。接着就会通过一个For循环来创建每个ManualResetEvent对象,并且将它们和一个新的EventState相联系。然后结果对象objState就会作为第二个参数传送到ThreadPool类的共享方法QueueUserWorkItem中。就象它的名字隐含的意思一样,该方法令工作项目以队列的形式送给runtime管理的线程池,以等待下一个工作线程完成它。第一个参数是用来指定在工作项目开始执行的时候需要回调的方法,在这里是Listen。通过传送EventState作为第二个参数,Listen方法可以接收该对象,并且如我们前面讨论的一样,使用里面的状态信息。在这里,状态包含有用来调试的线程名字和一个用来同步线程的ManualResetEvent对象。在循环完成后,指定数目的工作项目将会以队列的形式被线程池执行。此时线程将会不断地检查指定的队列以得到新信息。 在客户端最终调用Finish方法来完成执行时,首先会设置其private mFinished变量为True。Listen方法在每次循环时都会检查该变量,如果设置为True时,就会退出循环,释放线程并且返回到池中。接着Finish方法将会使用WaitHandle类的共享WaitAll方法阻塞,直到mEvs数组中的所有ManualReset事件对象都被设置为signaled状态(True)。如果超时值被传送给该方法时,就会使用可选的第二个参数,在反阻塞现有的线程前,会等待指定的时间。使用这种方法,就可以确保Finish方法将一直阻塞,直到每个工作线程已经完成Listen方法中的当前循环。值得一提的是,线程确实被返回到池中而没有被破坏。这样在下一次调用Monitor时,将会重新使用现有的线程,不会重新创建它们而带来系统开销。 对于使用QueueListener的客户来说,其实现如下面的代码所示:
在初始化一个新的对象,并且传送它监听的机器名和队列,工作项目的数字就被设置好,并且调用Monitor方法。其后,客户端可以调用Finish方法来清除工作线程(可带超时参数)。 这个例子向你解释了如何使用ThreadPool类,不过它当然不是创建线程池以执行监视消息队列的唯一方法。例如,可以很容易地修改QueueListener来创建和跟踪类中的Thread对象数组,以实现线程池。接着Finish方法在设置mFinished标志后,就可以执行一个循环来监视IsAlive属性,以决定线程池何时耗尽,这时就无需使用ManualResetEvent对象了。此外,上面提到TLS技巧可以用来传送状态信息给线程。这个体系可让你更好地控制线程,实际上,当runtinme管理的线程已经很繁重或者需要更多的工作项目时,这个方法将是更好的。 |