无论您是为具有单个处理器的计算机还是为具有多个处理器的计算机进行开发,您都希望应用程序为用户提供最好的响应性能,即使应用程序当前正在完成其他工作。要使应用程序能够快速响应用户操作,同时在用户事件之间或者甚至在用户事件期间利用处理器,最强大的方式之一是使用多个执行线程。
线程与线程处理
操作系统使用进程将它们正在执行的不同应用程序分开。线程是操作系统分配处理器时间的基本单元,并且该进程中可以有多个线程同时执行代码。每个线程都维护异常处理程序、调度优先级和一组系统用于在调度该线程前保存线程上下文的结构。线程上下文包括为使线程在线程的宿主进程地址空间中无缝地继续执行所需的所有信息,包括线程的 CPU 寄存器组和堆栈。
.NET 框架将操作系统进程进一步细分为由 System.AppDomain 表示的称为应用程序域的轻量托管子进程。一个或多个托管线程(由 System.Threading.Thread 表示)可以在同一个非托管进程中的一个或任意数目的应用程序域中运行。虽然每个应用程序域都是用单个线程启动的,但该应用程序域中的代码可以创建附加应用程序域和附加线程。其结果是托管线程可以在同一个非托管进程中的应用程序域之间自由移动;您可能只使一个线程在若干应用程序域之间移动。
支持抢先多任务处理的操作系统可以创建多个进程中的多个线程同时执行的效果。它通过以下方式实现这一点:在需要处理器时间的线程之间分割可用处理器时间,并轮流为每个线程分配处理器时间片。当前执行的线程在其时间片结束时被挂起,而另一个线程继续运行。当系统从一个线程切换到另一个线程时,它将保存被抢先的线程的线程上下文,并重新加载线程队列中下一个线程的已保存线程上下文。
时间片的长度取决于操作系统和处理器。由于每个时间片都很小,因此即使只有一个处理器,多个线程看起来似乎是在同时执行。这实际上就是多处理器系统中发生的情形,在此类系统中,可执行线程分布在多个可用处理器中。
何时使用多个线程
需要用户交互的软件必须尽可能快地对用户的活动作出反应,以便提供丰富多彩的用户体验。但同时它必须执行必要的计算以便尽可能快地将数据呈现给用户。如果应用程序仅使用一个执行线程,则可以结合使用异步编程与 .NET 远程处理或使用 ASP.NET 创建的 XML Web services,以便在使用自己计算机的处理时间以外再使用其他计算机的处理时间,从而提高对用户的响应速度并减少应用程序的数据处理时间。如果您正在进行大量的输入/输出工作,则还可以使用 I/O 完成端口来提高应用程序的响应速度。
多个线程的优点
无论如何,要提高对用户的响应速度并且处理所需数据以便几乎同时完成工作,使用多个线程是一种最为强大的技术。在具有一个处理器的计算机上,多个线程可以通过利用用户事件之间很小的时间段在后台处理数据来达到这种效果。例如,在另一个线程正在重新计算同一应用程序中的电子表格的其他部分时,用户可以编辑该电子表格。
无需修改,同一个应用程序在具有多个处理器的计算机上运行时将极大地满足用户的需要。单个应用程序域可以使用多个线程来完成以下任务:
通过网络与 Web 服务器和数据库进行通讯。
执行占用大量时间的操作。
区分具有不同优先级的任务。例如,高优先级线程管理时间关键的任务,低优先级线程执行其他任务。
使用户界面可以在将时间分配给后台任务时仍能快速作出响应。
多个线程的缺点
建议您使用尽可能少的线程,这样可以使操作系统资源的使用率最低,并可提高性能。线程处理还具有在设计应用程序时要考虑的资源要求和潜在冲突。这些资源要求如下所述:
系统将为进程、AppDomain 对象和线程所需的上下文信息使用内存。因此,可以创建的进程、AppDomain 对象和线程的数目会受到可用内存的限制。
跟踪大量的线程将占用大量的处理器时间。如果线程过多,则其中大多数线程都不会产生明显的进度。如果大多数当前线程处于一个进程中,则其他进程中的线程的调度频率就会很低。
使用许多线程控制代码执行非常复杂,并可能产生许多错误。
销毁线程需要了解可能发生的问题并对那些问题进行处理。
提供对资源的共享访问会造成冲突。为了避免冲突,必须对共享资源的访问进行同步或控制。未能正确地使访问同步(在相同或不同的应用程序域中)会导致诸如死锁(两个线程都停止响应,并且都在等待对方完成)和争用条件(由于意外地出现对两个事件的执行时间的临界依赖性而发生反常的结果)等问题。系统提供了可用于协调多个线程之间的资源共享的同步对象。减少线程的数目使同步资源更为容易。
需要同步的资源包括:
系统资源(如通讯端口)。
多个进程所共享的资源(如文件句柄)。
由多个线程访问的单个应用程序域的资源(如全局、静态和实例字段)。
线程处理与应用程序设计
一般情况下,要为不会阻塞其他线程的相对较短的任务处理多个线程并且不需要对这些任务执行任何特定调度时,使用 ThreadPool 类是一种最简单的方式。但是,有多个理由创建您自己的线程:
如果您需要使一个任务具有特定的优先级。
如果您具有可能会长时间运行(并因此阻塞其他任务)的任务。
如果您需要将线程放置到单线程单元中(所有 ThreadPool 线程均处于多线程单元中)。
如果您需要与该线程关联的稳定标识。例如,您应使用一个专用线程来中止该线程、将其挂起或按名称发现它。
托管线程处理支持
公共语言基础结构提供三种策略来同步对实例和静态方法以及实例字段的访问:
同步上下文。可以使用 SynchronizationAttribute 为 ContextBoundObject 对象启用简单的自动同步。
同步代码区域。可以使用 Monitor 类或该类的编译器支持来仅同步需要该类的代码块。
手动同步。可以使用各种同步对象来创建自己的同步机制。
公共语言运行库提供一个线程模型,在该模型中,类分为许多类别,这些类别可以根据要求以各种不同的方式进行同步。下表显示了为具有给定同步类别的字段和方法提供的同步支持。
类别
全局字段
静态字段
静态方法
实例字段
实例方法
特定代码块
无同步
否
否
否
否
否
否
同步上下文
否
否
否
是
是
否
同步代码区域
否
否
仅当标记时
否
仅当标记时
仅当标记时
手动同步
手动
手动
手动
手动
手动
手动
无同步
这对于对象是默认情况。任何线程都可以随时访问任何方法或字段。仅应有一个线程访问这些对象。
同步上下文
可以使用任何 ContextBoundObject 上的 SynchronizationAttribute 来同步所有实例方法和字段。相同上下文域中的所有对象共享相同的锁。允许多个线程访问方法和字段,但在任一时刻只允许一个线程访问。
同步代码区域
可以使用 Monitor 类或编译器关键字来同步代码块、实例方法和静态方法。不支持同步静态字段。
Visual Basic 和 C# 都支持使用特定的语言关键字来标记代码块。最终生成的代码将尝试在代码执行时获取锁。如果已经获取锁,则正在执行的代码将一直等待,直到锁可用为止。当代码退出同步代码块时,锁被释放。还可以用 MethodImplAttribute 修饰方法并传递 MethodImplOptions.Synchronized,其效果与使用 Monitor 或一个适用于 Monitor 代码块的编译器关键字相同。
Thread.Interrupt 可用于使线程跳出阻塞操作(如等待访问同步代码区域)。Thread.Interrupt 还用于使线程跳出 Thread.Sleep 等操作。
Monitor 类支持以下代码块同步:
同步实例方法。在实例方法上,当前对象(在 C# 中为 this 关键字,在 Visual Basic 中为 Me 关键字)用于同步。
同步静态方法。在静态方法上,类用于同步。
编译器支持
Visual Basic 和 C# 都支持使用 Monitor.Enter 和 Monitor.Exit 来锁定对象的语言关键字。Visual Basic 支持 SyncLock 语句;C# 支持 Lock 语句。
在这两种情况下,如果在代码块中引发异常,则由 lock 或 SyncLock 获得的锁将被自动释放。C# 和 Visual Basic 编译器在发出 try/finally 块时,在 try 的起始处使用 Monitor.Enter,在 finally 块中使用 Monitor.Exit。如果在 lock 或 SyncLock 块内部引发异常,则 finally 处理程序将运行,从而使您可以执行任何清除工作。
手动同步
可以使用同步类 Interlocked、Monitor、ReaderWriterLock、ManualResetEvent 和 AutoResetEvent 来获取锁和释放锁以保护全局、静态和实例字段以及全局、静态和实例方法。
Thread.Suspend、垃圾回收和安全点
当在线程上调用 Thread.Suspend 时,系统并不立即执行该操作。相反,它注意到已请求线程挂起,然后等待至该线程到达某个安全点时才挂起该线程。线程的安全点是进行垃圾回收的安全点。
到达垃圾回收安全点的方式有两种:
如果某些代码引起垃圾回收,并且公共语言运行库可以成功修改堆栈上的返回地址以便它可以返回到运行库而不是其调用方,则运行库将在此刻将该线程挂起。
如果不包含任何调用的循环由于某种原因产生垃圾回收(如果它包含调用,则运行库将能够修改返回地址并可以控制该线程以便在安全点进行垃圾回收),则常规实时 (JIT) 编译将检测到这种情况并使包含该循环的整个方法完全可中断。方法中的所有指令都可以安全进行垃圾回收,因为它将为每条指令创建垃圾回收表。
为执行垃圾回收,所有线程都必须挂起,当然,执行回收的线程除外。每个线程在可以挂起之前都必须置于安全点。将线程置于安全点的原因并不限于这些。例如,运行库可能由于中止线程等原因来得到对线程的控制。
Microsoft Windows 中的托管和非托管线程处理
所有线程管理都是通过 Thread 类来实现的,包括由公共语言运行库创建的线程以及在运行库以外创建并进入托管环境以执行代码的线程。运行库监视其进程中曾经在托管执行环境中执行过代码的所有线程。它不跟踪任何其他线程。线程可以通过 interop(原因是运行库将托管对象作为 COM 对象向非托管领域公开)、COM DllGetClassObject() 函数和平台调用进入托管执行环境。
当非托管线程进入运行库时(如通过 COM 可调用包装),系统将检查该线程的线程本地存储区以查找内部托管 Thread 对象。若找到一个对象,运行库就注意到该线程。但如果一个也找不到,则运行库将生成新的 Thread 对象并将其安装在该线程的线程本地存储区中。
在托管线程处理中,Thread.GetHashCode 是稳定的托管线程标识。在线程的生存期内,它不会与来自其他任何线程的值相冲突,不管您是从哪个应用程序域获取该值。
注意 因为非托管宿主可以控制托管和非托管线程之间的关系,所以操作系统 ThreadId 与托管线程之间没有固定的关系。具体地说,一个复杂的宿主可以使用纤维 API 针对同一操作系统线程调度多个托管线程或在不同的操作系统线程之间移动托管线程。
从 Win32 线程处理到托管线程处理的映射
下表将 Win32 线程处理元素映射为其近似的运行库等效元素。请注意,该映射不表示具有相同的功能。例如,TerminateThread 不执行 finally 子句或释放资源,并且不能被禁止。但 Thread.Abort 可以执行所有回滚代码,回收所有资源,并可以使用 ResetAbort 来拒绝。请确保在对功能进行假设之前仔细阅读该文档。
在 Win32 中
在公共语言运行库中
CreateThread
Thread 和 ThreadStart 的组合
TerminateThread
Thread.Abort
SuspendThread
Thread.Suspend
ResumeThread
Thread.Resume
Sleep
Thread.Sleep
线程句柄上的 WaitForSingleObject
Thread.Join
ExitThread
无等效项
GetCurrentThread
Thread.CurrentThread
SetThreadPriority
Thread.Priority
无等效项
Thread.Name
无等效项
Thread.IsBackground
接近 CoInitializeEx (OLE32.DLL)
Thread.ApartmentState
托管线程和 COM 单元
可以标记一个托管线程以指示它将承载一个单线程或多线程单元。Thread.ApartmentState 属性用于返回和分配线程的单元状态。如果该属性尚未设置,则该属性将返回 ApartmentState.Unknown。
当线程处于 ThreadState.Unstarted 或 ThreadState.Running 状态时可以设置该属性;但一个线程只能设置一次。两个有效的属性状态是单线程单元 (STA) 或多线程单元 (MTA)。每个状态都对应用程序的托管部分影响甚微。终结程序线程和由 ThreadPool 控制的所有线程都是 MTA。
向 COM 公开的托管对象的行为就如同它们聚合了自由线程封送拆收器一样。换句话说,它们可以通过自由线程的方式从任何 COM 单元中调用。唯一不显示这种自由线程行为的托管对象是那些从 ServicedComponent 派生的对象。
在托管领域中,不支持 SynchronizationAttribute,除非使用上下文和上下文绑定的托管实例。如果使用的是 EnterpriseServices,则对象必须从 ServicedComponent(它本身是从 ContextBoundObject 派生的)派生。
当托管代码调用至 COM 对象时,它总是遵循 COM 规则。换句话说,它遵循 OLE32 的规定,通过 COM 单元代理和 COM+ 1.0 上下文包装来调用。
阻塞问题
如果线程对已阻塞非托管代码中的线程的操作系统进行非托管调用,则运行库将不会为 Thread.Interrupt 或 Thread.Abort 控制该调用。在使用 Thread.Abort 的情况下,运行库为 Abort 标记该线程,并在它重新进入托管代码时对其进行控制。使用托管阻塞而不使用非托管阻塞更为可取。WaitHandle.WaitOne、WaitAny 以及 WaitAll、Monitor.Enter、Monitor.Block、Thread.Join、GC.WaitForPendingFinalizers 等都会响应 Thread.Interrupt 和 Thread.Abort。同样,如果线程处于单线程单元中,则当线程被阻塞时,所有这些托管阻塞操作都会正确地发送单元中的消息。
线程活动状态
Thread.ThreadState 属性提供指示线程当前状态的位掩码。线程总是处于 ThreadState 枚举中至少一个可能状态中,并且可以同时处于多个状态中。
在创建托管线程时,该线程处于 Unstarted 状态中。线程将始终保持在 Unstarted 状态中,直到通过调用 Thread.Start 将其移动到已启动状态。进入托管环境的非托管线程已经处于已启动状态中。一旦处于已启动状态中,就可以执行许多操作来使线程更改状态。下表列出使状态发生更改的操作以及相应的新状态。
操作
所得到的新状态
另一个线程调用 Thread.Start。
不变
线程响应 Thread.Start 并开始运行。
Running
线程调用 Thread.Sleep。
WaitSleepJoin
线程对另一个对象调用 Monitor.Wait。
WaitSleepJoin
线程对另一个线程调用 Thread.Join。
WaitSleepJoin
另一个线程调用 Thread.Suspend。
SuspendRequested
线程响应 Thread.Suspend 请求。
Suspended
另一个线程调用 Thread.Resume。
Running
另一个线程调用 Thread.Interrupt。
Running
另一个线程调用 Thread.Abort。
AbortRequested
线程响应 Thread.Abort。
Aborted
由于 Running 状态的值为 0,因此不可能执行位测试来发现该状态。但可以使用以下测试(以伪代码表示)。
if ((state & (Unstarted | Stopped)) == 0) // implies Running
在任何给定时间,线程通常处于多个状态中。例如,如果某个线程在 Wait 调用阻塞并且另一个线程对该同一线程调用 Abort,则该线程将同时处于 WaitSleepJoin 和 AbortRequested 状态中。在这种情况下,一旦该线程从对 Wait 的调用返回或被中断,则它将收到 ThreadAbortException。
一旦线程由于调用 Thread.Start 而离开 Unstarted 状态,则它将永远无法返回到 Unstarted 状态。同样,线程也永远无法离开 Stopped 状态。
线程级别
托管线程或者是后台线程,或者是前台线程。后台线程不会使托管执行环境处于活动状态,除此之外,后台线程与前台线程是一样的。一旦所有前台线程在托管进程(其中 .exe 文件是托管程序集)中被停止,系统将停止所有后台线程并关闭。通过设置 Thread.IsBackground 属性,可以将一个线程指定为后台线程或前台线程。例如,通过将 Thread.IsBackground 设置为 true,就可以将线程指定为后台线程。同样,通过将 IsBackground 设置为 false,就可以将线程指定为前台线程。从非托管代码进入托管执行环境的所有线程都被标记为后台线程。通过创建并启动新的 Thread 对象而生成的所有线程都是前台线程。如果要创建希望用来侦听某些活动(如套接字连接)的前台线程,则应将 Thread.IsBackground 设置为 true,以便进程可以终止。
线程本地存储区和线程相关的静态字段
可以使用托管线程本地存储区 (TLS) 和线程相关的静态字段来存储某一线程和应用程序域所独有的数据。如果您可以在编译时预料到您的确切需要,请使用线程相关的静态字段。如果只能在运行时发现您的实际需要,请使用托管线程本地存储区。
在非托管 C++ 中,可以使用 TlsAlloc 来动态分配槽,使用 __declspec(thread) 来声明变量应相对于线程进行分配。线程本地存储区和线程相关的静态字段提供了此行为的托管版本。
线程本地存储区
托管线程本地存储区提供了某一线程和应用程序域组合所独有的动态数据槽。数据槽包括两种类型,即命名槽和未命名槽。因为可以使用助记标识符,所以命名槽可能是很方便的。但其他组件可以通过对其自身的线程相关存储区使用相同的名称而有意或无意地修改这些标识符。但是,如果不将未命名的数据槽公开给其他代码,其他任何组件就无法使用该数据槽。
若要使用托管 TLS,只需使用 Thread.AllocateNamedDataSlot 或 Thread.AllocateDataSlot 来创建数据槽,并使用适当的方法设置或检索槽中的信息。
线程相关的静态字段如果您知道某类型的字段应总是某个线程和应用程序域组合所独有的,则使用 ThreadStaticAttribute 修饰静态字段。需要注意的是,任何类构造函数代码都将在访问该字段的第一个上下文中的第一个线程上运行。在所有其他线程或上下文中,该字段将被初始化为 null(在 Visual Basic 中为 Nothing)。因此,您不应依赖于类构造函数来初始化线程相关的静态字段。而应总是假定线程相关的静态字段被初始化为 null (Nothing)。