如果想让应用程序受益于新处理器持续的指数级吞吐量优点,那么需要编写很好的并发(通常是多线程的)应用程序。
—— Herb Sutter
线程化,或者更多的说法是并发性和并行性,提供一个同时执行多个单元代码的方法。Windows平台经历了从仅提供顺序执行(例如在MS-DOS中,以终止和保持驻留程序形式的准多任务[TSRs]),到在16位Windows中的合作多任务,到32位Windows中的真正多线程的发展。线程是OS中最基本的单位,它允许划分逻辑上独立的任务,随后OS在最大化物理资源利用的方式下,将这些任务按预定顺序执行。在实际的并行机上—— 例如,多处理器、多核和/或超线程(HT)体系结构—— 这会使代码的执行实现真正的并行。在其他机器上,OS调度算法模拟并行执行,其方法是在许可每个可运行线程运行一个有限的时间片后,经常在线程间切换。在最基本的层次上,这使多个进程可以立刻运行;这是因为每个进程至少使用一个线程。再深入研究,单个的进程可以将工作划分为多个线程执行。
在两个一般类别的情况下,这种行为是令人期待的:响应灵敏度和性能。程序经常会阻塞执行,以执行I/O操作,例如读磁盘,与网络终端通信等。但是,UI的运行是通过处理在每个UI线程消息队列中排队的消息。一些类型的阻塞造成UI消息的同时运行,但是其他类型不会这样。这会造成队列中消息(例如,WM_CLOSE,WM_PAINT等)堵塞,直至I/O操作完成。对于很长的操作,这会导致无响应的UI。(如果曾经看到过程序的标题栏改为“…(没有响应)”,就会知道这句话的意思)。第二个原因性能可以用于利用真正的硬件。某些算法可以有助于并行执行。与顺序执行相比,将这种操作分为多个线程可以在并行运行时提高速度。
为了使这种情况成为可能,.NET Framework提供了多个异步编程基本方法和技术。将关注3种基本层次的孤立性和并发执行。从粗粒度的单元到细粒度的单元,它们是进程、应用程序域(以后称为AppDomain)和线程。记住,线程是并发的唯一实际单元;其他是孤立的形式,并且可用于将相关线程按逻辑分组。图10-1说明了这3个执行单元之间的关系。

图10-1 进程、AppDomain和线程间的孤立性
.NET Framework中的进程与Windows中的进程一一 对应。进程的主要目的是管理每个程序的资源;这包括了进程中运行的所有线程之间的一个共享虚拟地址空间、一个HANDLE表、一个重载的DLL共享集(映射到同样的地址空间)、以及存储在进程环境块(PEB)中的各种其他进程范围的数据。一个进程出了问题,正常情况下不会影响其他进程,因为它是孤立的。然而,由于进程间通信和机器范围的资源共享—— 如文件、内存映射I/O和指定的内核对象—— 一个进程通常要和其他进程交互。
一个托管进程(该进程已经重载了CLR)能够包括任何数量的AppDomain。最简单的进程只包括一个AppDomain。为了达到可靠和安全的目的,AppDomain是同一个进程中的逻辑组件之间的细粒度孤立级别。它们是进程级孤立的良好可选方法,因为它们的创建、管理和切换执行的开销非常小,并且在进程中AppDomain之间的资源共享级别也是一个因素。进程中的AppDomain不是完全孤立的。然而,它们一般重载自己的程序集并且拥有它们自己的静态变量副本,例如,从一个AppDomain泄漏的资源可以影响另一个AppDomain,还有可能造成HANDLE窃取安全漏洞,因为多个AppDomain共享一个每个进程的HANDLE表。AppDomain可以单独关闭,同时使包括该AppDomain的进程保持活动状态。
如前面所提到的,线程是Windows中的实际执行单元。它也有自己的状态集。也许最重要的是,每个线程有它自己的栈(通常是1MB大小),这个栈用来创建激活框架以及执行方法。线程也有自己的存储线程本地状态的线程环境块(TEB),如线程本地存储(Thread Local Storage,TLS)。根据进程优先级类和线程优先级来调度每个线程。如果执行线程的时间片期满或者更高优先级的线程开始可以运行,那么这个线程的执行会被抢占;当一个线程被抢占时,OS会执行一个上下文切换,它保存退出的线程的注册状态,为进入的线程恢复这个状态。
托管线程是至少执行一次托管代码的线程,在这种情况下,它将在TLS中被永久地标记CLR数据结构。一个线程可以只属于单个进程。但是如果一个线程在多个AppDomain中有栈—— 即它当前的栈遍历了一个以上的AppDomain—— 则线程可以同时属于一个以上的AppDomain。同样重要的是认识到托管线程没有必要映射到物理的OS线程;例如,使用驻留的IHostTaskManager和IHostSyncManager API,主机可以另外选择映射到另一个基本单位,如Windows光纤。将在本章后面看到一些关于这方面的内容。
强大的API集对每个孤立的单元提供细粒度控制,允许显式地构造、启动、监视、以及停止进程、AppDomain和线程。对于简单的情况,所有这些是隐式发生的。有权对这些内容进行控制时,就要负责使用同步和锁定基本单位(例如monitor、互斥和信号量)组织和实施线程间一致的数据共享实践。没有这些结构,意外的(不需要的)行为就会发生,竞争条件就是一个例子。锁定的引入也会造成更多的错误,如死锁。这些内容将在本章后面详细介绍,它们是最难修正的故障。每个这种故障都可以造成程序状态被破坏、挂起或者未定义的行为。稍后将看到如何避免这些情况。
10.1 线程
根据所运行的托管程序的类型,默认执行的线程数量是不同的。所有托管的应用程序至少需要一个附加线程来运行GC(垃圾收集)来结束。当调试器附加在托管进程上时,会生成一个特别的调试器帮助线程。AppDomain的卸载由一个特别的线程服务。对于并发性和服务器GC,将创建很多线程来执行异步集合。当然,依据应用程序使用的ThreadPool的程度,将分配I/O Completion Port(完成端口)线程和工作者线程。重要的是,引入一个线程没有很大的开销:通常1MB的栈空间、用来存储TLS数据的一些堆开销、以及调度执行的额外可运行线程的成本。
通过使用System.Threading API,可以用许多有趣的方法管理线程的执行。每个线程由Thread类的一个实例表示。获得对一个活动线程的引用的最简单的方法是激活静态Thread.CurrentThread属性,这个属性返回当前执行的线程。也可以显式创建自己的线程或者在ThreadPool上安排时间工作,ThreadPool是一个CLR管理的线程池。下面将深入研究这些主题。首先讨论显式托管的CLR线程的生命周期。
10.1.1 线程池的排队工作
在多数情况下,实际不需要手动创建自己的Thread。系统管理工作者的线程池,可以适应任意进入的工作。每个进程只有一个进程池,因此ThreadPool类只提供静态方法。CLR通过智能启发式管理来增加或减少物理线程数量,以确保在机器硬件体系结构上的良好的可伸缩性。它也管理I/O基础架构使用的I/O完成(I/O Completion)线程集(曾经在第7章介绍过)。
如果为工作项的执行安排时间表,只需调用QueueUserWorkItem方法,传入WaitCallback委托:
ThreadPool.QueueUserWorkItem(new WaitCallback(MyWorker));
// ...
void MyCallback(object state)
{
// Do some work; this executes on a thread from the ThreadPool.
}
这个例子把一个委托插入MyCallback函数队列中,在将来的某个时刻运行这个函数。QueueUserWorkItem函数的重载允许传入一些状态;这也可以单独传给回调函数。注意,Thread类也有一个IsThreadPoolThread属性,当代码从一个线程池上下文中运行时,属性的值为true。
线程池不保证服务的质量,更不会确保队列中的所有任务在关机前执行完毕。如果进程中的所有非后台线程都停止,那么线程池中留下的工作项将没有机会运行。
1. 最少和最多的线程
ThreadPool在它的工作者和I/O完成线程中使用下限和上限。默认最少是每个逻辑硬件线程为1(处理器、内核、HT),最多是每个逻辑硬件线程为25个。如果试图调度多于25个,QueueUserWorkItem将会阻塞。如果所有25个线程由于等待一些其他的事件发生而阻塞,那么这种情况会造成死锁。通常不推荐改变默认的线程数量—— 最优情况是每个逻辑硬件线程只有一个线程是可运行的,明确地说,25个是非常多的数量—— 但是这样可以解决这种死锁问题(例如,如果在ThreadPool上没有代码生成工作)。
GetMinThreads和GetMaxThreads API保存了在当前输出参数中的数量。类似地,GetAvailableThreads可以用于确定当前线程池中有多少没有使用的线程:
int minWorkerThreads, maxWorkerThreads, availableWorkerThreads;
int minIoThreads, maxIoThreads, availableIoThreads;
ThreadPool.GetMinThreads(ref minWorkerThreads, ref minIoThreads);
ThreadPool.GetMaxThreads(ref maxWorkerThreads, ref maxIoThreads);
ThreadPool.GetAvailableThreads(
ref availableWorkerThreads, ref availableIoThreads);
可以用SetMinThreads和SetMaxThreads方法调整最少和最多的值。最好永远都不要使用SetMinThreads方法,因为在低工作量时,它限制了线程池收缩的能力;有一个额外的问题要说明,线程池的启发式管理可以防止短期内的过分增长(参见MSDN KB文件810259,了解相关细节)。同样,可以使用配置为ASP.NET应用程序设置这些值:
<configuration>
<system.web>
<processModel
minWorkerThreads="..." maxWorkerThreads="..."
minIoThreads="..." maxIoThreads="..."
... />
</system.web>
</configuration>
在多数应用程序中应该不需要为这些设置而烦恼。许多人尝试调整这些设置和/或编写自己的ThreadPool逻辑,而很少能成功。
2. 等待注册
另一个ThreadPool API—— RegisterWaitForSingleObject函数—— 允许在发信号给特定WaitHandle时调度回调函数的执行。本章稍后在自动重置和手动重置事件的上下文中讨论WaitHandle。但在较高级别中,可以发信号给Windows内核和执行对象,表明事件已经发生。这可以应用于同步原语,例如Mutex、Semaphore和EventWaitHandle,但也应用于其他对象,如Process和Thread。客户端可以用WaitHandle.WaitOne方法来等待这些事件(如,Win32的WaitForSingleObjectEx和WaitForMultipleObjectsEx)。
但是,WaitOne是同步的,并且阻塞了当前的线程。可以调度一个任务,在这种信号发生时在ThreadPool线程上执行该任务:
EventWaitHandle ewh = /*...*/;
RegisteredWaitHandle rwh = ThreadPool.RegisterWaitForSingleObject(
ewh, MyCallback, null, Timeout.Infinite, true);
在这种情况中,当ewh收到信号时注册MyCallback的执行。将state指定为null(类似于QueueUserWorkItem,这可以传递到回调函数)。超时参数可以告诉ThreadPool它只应该在放弃之前等待指定的时间;Timeout.Infinite是常数-1,表示请求时间永不超时。最后一个参数是布尔值,表示是否只等待一个信号。如果为false,每次句柄收到信号时注册都将激活。返回的对象RegisteredWaitHandle上的Unregister函数可以用于取消注册。
10.1.2 显式线程管理
如同上面所提到的,可以用Thread类型检查和管理线程。虽然它们不受显式线程管理限制—— 例如,它们可以用来检查执行的任何时刻的全局状态—— 仍然将一起讨论它们。
1. 调度状态
每个线程通常处于一个独立的定义良好的状态:Unstarted、Running、Aborted、Stopped、Suspended或WaitSleepJoin。这些状态由CLR管理,并且可以通过Thread类中的ThreadState属性获得。返回值是标记风格的枚举类型ThreadState的一个实例。一个线程的状态可以包括丰富的信息:Background、AbortRequested、StopRequested和/或SuspendRequested,这根本不需要保持调度。Background是不成组的奇数值,表示目标线程是一个后台线程(即,如果其他线程都退出的话,它也不会保持在活动状态)。另一个值提供关于状态被请求转换而且还未转换的有用的信息。下面是每个状态的描述:
● Unstarted:已经分配Thread对象,但是还没有开始执行。在大多数情况下,这意味着本地OS线程没有被分配,因此它还不能实际地执行任何代码。
● Running:目标线程的ThreadStart是runnable,或者是在活跃地执行。CLR无法区分两者之间的区别,原因将在下面讨论。
● Aborted:线程异常中止,可能是作为个体或者是AppDomain一部分的卸载进程。
● AbortRequested:请求异常中止正在运行的线程,但是线程还没有响应。这通常是因为线程正在执行一些不可异常中止的代码,该状态的含义将在下面讨论。
● Stopped:本地线程已经退出并且不再执行。
● StopRequested:CLR的线程化子系统发出异常中止线程执行的请求。它不能由用户请求初始化。
● Suspended:线程的执行被挂起。它等待继续运行。
● SuspendRequested:请求挂起执行,但线程化子系统还不能够响应。
● WaitSleepJoin:线程的执行当前在托管代码中被阻塞,等待一些条件的发生。如果线程使用的是争用的Monitor.Enter或者Monitor.Wait、Thread.Sleep或WaitHandle.WaitOne API中的任何一个,则将自动初始化该状态。
许多状态之间的转换都可以手工触发,方法是用Thread类本身执行的动作,而其他的状态转换都是由CLR的执行或者使用一定的非线程API隐式触发。线程状态的可能转换和一般的线程生命周期如图10-2所示;由线程化子系统初始化的转换用白色箭头而不是黑色箭头标出。
状态转换发生的正确方法以及状态表示的含义是本节的主题。注意CLR和OS一样,无法区别运行和就绪。这是因为OS不知道任何关于线程状态的内容。例如,它不能在上下文切换时修改状态,而且修改状态也不算是个好主意。所以,如果调用一些阻塞线程的托管代码,那么ThreadState将很可能返回Running而非WaitSleepJoin。

图10-2 线程状态转换
创建新线程
当创建一个新Thread对象时,必须向它传递一个委托,它表示一旦开始就要执行的方法。这称为线程启动函数。一个线程直到显式告诉它使用Start实例方法时才会开始执行,这个方法将在后面描述。Thread的构造函数只能给出在Unstarted状态下的新的Thread实例,可以使用该实例,并且最终可以调度它的执行。如果没有考虑调用Start,那么底层的本地OS线程将不会被创建(包括任何内存分配,例如栈空间和TEB)。
有两个类型的线程启动方式:一个简单版本(ThreadStart)和一个参数化版本(ParameterizedThreadStart),后者用单个对象作为参数。这两个委托类型的签名分别是void ThreadStart()和void ParameterizedThreadStart(object)。参数化版本适用于需要从启动函数向新线程传递信息的情况。如果用参数化版本构造线程,应该也用相对应的Start的重载方法,它接受一个对象作为参数,否则委托的目标值将为null。
给定两个方法WorkerOperation和ParameterizedWorkOperation:
void WorkerOperation()
{
// Do some work...only relies on shared state, e.g. statics.
Console.WriteLine("Simple worker");
}
void ParameterizedWorkerOperation(object obj)
{
// Do some work...relies on the state passed in, i.e. 'obj'.
Console.WriteLine("Parameterized worker: {0}", obj);
}
可以用刚讨论的两个构造函数调度两个线程的执行:
Thread thread = new Thread(WorkerOperation);
Thread paramThread = new Thread(ParameterizedWorkerOperation);
注意,通过使用C#的委托推理语法,可以看到实现相同内容的简写方法,如下所示:
Thread thread1 = new Thread(WorkerOperation);
Thread thread2 = new Thread(ParameterizedWorkerOperation);
thread1.Start();
thread2.Start("Some state...");
这段代码的运行结果是“Simple Worker”和“Parameterized Worker:some state…”都写入控制台。结果的顺序是不能确定的;它完全决定于调度线程的方法。在下一节中将对Start方法有更详细的介绍。
控制线程的栈大小
两种样式的构造函数都提供重载,这些重载获得名为maxStackSize的整数参数。如同前面提到的那样,线程在Windows下默认保留1MB的栈空间。最初只有很少的页被提交。页面保护用于在需要额外的页时捕获,这会导致栈中的页面被提交。可以用这个参数指定栈的可选大小。如果知道线程永远用不到1MB空间,这样做就可能很有用,特别是在创建许多线程时。例如,SQL Server只为托管线程分配0.5MB的栈空间。
maxStackSize的最小值为128K。如果试图提交的页面一直到栈顶的附近,那么太小的值显然会造成栈的溢出。在第3章中讨论了栈溢出和CLR对这种情况的处理。总的来说,这很快造成了失败。
这一段代码用具体大小的栈开始一个线程,并通过无跳出条件的递归来触发栈溢出:
public void Test(int howManyK)
{
Thread t = new Thread(TestOverflow, 1024*howManyK);
t.Start(1);
}
void TestOverflow(object o)
{
int i = (int)o;
Console.WriteLine("TestOverflow(" + i + ")");
TestOverflow(i + 1); // Call this recursively to find the overflow point.
}
TestOverflow用无跳出条件的递归产生栈溢出,并打印出每个调用的层次。例如,在一些快速而简单的测试中,当howManyK值为128时,将在第6249次调用时发生溢出;在第14441次调用时共有256个溢出。
开始运行
只有显式地启动线程,线程才会运行。启动一个线程是在物理上分配OS线程对象(通过Win32的CreateThread方法),将托管线程的状态设置为Running,然后使用在构造时提供的线程启动委托开始执行。Start重载接受一个parameter对象,将它显式传递给线程启动方法。
线程会持续运行,直到下面的条件之一发生时才停止:
● 指定的线程启动方法终止。这种情况的发生可能是由于线程启动例程的正常完成,或调用了Win32的ExitThread函数,或者是由于未处理的异常。在任意一种情况下,线程的最终状态都是Stopped。
● 请求异常中止线程,可能是显式地请求或作为AppDomain卸载进程的一部分请求。一旦线程子系统能够处理异常中止,将使Thread转换为最终状态Aborted。通过引发ThreadAbortException异常,这就会中断线程启动代码的执行。参考下一节中对线程异常中止的详细介绍。
● 阻塞等待(例如,Monitor.Wait、争用的Monitor.Enter、WaitHandle.WaitOne),Thread.Sleep或者Thread.Join操作被调用。线程的状态被设为WaitSleepJoin,执行阻塞,并且一旦所要求的条件变为真,它将恢复为Running状态。
● 线程的执行被挂起,作为系统服务(如GC)的一部分挂起,或者通过使用Suspend API挂起。在线程子系统能够响应之后,线程的状态变为Suspended。当线程的执行恢复时,将状态转换到Running。
如果一个线程的执行没有结束,那么Thread类的IsAlive属性将返回true—— 也就是说,如果线程状态不是Stopped或Aborted,即两个可能的最终线程状态。
休眠
Thread类的静态Sleep方法将当前执行的线程置为在提供的间隔时间内休眠(以毫秒或者TimeSpan为单位指定)。将一个线程置于休眠可使其进入WaitSleepJoin状态。调用Thread.Sleep(0)可使当前线程让位给其他可运行的线程,并且允许OS进行一个上下文切换。如果当前线程没有可执行的有用工作,这样做就很有用。注意,一旦在线程的时间片到期时,上下文切换会自动发生。
中断
Interrupt方法可以异步中断一个当前处于WaitSleepJoin状态的被阻塞的线程。这个中断会生成ThreadInterrupted Exception异常,这个异常来源于将它阻塞的语句。如果一个线程没有阻塞的情况下调用Interrupt,那么下次线程试图进入这种状态时就会立刻发生中断。正常情况下,线程可能在这样的中断有机会发生前就正常终止。
单独使用Interrupt是不足够的。如果一个线程没有阻塞,它将永远不会被唤醒。通过特定的协作可以温和地请求线程停止;如果它试图阻塞,仍然可以中断它。例如:
class Worker
{
private bool interruptRequested;
private Thread myThread;
public void Interrupt()
{
if (myThread == null)
throw new InvalidOperationException();
interruptRequested = true;
myThread.Interrupt();
myThread.Join();
}
private void CheckInterrupt()
{
if (interruptRequested)
throw new ThreadInterruptedException();
}
public void DoWork(object obj)
{
myThread = Thread.CurrentThread;
try
{
while (true)
{
// Do some work... (including some blocking operations)
CheckInterrupt();
// Do some more work...
CheckInterrupt();
// And so forth...
}
}
catch (ThreadInterruptedException)
{
// Thread was interrupted; perform any cleanup.
return;
}
}
}
另一段代码可以类似于如下使用工作者线程:
Worker w = new Worker();
Thread t = new Thread(w.DoWork);
t.Start();
// Do some work...
// Uh-oh, we need to interrupt the worker.
w.Interrupt();
这种模式需要工作者代码加入中断模式。有时这是不可行的,但是在最坏的情况下,中断仍然会在阻塞点发生。
挂起和恢复
线程挂起的特性在Framework 2.0版本中已经不再使用。在这里简单提及它,这样就能够处理可能用到它的已有代码,同时也会明白为什么它本身有危险。Suspend方法告诉线程子系统挂起一些目标线程的执行。在线程上调用Resume()可将该线程转换到Running状态,从它在挂起之前的状态开始精确执行。如果一个线程没有恢复,它就继续耗费系统资源,一直使用对象和资源,并在挂起之前锁定它们,直到关闭运行库时才释放。
遗憾的是,这种挂起可能会发生在目标线程拥有临界区和资源时。例如,当一个目标线程正在执行一个类的构造函数时,将它挂起,这时如果另一个线程需要调用同样的构造函数,就形成应用程序的死锁。挂起用来执行从目标线程用StackTrace类捕获栈陷阱这类的工作。如果由于历史原因而必须使用它,最好是最小化一个线程挂起时所用的时间。
结合
有时需要阻塞当前线程的执行,直到另一个目标线程的运行结束。这在fork/join类型中是普遍存在的,有一个主线程,它有一组更小的工作项目,并且必须确保完成它们中的每一个(有时称为阻碍)。Join方法允许您这样做。结合到另一个线程将把执行的线程(即,执行Join声明的线程)转换到WaitSleepJoin状态,一旦目标线程的执行完成,自动会回到Running状态。
例如,考虑如下代码:
Thread[] workers = new Thread[10];
// Create worker threads:
for (int i = 0; i < workers.Length; i++)
{
workers[i] = new Thread(DoWork);
workers[i].Start();
}
// Do some work while the workers execute...
// Now join on the workers (and perhaps process their results):
foreach (Thread t in workers)
{
t.Join();
}
在这个代码片段中,首先创建一个数组,构造并且启动每一个Thread元素,当它们在后台执行时完成一些工作,最终将它们按顺序结合到一起。结合同样提供超时的重载(毫秒或者TimeSpan),通过返回一个布尔值指示结合是否未被阻塞而成功(true),或者由于超时(false)而失败。
异常中止线程
可以异常中止线程,这是一种比较谨慎的终止线程的方法。通过在目标Thread对象上调用Abort,用户代码可以调用异常中止。线程异常中止也可以在AppDomain卸载进程中使用,从而谨慎地关闭卸载发生时在AppDomain中活动的所有代码。也可能是强行的卸载—— 这取决于主机配置;例如,如果代码花费很长时间也没有完成展开—— 不可能用谨慎的方式异常中止线程,SQL Server将强行异常中止。它会终止它们。在不透明的线程上进行线程异常中止是不可取的;它们应该只由能够减轻接下来发生崩溃状态的风险的复杂主机使用。
CLR使用延迟异常中止区域来确保正常的线程异常中止不能中断特定区域代码的执行。这样,CLR排队异常中止请求,并且一旦目标线程退出这个代码区域,它就处理异常中止请求(假设代码区域没有嵌套)。这样做的目的是为了防止发生崩溃。这与临界区域不同(后面将进行讨论),临界区域的作用是建议主机不尝试异常中止个别的线程,而是逐步升级到AppDomain卸载。下面的代码片段由CLR自动标记为延迟异常中止:
● 任何当前在托管的catch或者finally代码块中的代码。
● 受限执行区域(Constrained Execution Region,简称CER)中执行的代码。CER将在第11章详细讨论。
● 当调用非托管代码时。通常情况下,非托管代码没有准备处理线程异常中止过程。
线程异常中止在目标Thread上的当前执行中插入ThreadAbortedException。根据它的来源方式,将其称为异步异常。线程异常中止异常同样不可否认,因为不允许catch代码块取消它:
try
{
// Imagine a thread-abort is issued at this line of code...
}
catch (ThreadAbortException)
{
// Do nothing (i.e. try to suppress the exception).
}
// The CLR re-raises the ThreadAbortException right here.
CLR在所有catch代码块的最后重新引发ThreadAbortException异常。如果ThreadAbortException异常跨越正在卸载的AppDomain的边界,它将变成AppDomain- UnloadedException异常。
注意,线程异常中止随时有可能在任何位置发生。例如,可以看出下面代码中的(可能的)内存泄漏吗?
IntPtr handle = CreateEvent(...);
try
{
// Use the handle...
}
finally
{
CloseHandle(handle);
}
异常中止可以发生在将CreateEvent的返回值的赋给句柄和进入try代码块之间。因此,一个ThreadAbortException不会导致激活CloseHandle。同样地,异常中止甚至可以发生在调用CreateEvent和将它的返回值赋给句柄变量之间!必须使用在延迟异常中止区域中分配的智能包装器,该包装器使用Finalize方法保证清除。SafeHandle类(在下一章讨论)可以精确地实现这个目的。
2. 线程属性
除了使用Thread类控制托管线程的生命周期,还有许多属性可以利用。某些属性改变了CLR调度或者管理底层OS线程的方式。
线程身份
线程拥有唯一的系统生成的标识符,以及可以用于信息和调试目的的名称。ManagedThreadId属性获得线程的自动生成的整数序列号(由CLR创建),确保对于所有当前活动的线程是唯一的。Name属性可以设置和访问线程的更加有意义的string名称。这是还没有命名的可设定的线程属性,一旦给定名称,它就成为只读的属性。
后台线程
前面简单介绍过,一个线程可以被标记为后台线程。托管的应用程序将保持活动状态,直到它所有的非后台线程都退出。因此,如果要运行一些类型的端口监控程序或记录线程,并且这些线程只有在应用程序执行其他工作时才是活动的,那么可以使用IsBackground属性,设置线程在后台运行。例如,下面的示例代码创建了一个线程,并在后台运行它:
Thread t = new Thread(...);
t.IsBackground = true;
t.Start();
将要在后台执行的线程—— 即它的IsBackground属性为true—— 将同样拥有Background的信息化ThreadState值。布尔值表达式(thread.ThreadState & ThreadState.Background) == ThreadState.Background的值总是等于IsBackground属性的值。
线程优先级
所有线程都有一个相对优先级,用于决定抢占式调度程序给准备运行的代码分配时间的方式。线程所在的进程同样有一个相对优先级类,作为线程优先级的乘法器。尽管调度程序用的是成熟的算法—— 推荐参阅Windows Internals一书,在“参考文献”一节中有详细介绍—— 总的来说,可运行的高优先级的线程总是比低优先级的线程有更高的优先级。如果低优先级的线程正在执行并且没有使用其他物理硬件线程,低优先级的线程将被抢占,从而高优先级的线程将得以运行。OS也运用防止资源缺乏的策略。下面将讨论可能造成主要问题的称为优先级倒置的情况,由上面所描述的调度算法实现这种情况。
每个Thread对象有一个读/写Priority属性,可以在启动线程之前或之后的任何时候设置该属性。这个属性属于枚举类型ThreadPriority。这个枚举类型包括5个不同的值,每个值增加调度程序所察觉的相对优先级:Lowest、BelowNormal、Normal,AboveNormal和Highest。可以猜到,Normal是托管线程API创建的线程的默认值。请参阅本章后面关于进程的部分,了解关于进程优先级类的细节。
10.1.3 线程隔离的数据
默认情况下,静态变量属于AppDomain范围,并且在进程中的所有线程之间共享。在一些情况中,可能希望隔离并存储特定于给定线程的全局数据。这可以避免担心许多在下面列出的同步和死锁问题。
1. 线程本地存储(Thread Local Storage,TLS)
TLS可以将数据分配和存储到CLR管理的插槽中。这些内容存储在TEB中,并且和TLS的Win32概念有一些不同(例如,使用TlsAlloc、TlsSetValue、TlsGetValue、TlsFree等)。存储在TLS中的数据完全和其他线程隔离。一个线程不能访问存储在其他线程TLS中的数据。
为了开始使用TLS,必须首先为每个唯一的要存储的数据分配新的存储槽。这会分配一个结构,所有的托管线程都使用这个结构。采用两种静态方法完成这一点:Thread.AllocateDataSlot方法或者AllocateNameDataSlot方法。每个方法返回一个LocalDataStoreSlot对象,作为用于检索和存储这个存储槽中的数据的键,使用命名的存储槽可以在以后用GetNameDataSlot方法查找存储槽的键,而使用未命名的存储槽则需要一直保存返回的LocalDataStoreSlot。只有一个存储槽可以具有给定的名称:任何添加重复键的尝试都将产生异常。
在存储槽中读写数据由Thread的GetData和SetData静态方法完成:
object GetData(LocalDataStoreSlot slot);
void SetData(LocalDataStoreSlot slot, object data);
如果应用程序不再需要使用存储槽,那么调用FreeNamedDataSlot将释放命名的TLS存储槽以及任何和它相联系的资源。
例如,这段代码使用一个未命名的存储槽:
LocalDataStoreSlot slot = Thread.AllocateDataSlot();
// ...
Thread.SetData(slot, 63);
//...
int slotValue = (int)Thread.GetData(slot);
这在存储线程范围的上下文时会很方便,并且不需要向必须访问数据的每个方法传递大量参数。此外,一些库可以用TLS来保持跨越不相交的方法调用的数据,而不是强制用户代码维护和传递特殊的上下文对象。
2. 线程静态
使用TLS的最简单替换办法是用所谓的线程静态字段,它可以造成特定的线程范围的静态字段,而不是AppDomain范围的静态字段。线程静态就是一个由System.ThreadStaticAttribute属性注释的普通的静态字段:
class ThreadStaticTest
{
[ThreadStatic]
static string data = "<unset>";
static void Test()
{
Console.WriteLine("[Master] before = {0}", data);
data = "Master thread";
Console.WriteLine("[Master] before loop = {0}", data);
Thread[] threads = new Thread[3];
for (int i = 0; i < 3; i++)
{
threads[i] = new Thread(delegate(object j) {
Console.WriteLine("[Thread{0}] before = {1}", j, data);
data = "Subordinate " + j;
Console.WriteLine("[Thread{0}] after = {1}", j, data);
});
threads[i].Start(i);
}
Array.ForEach<Thread>(threads, delegate(Thread t) { t.Join(); });
Console.WriteLine("[Master] after loop = {0}", data);
}
}
调用Test方法,输出如下几行数据:
[Master] before = <unset>
[Master] before loop = Master thread
[Thread0] before =
[Thread0] after = Subordinate 0
[Thread1] before =
[Thread1] after = Subordinate 1
[Thread2] before =
[Thread2] after = Subordinate 2
[Master] after loop = Master thread
注意主从线程设置的值是在每个线程中完全隔离的。尽管主线程在运行从线程前将data设置为“Master thread”,但没有一个线程能看到这个值。同样地,当从线程退出时,主线程仍然看到值“Master thread”,即使每个从线程在执行期间已将本身的data版本改成其他的值。
可能已经注意到,在设置data前,每个从线程看到的是data的null值,而非“<unset>”。线程静态字段的另一个结果是,它们只由类构造函数初始化一次,并且只在第一次引用该字段的线程上。其他的线程将总是看到默认的null值(或者值类型的default(T))。使用类构造函数初始化线程静态字段会产生令人意想不到的行为,这个行为取决于固有的竞争条件;因此,强烈建议不要使用这个方法。
10.1.4 线程间的状态共享
多个线程可以共享同一数据的访问。例如,进程中的任何线程已经访问整个地址空间。CLR的类型安全限制了对这些对象的访问,而在线程上执行的代码可以访问这些对象。一旦线程获得了堆分配对象的引用,并且这个对象也可能同时被其他托管线程使用—— 静态变量或者传递给线程启动函数的对象—— 则有遇到一些复杂情况的风险。其他状态类型可以共享,例如进程范围的Win32 HANDLE、系统范围的命名内核对象、内存映射I/O等。
在线程间共享状态有时公认是很便利的。但是一旦开始共享状态,甚至是最普通的加载—修改—存储(例如,i += 5)这样的习惯语法,都会产生意想不到的更新冲突。如果操作的整个执行都保证立刻执行,那么该操作就是原子性操作。但是多个指令—— 例如在字段上进行加载—修改—存储—— 可能在执行期间的任何时间点中断。这可能是由于上下文切换或者真正的并行执行。当一个线程加载一个值后,另一个线程可以隐式地访问并修改它,这时第一个线程得到(在它的栈中)的原始数据就过时。这是一个典型的竞争条件。一旦多个线程共享状态访问,任何程序必须考虑所有可能的对于这个位置的交错读写。
防止出现这种并发错误的常用对策是使用临界区。临界区可以保护对代码块的访问,从而在任何给定时间内只有一个线程在临界区中。临界区最常见的实现是使用锁(lock)。如果任何时候一段代码要修改一个共享位置,它需要一个共享锁,如果可以保证一个时刻只有一段代码可以拥有给定的锁,那么就不会遇到上述的情况。在Framework中有许多锁定机制。在简要的竞争条件示例后,将逐一了解它们。要注意的是锁(lock)降低了应用程序显示出来的并发数量;引入锁的目的在于禁止多个线程同时在临界区中执行。稍后将看到一些可能作为结果引发的其他问题。
1. 典型的竞争条件
作为由于缺少临界区而造成程序正确性问题的情况的简单示例,考虑下面的代码:
static int nextId = 0;
static int NextId()
{
return nextId++;
}
NextId方法实际由3个IL指令组成:一个加载指令,一个增加(添加)指令,一个存储指令。如果两个线程可以并行访问这个方法,那么多个调用程序可能会获得重复的标识符。为了理解这种竞争条件存在的原因,考虑图10-3显示的时间线。

图10-3 非同步访问共享数据产生的竞争条件
因为每个执行的线程有自己的栈,所以每个线程都在其栈上加载一个nextId的副本并且对它进行操作。在这个例子中,Thread 1加载变量为0,然后增加它的本地副本到1。此时,它被抢占,原因是它的时间片到期或者优先级更高的线程变成可运行(或者作为选择,另一段代码正在另一个物理硬件线程上运行)。发生上下文切换,并且线程记住在它的栈中的值为1。然后Thread 2运行:它也加载newId为0,增加它的本地副本到1,然后将它存储回共享内存位置newId。然后Thread1恢复;它也将1存储到newId存储槽中。每个线程都看到NextId返回的值0,两者都执行完成后,nextId仍然只包含1。这并不很奇怪!注意,如果一个线程能够执行所有3个指令而不被中断,那么这段代码将正确工作。这是为什么竞争条件很难处理的原因之一—— 它们很难重新产生,而且超过一百万次才出现一次。
2. 监控程序
每个对象有一个监控程序,它可以用来同步对其的访问。类似于临界区,任何时候只有一个线程可以在一个对象的监控程序中运行(实际上,监控程序在CLR中就相当于Win32中的CRITICAL_SECTION数据结构及其相关函数)。CLR用对象的同步块管理监控程序的进入和退出,这一点在第3章中进行了介绍。监控程序也支持递归和重入,表示一旦线程拥有了一个对象的监控程序,任何对这个监控程序的重新请求都会获得成功。如果一个线程试图进入一个对象的监控程序,而这个监控程序被另一个线程拥有,那么这个请求将阻塞,直到另一个线程退出监控程序。超时是可选项,可以用监控程序的Enter API进行具体设定。
Monitor.Enter(object obj)将试图进入一个指定对象的监控程序,它将被阻塞,直到成功进入这个监控程序。如果调用的线程当前不在obj的监控程序中,那么Monitor.Exit(object obj)将退出指定对象的监控程序,并且抛出一个SynchronizationLockException异常。每个对Enter(o)的调用必须与对Exit(o)的调用相匹配;否则,线程将可能无限期地拥有o的监控程序的锁(lock)。这样会造成死锁。
下面的模式可以确保适当的退出:
object obj = /*...*/;
Monitor.Enter(obj);
try
{
// Protected code...
}
finally
{
Monitor.Exit(obj);
}
使用监控程序,可以轻松地解决上面提到的NextId问题:
static int nextId;
static object mutex = new object();
static int NextId()
{
Monitor.Enter(mutex);
try
{
return newId++;
}
finally
{
Monitor.Exit(mutex);
}
}
确保在每次访问或者修改静态nextId字段时都锁定mutex对象。通常情况下,精确地决定锁定什么以及确保始终保持一致地这样做需要一定的技巧,特别是在代码中从多个访问路径同步访问数据的时候。但是假设这是唯一直接访问nextId的代码,那么代码就是线程安全的—— 它不会再返回重复的标识符。
还有一个基于超时的TryEnter方法,它将试图进入目标对象的监控程序,如果时间长于指定的间隔时间,它就返回。该方法具有一个获得基于int的毫秒数的重载,也有一个获得TimeSpan的重载。若在成功进入监控程序之前超时,函数将返回false。例如,上面的代码可能如下所示,其中具有超时:
static int nextId;
static object mutex = new object();
static int NextId()
{
bool taken = Monitor.TryEnter(mutex, 250);
if (!taken)
throw
new Exception("Possible
deadlock...monitor acquisition
time-out");
try
{
return newId++;
}
finally
{
Monitor.Exit(mutex);
}
}
Monitor的Wait、Pulse和PulseAll方法也可以在并发任务中用于基于事件的同步。将在下面讨论这一点。
C# 锁块(语言特性)
C#语言提供可以用作上面模式的简写的语法。lock关键字在下面的代码块前后进入和退出对象的监控程序;退出监控程序实现为finally代码块以确保执行。VB也用SyncLock关键字提供相似的特性:
static int nextId;
static object mutex = new object();
static int NextId()
{
lock (mutex)
{
return customerId++;
}
}
这个方法在行为上是一样的,但是无可否认,它更具可读性和可写性,因此更具可维护性。不提供超时的变量。但是,将超时作为死锁预防机制根本不是健壮的解决方法;目标应该是在代码产生死锁前搜索和修改任何可能的死锁情况。使用TryEnter可以识别出这些问题,但通常程序挂起就是在通知出现这样的问题。
同步的方法
System.Runtime.CompilerServices的MethodImplAttribute属性可以用来同步对整个方法的访问。使用具有参数MethodImplOptions.Synchronized的属性注释方法,这会造成CLR使用监控程序同步所有对这个方法的访问。这就是如何在J#中实现使用synchronized关键字修饰的方法。这个结构和上面回顾的结构之间的区别是整个方法的执行需要一个锁:
static int nextId;
[MethodImpl(MethodImplOptions.Synchronized)]
static int NextId()
{
return customerId++;
}
遗憾的是,这个机制也不能控制哪一个监控程序是需要的。在几乎所有情况中,程序逻辑应该保护锁,这样外部的代码不能影响获得锁的能力。在一些极端的情况中,这个技术会造成单独AppDomain中的代码相互冲突,甚至产生死锁。通常情况下,应该坚持使用上面描述的lock关键字。
3. Windows 锁对象
有几个Windows原语执行对象允许在线程之间进行一些形式的同步。Mutex和Semaphore类是在相关的Win32函数上的简单包装器。它们建立在WaitHandle原语之上,允许用标准接口等待和发信号。它们提供了同监控程序相似的功能,并且可以在与监控程序相同的许多情况下使用,但是仍有一些重要的区别。首先,锁本身是由Windows(通过Win32函数调用)控制的。因此,使用它们分配必须处理的非托管资源(HANDLES)。第二,更重要的是,它们可以被命名并且在多个进程之间引用,从而同步对机器范围内资源的访问。
互斥体
互斥体(也称为变异体)表示“互相排斥”并且允许同步对共享状态的访问,这和Monitor类很相似。它的功能是在具有Mutex类的托管代码的外部。因为互斥体可能是系统范围的,所以多个进程可以用同一个互斥体保护对一些共享系统资源的访问。同样注意,和监控程序一样,互斥体是递归的和可重入的;尝试获得一个已经由当前线程所拥有的互斥体都会成功并增加一个递归计数。互斥体必须释放和获得它一样多的次数。
互斥体可以被命名,在这种情况下,它们是系统范围的互斥体。没有名称的是本地互斥体,并且不能够在分配它们的进程外部进行访问。必须使用共享的Mutex对象访问它们。有两个主要的方法可以得到一个互斥体的实例:要求OS用一个可用的构造函数分配新的实例,或者用静态OpenExisting方法打开以前分配的系统范围的互斥体。
Mutex()和Mutex(bool initiallyowned)构造函数可以构造新的本地互斥体,而Mutex(bool initiallyOwned, string name)和Mutex(bool initiallyOwned, string name, out bool createdNew)可以构造新的系统范围的、命名的互斥体。如果不需要进行进程间的互操作,那么本地的重载函数更可取;它们避免了和其他命名互斥体的冲突,并且由于它们是本地进程的,因此开销更少。initiallyOwned参数指明是否将获得新的互斥体或者不是自动获得。如果OS获得了互斥体的拥有权,那么createdNew输出参数被设为true。
互斥体的状态可能是signaled或unsignaled(记住,它构建在WaitHandle之上)。状态为signaled的互斥体是可以获得的,而状态为unsignaled的互斥体则已经被拥有。使用WaitOne实例方法可以得到互斥体。这个方法具有简单的无参数重载WaitOne(),它将无限地阻塞,直到获得目标互斥体。WaitOne(int millisecondsTimeout, bool exitContext)和WaitOne(TimeSpan timeout, bool exitContext)重载允许传入一个超时,在此之后就放弃等待,返回true或者false以表示成功或者失败。每一个重载都获得exitContext参数,当栈中存在一个ContextBoundObject时,这个参数用来退出当前同步域。如果必要的话,可以查阅SDK获得更多关于同步域的信息。也可以在WaitHandle上使用WaitAny或者WaitAll静态方法来同步等待许多WaitHandle(包括Mutex)。ReleaseMutex用来释放已经获得的互斥体。
例如,下面这段代码表示了一个本地互斥体是如何保护对一个共享变量的访问:
static int nextId;
static Mutex mutex = new Mutex();
static int NextId()
{
mutex.WaitOne();
try
{
return newId++;
}
finally
{
mutex.ReleaseMutex();
}
}
在抛弃的互斥体上发布WaitOne将抛出一个AbandonedMutexException异常。抛弃的互斥体是系统互斥体,它在其线程的进程退出时没有被自己的线程正确释放。如果进程在更新共享数据结构的中途崩溃,这种情况就可能发生。可以捕获这个异常并且通过验证共享数据的完整性来响应。如果每件事情看上去正常运行,那么就可以像平常一样请求互斥体和继续(要小心谨慎)。
信号量
通过使用指定的内核对象,信号量可以保护对临界区资源的访问,包括本地和系统范围的资源。信号量的实例是由托管的Semaphore类表示并且包装对底层Win32函数的访问。然而,与互斥体不同的是,信号量不仅仅是为了互相排斥对资源的访问。每个信号量有许多可用的资源,每次某个人获得一个信号量时,这些资源就会减一。当计数为零时,任何要获得附加锁的尝试将阻塞执行,直到信号量的计数器重新增加到大于零。信号量的增加是通过释放获得的锁。同样地,信号量会有一个最大计数。如果线程试图增加信号量的计数以超过这个最大值,它将抛出SemaphoreFullException异常。这样就有效地将访问受保护资源的线程的数量限制到一个有限的数值内。这对限制访问缺乏的共享资源很有帮助。
和互斥体不同,任何线程可以增加或者减少信号量。实际上,在生产者-消费者场景中,这是一个常见的模式:一个线程将增加计数以指示新资源的到来,而另一个线程将它减少,例如,消费生产的产品。对于系统范围的信号量,如果程序崩溃,这会造成难以调试的错误。抛弃的互斥体对检测这样的问题非常有用;然而,有了信号量,这种编码模式完全合法,并且在OS中是没有错误的。
Semaphore类提供的功能几乎和Mutex一样。可以使用一个可用的构造函数重载来创建新的信号量;这些重载获得两个有趣的参数:initialCount和maximumCount,这两个参数都是整型。initialCount参数指定信号量的初始计数,而maximumCount设定信号量计数的最大值。OpenExisting静态方法将打开一个已经存在的指定OS信号量。
和Mutex一样,可以用WaitOne、WaitAny或者WaitAll方法获得一个信号量(也称为减少它的计数)。可以用Release方法释放一个信号量(也就是增加它的计数)。默认的重载是将计数每次加1,但是可以用Release(int count)重载,根据count的值增加它。
访问控制列表(ACL)
所有Windows的执行对象都可由ACL保护。这对系统范围的对象尤其有用,例如为了确保任意进程不能用名称访问Mutex或者Semaphore,并且造成奇怪的程序错误。其提供的功能和用来保护对文件的访问是非常相似的。MutexSecurity、SemaphoreSecurity和EventWaitHandleSecurity是相关的类型,它们位于System.Security.AccessControl的命名空间中。第9章中具体介绍了.NET Framework的ACL功能。
4. 读取器—写入器锁
使用上面提到的互斥技术,确保只有一个线程可以同步读或写共享数据,从而保护对共享资源的访问,这是非常简单的事情。然而,将读写同等对待会造成不必要地减少并行化。在多数情况下,真正需要实施的是同步写入器不能被干扰(也就是说,允许原子化的更新),以确保读者只看到一致的数据。一种策略可以保证做到这些,当写入器正在写入时,只有一个写入器可以这样做,并且没有人能够并发读取;但是如果没有人写入,则多个任务可以并行地读取。为了实现这样的策略,并不需要切断某个时刻对数据的所有访问。实际上,这样做会给性能带来显著的退化,例如在一些情况中,大量的读取器大大降低写入器的数量。
ReaderWriterLock类可以真正做到文中所写的那样。它允许多个读取器同时持有一个ReaderWriterLock的锁,但是当一个写入器拥有了一个锁时,其他任何人都不能获得读或者写的锁。只有当所有读取器和写入器释放了它们的锁时,写入器才能获得一个锁。通过AcquireReaderLock方法和AcquireWriterLock方法来获取锁。每个方法获得整型或基于TimeSpan的超时值。一个线程将一直阻塞,直到获得其请求的类型的锁,或者等待时间超时,在这种情况下,方法不会在没有获得锁的情况下返回。可以传递Timeout.Infinite(例如,整数-1)来指示获取应该没有超时。IsReaderLockHeld和IsWriterLockHeld属性可以确定一个锁是否成功。如果请求的类型的锁由当前线程拥有,它们就返回true。
对应的ReleaseReaderLock和ReleaseWriterLock方法释放各自类型的锁。因为ReaderWriterLock类是可重入的和递归的,单个的线程可以同时保持多个读取器锁。ReleaseLock方法是释放当前线程(读取器或写入器)拥有的所有锁的快捷方式,并且将立刻释放保持的多个线程:
ReaderWriterLock rwLock = new ReaderWriterLock();
rwLock.AcquireReaderLock(250);
if (rwLock.IsReaderLockHeld)
{
try
{
// Synchronized code...
}
finally
{
rwLock.ReleaseReaderLock();
}
}
尽管是可重入的,但是一个锁只有在当前没有读取器时才允许一个新的写入器。这就是说一个已经拥有读取器锁而又试图获得写入器锁的线程将阻塞,直到它的超时到期(如果提供了无限的超时时间,则不确定何时取消阻塞)。为了方便起见,ReaderWriterLock也有一个UpgradeToWriterLock方法。这会将一个已经拥有的读取器锁变成写入器锁。这不是可能期望的原子操作,因此一个已经在队列中等待获取锁的写入器可能有机会在锁升级之前运行。这个方法返回一个LockCookie,可以将其传递给DowngradeToReaderLock方法,将写入器锁降为读取器锁。
请求队列和资源缺乏
ReaderWriterLock维护两个锁请求的队列:一个是用于写入器,另一个用于读取器。在读取非常频繁的应用程序中,可能会发生写入器资源缺乏的情况。如果一个写入器必须等到所有读取器锁都被释放之后才能得到它请求的锁,则在理论上就可能发生资源缺乏的情况。如果读取器继续请求锁而不管是否写入器在等待,就可能永远不会有零活动的读取器。如果情况是这样,就没有写入器会得到锁,很显然这会严重破坏程序。
为了解决这个问题,ReaderWriterLock类实现为只要有一个写入器进入队列,就停止分发读取器锁。只有当写入器队列是空的时候,才会再次允许读取锁。遗憾的是,这意味着会发生与上文提到的相反的情况。也就是说,如果一个固定的写入器流一直进入队列,那么读取器将永远没有机会运行。因此,这个类型应该只在读写操作的比例至少为2:1的情况下使用。
示例:读取器和写入器
接下来研究说明互斥访问的一些相关潜在问题的情况,以及在特定情况下使用ReaderWriterLock的好处。假设有一个Account类,它有很多属性。这个类的每个实例由程序中的许多线程共享,并且由于各种目的而被频繁读取。这些实例的更新相对较少发生。应用程序的一个清楚的需求是永远不会在不一致的状态(就是在其更新期间)下查看Account。
class Account
{
private string company;
private decimal balance;
private DateTime lastUpdate;
public Account(string company)
{
this.company = company;
this.balance = 0.0m;
this.lastUpdate = DateTime.Now;
}
public string Company { get { return company; } }
public decimal Balance { get { return balance; } }
public DateTime LastUpdate { get { return lastUpdate; } }
public decimal UpdateBalance(decimal delta)
{
balance += delta;
lastUpdate = DateTime.Now;
return balance;
}
}
除非使用Account的代码执行一些类型的互斥,否则就会有许多问题发生。并发更新可能互相产生冲突(例如,造成更新不平衡),或者一个读取器将访问数据,而同时一个实例只有部分被更新(例如,balance被更新,但是相应的lastUpdate又进行修改)。一个不完善的同步模式也许会在任何Account实例的使用的周围使用锁。
例如,一个读取器可能只锁定实例自身:
Account account = /*...*/
lock (account)
{
Console.WriteLine("{0}, balance: {1}, last updated: {2}",
account.Company, account.Balance, account.LastUpdate);
}
同样,一个写入器可能锁定以确保它不会和并发更新有冲突:
Account account = /*...*/;
lock (account)
{
account.UpdateBalance(-125.75m); // debit operation
}
但是,如果大部分时间只读取数据,两个同时发生的读取也会互相冲突。这必定会降低并发性。为了解决这个问题,可以用ReaderWriterLock类:
class Account
{
public ReaderWriterLock SyncLock = new ReaderWriterLock();
// Class definition otherwise unchanged...
}
这个类的使用者可以用一个简单的策略设计同时访问一个共享的Account实例。当一个类将从Account读取数据时,它必须首先调用AcquireReaderLock,读取完数据后调用ReleaseReaderLock;同样地,写入器必须调用AcquireWriterLock和ReleaseWriterLock方法:
Account account = /*...*/;
account.SyncLock.AcquireReaderLock(-1);
try
{
Console.WriteLine("{0}, balance: {1}, last updated: {2}",
account.Company, account.Balance, account.LastUpdate);
}
finally
{
account.SyncLock.ReleaseReaderLock();
}
同样,写入器将变为:
Account account = /*...*/;
account.SyncLock.AcquireWriterLock(-1);
try
{
account.UpdateBalance(-125.75m);
}
finally
{
account.SyncLock.ReleaseWriterLock();
}
这消除了不必要的互斥锁争用情况。也可能考虑同步方法本身的实现,而不用强制使用者理解锁定机制。例如,UpdateBalance方法可以自动在其主体的开始和结束处分别获取写入器锁和释放写入器锁。然而,对于读取器锁并不如此简单。通常,要使用一个跨越多个属性访问的锁来模仿原子性;因此,在每个属性访问前后获得和释放新的锁都是不可行的。例如,如果有一个原子性的获得者(getter)方法,可以适当同步访问。这两个实例方法演示了这个技术:
public class Account
{
private ReaderWriterLock syncLock = new ReaderWriterLock();
public decimal UpdateBalance(decimal delta)
{
syncLock.AcquireWriterLock(-1);
try
{
balance += delta;
lastUpdate = DateTime.Now;
return balance;
}
finally
{
syncLock.ReleaseWriterLock();
}
}
public void GetState(out string companyOut, out decimal balanceOut,
out DateTime lastUpdateOut)
{
syncLock.AcquireReaderLock(-1);
try
{
companyOut = company;
balanceOut = balance;
lastUpdateOut = lastUpdateOut;
}
finally
{
syncLock.ReleaseReaderLock();
}
}
// Class definition otherwise unchanged...
}
不可否认的是,必须通过使用这种原子性的GetState(…)方法访问对象的状态是比较笨拙的。可以考虑返回一个包含每个字段的简单结构体。
5. 互锁操作
Interlocked类包括许多静态辅助方法,它们对共享内存区域进行原子性更新操作。通过Interlocked,可以使用硬件原语来实现快速比较和交换以及相关的函数,并且绝对避免任何形式的阻塞。这些使用所有现代硬件(例如,x86上的lock前缀,IA-64上的内存栅栏)中可用的特殊指令,并且引入缓存一致协议和内存控制器以保证一定程度的线程安全。
在共享整数上添加一个数字要用3个IL指令:加载、修改和存储。正如在上面所看到的那样,这会形成竞争条件。没有使用可能造成竞争线程进入阻塞等待状态的重量级锁,而是使用原子性的Add方法。有一个int类型(32-位)和long类型(64-位)的重载。两个重载都获得对某个位置的引用作为其第一个参数,而将加入到当前位置的值作为第二个参数。这个方法通过值增加该位置的内容,并且返回在改变之前该位置的原始值。这对上面的情况是完美的解决方案:
static int nextId = 0;
static int NextId()
{
return Interlocked.Add(ref nextId, 1);
}
这段代码现在是完全线程安全的,并且也有更好的性能。
同样地,Increment和Decrement方法类似于Add(1)和Add(-1)方法的快捷应用,除了它们返回在函数调用后保留在内存位置中的值。换句话说,可以用Increment来作为示例,如下所示:
static int nextId = 0;
static int NextId()
{
return Interlocked.Increment(ref nextId) - 1;
}
Exchange方法允许替换通过引用指向的内容,并且检索原始值作为单个的原子性操作。它有几种重载:用于整型、浮点型、IntPtr和Object。将引用作为第一个参数传递,将存放在该位置中的值作为第二个参数传递,函数返回原来的内容。CompareExchange也是相似的,但是它会有条件地检查以确保在改变内容前目标引用的值是具体的值。前两个参数和Exchange相同,第三个参数是用来比较的。如果location1中存储的值与comparand相等,它将被设为value。否则,location1的内容将不会改变。CompareExchange返回任何它在location1中看到的值。
例如,CompareExchange可以用来建立一个旋转锁,如程序清单10-1中所示。这段代码使用了许多高级技术,如临界区和SpinWait,这些将在“高级线程主题”一节中讨论。
程序清单10-1 旋转锁
using System;
using System.Threading;
class SpinLock
{
private int state;
private EventWaitHandle available = new AutoResetEvent(false);
// This looks at the total number of hardware threads available; if it's
// only 1, we will use an optimized code path
private static bool isSingleProc = (Environment.ProcessorCount == 1);
private const int outerTryCount = 5;
private const int cexTryCount = 100;
public void Enter(out bool taken)
{
// Taken is an out parameter so that we set it *inside* the critical
// region, rather than returning it and permitting aborts to creep in.
// Without this, the caller could take the lock, but not release it
// because it didn’t know it had to.
taken = false;
while (!taken)
{
if (isSingleProc)
{
// Don’t busy wait on 1-logical processor machines; try
// a single swap, and if it fails, drop back to EventWaitHandle.
Thread.BeginCriticalRegion();
taken = Interlocked.CompareExchange(ref state, 1, 0) == 0;
if (!taken)
Thread.EndCriticalRegion();
}
else
{
for (int i = 0; !taken && i < outerTryCount; i++)
{
// Tell the CLR we're in a critical region;
// interrupting could lead to deadlocks.
Thread.BeginCriticalRegion();
// Try 'cexTryCount' times to CEX the state variable:
int tries = 0;
while (!(taken =
Interlocked.CompareExchange(ref state, 1, 0) == 0) &&
tries++ < cexTryCount)
{
Thread.SpinWait(1);
}
if (!taken)
{
// We failed to acquire in the busy spin, mark the end
// of our critical region and yield to let another
// thread make forward progress.
Thread.EndCriticalRegion();
Thread.Sleep(0);
}
}
}
// If we didn't acquire the lock, block.
if (!taken) available.WaitOne();
}
return;
}
public void Enter()
{
// Convenience method. Using this could be prone to deadlocks.
bool b;
return Enter(out b);
}
public void Exit()
{
if (Interlocked.CompareExchange(ref state, 0, 1) == 1)
{
// We notify the waking threads inside our critical region so
// that an abort doesn't cause us to lose a pulse, (which could
// lead to deadlocks).
available.Set();
Thread.EndCriticalRegion();
}
}
}
最后,在32位的机器上读和写64位的值不是原子性的。这意味着,受限于竞争条件,一个线程可读64位位置中的前32位,而另一个线程可以更新该位置,然后这个线程再读该位置中的后32位。假设值将不是指针(因为在32位的机器上),但是这会造成细微的数据破坏。为了保证原子性,必须使用InterlockedRead和Exchange方法。
10.1.5 常见的并发问题
并发代码会引起一系列的常见问题。在上面介绍了一个最大的问题:竞争条件。竞争是最难测试和修改的错误,这是由于在表面上毫无关联的代码间复杂的交互,以及结合各种怪异的、难以重现的时间选择。还有一些其他的问题必须了解。
1. 死锁
在并发应用程序中一个最著名的问题可能就是死锁。死锁是如下的情况:一个线程链相互等待彼此完成,这样谁都永远不会被唤醒。例如,如果线程在共享数据上同步,但是获得和释放锁的顺序不同,就会发生死锁的情况。除非适当地缓解这种情况,否则程序(或者程序的一部分)将不确定地最终挂起。
作为一个简单的例子,考虑如下情况:共享数据上有两个线程锁。然而,它们用不同的顺序来这样做。线程A先获得a的锁,然后获得b的锁:
lock (a)
{
lock (b)
{
// Synchronized code...
}
}
线程B获得同样的锁,但是顺序相反,即先获得b的锁,然后获得a的锁:
lock (b)
{
lock (a)
{
// Synchronized code...
}
}
考虑按照这种顺序将发生什么:A获得a然后被抢占;B获得b。B试图获得a,但是因为A拥有了它,所以造成阻塞。然后A运行并试图获得b,但是因为B拥有了它,所以也造成阻塞。现在死锁发生,即所谓的僵局(deadly embrace)。线程A将不会释放a的锁,直到它可以获得b的锁,但是线程B将不会释放b的锁,直到它能获得a的一个锁。遗憾的是,死锁并不总是可以这样直接检测到。等待图表是一个数据结构,它跟踪谁在等待谁。这张图表中的任何循环代表一个死锁。例如,想象A在等待B,B在等待C,C在等待D,D在等待A;这是一个死锁,但是更难于检测和管理。
一旦处于死锁的情况下,必须采取一些行动。但是CLR不会试图自动解决这个冲突。然而,SQL Server作为主机会执行死锁检测。大多数RDBMS处理这种情况的办法是删除任何一个作业量完成最少的任务。但是对于复杂的等待图表,所使用的算法会非常复杂。因为没有内置的支持来处理死锁,需要自己减少这种风险。最好的解决方法当然是完全避免这种情况。
避免死锁最简单的方法是用调整锁级别(lock leveling)。这个技术确保锁的获得在整个应用程序中以一致的顺序发生。如果两段代码块总是先获得a再获得b,那么上面的情况将永远不会发生。一般来说,这是通过以分层的方式分解软件组件来完成的。然后,在特定层中的组件只能获得比它所在的层更低的层的锁。很难在整个应用程序中都这样实施,但是严格的代码复查和一些级别的库支持可有所帮助。
2. 资源缺乏
处理时间分配给线程的方法完全由OS调度算法解决。这种调度算法依靠进程类和线程优先级选择可用的任务来运行。总而言之,其结果就是最高优先级的工作项有机会先于低优先级的工作项运行。在多数情况中,这正是所需要的情况。然而,它也会造成问题,导致的结果就是通常所指的资源缺乏。
作为资源缺乏的说明,考虑一个程序,它生成许多高优先级的线程。如果这些线程用光所有可用的处理器时间,那么就没有低优先级的任务能够有时间运行。调度程序会陷入一种情况,允许高优先级的线程用完所有的CPU时间,没有给低优先级的任务留下一点时间来运行。无可否认,这种情况是很少见的,因为多数任务是I/O绑定的,而非CPU绑定的(甚至阻塞的高优先级的线程也会允许低优先级线程运行),但是它确实可能会发生。
优先级倒置(Priority inversion)是一个资源缺乏的经典表现。想象一下这种情况:3个线程在运行,A是高优先级,B是中等优先级,C是低优先级。C有机会运行,并获得了锁a。A随后变成可运行,试图获得锁a,所以A阻塞(因为C已经拥有它)。在C释放a之前,B变成可运行;OS让B顺利运行,因为它的优先级比C高。此时,就会遇到优先级倒置。C的优先级被人为地放大,因为它获得了一个临界区并且迫使A阻塞。B必须放弃处理器,并允许C释放a,以便A能够重新继续进行。
一般的资源缺乏问题由Windows处理。线程调度程序用防资源缺乏策略避免出现上面叙述的不利情况。关于这种策略如何运作的详细讨论不在本书的介绍范围内。请参阅“参考文献”部分来获得资源,其中有对OS调度算法的深入研究。
10.1.6 事件
并发的任务必须经常互相协调工作。想象一种情况,一个线程正在生产一些有用的项,而另一个线程正在消费这些项。一种实现这个模式的方法是在循环中编写检查项的消费者代码,如果有相应的项,则处理它们,直到没有任何项,然后睡眠一段有限的时间,然后再次检查。这是非常差劲的方法。检查新的项目之前,在每个循环的最后为了新的元素而等待任意的时间是非常浪费的:而消费者的睡眠时间毫无疑问将比必要的要长或者不够长。理想状况是,当一个新的项可用时,生产者将对所有感兴趣的团体“宣布”新的项是可用的。
Events(事件)就允许这样做。一个事件是一个通告,通知其他对象可能感兴趣的一些条件已经引发。当一个数据结构被修改或者当一些对象造成条件变为true时,它负责广播相关的事件。然后监听事件的对象可以唤醒并执行所需的处理工作。在上面提到的上下文中,每次一个项生产出来时,生产者就生成一个事件,消费者可以用这些事件触发消费。
1. 基于监控程序的事件
监控程序可以用于基于事件的编程。用同样的方法可以调用Enter进入一个对象的监控程序,也可以同样地等待一个object。object可以收到信号以唤醒任何当前在等待的人。Pulse用来只唤醒(随机选择)一个等待者,而PulseAll用来唤醒所有等待者。
为了等待object,必须首先进入目标对象的监控程序。一旦调用了Wait(…)方法,就要暂时隐式地退出对象的监控程序,而线程进入WaitSleepJoin线程状态。当目标对象生成一个事件(也就是一个脉冲(pulse)),线程将唤醒,它会试图重新获得对象的监控程序,并在调用Wait之后正确地继续。因为没有办法指定其等待的条件,所以必须在唤醒后立刻对其持有的条件进行检查。
例如,下面的代码要求队列中至少有一项在处理。当唤醒时必须验证持有的条件:
static Queue<object> queue = new Queue<object>();
void Consume()
{
object item = null;
lock (queue)
{
// Standard test-condition/wait loop:
while (queue.Count == 0)
Monitor.Wait(queue);
item = queue.Dequeue();
}
// OK to process the item...
}
Wait方法有许多重载,它们都获得一个object参数obj,表示等待的目标object。类似于System.Threading命名空间中许多与Wait相似的构造,可以以int类型的毫秒计数或者TimeSpan类型实例的形式传递一个超时。如果等待时间超时,那么方法将返回false。在这种情况中,事件不会发生。
调用Pulse和PulseAll方法生成事件必须在持有目标object的监控程序时。Pulse方法直到监控程序释放时才会被处理。一旦处理,这将唤醒当前等待事件的线程:
void Produce()
{
object item = /*...*/;
lock (queue)
{
queue.Enqueue(item);
Monitor.Pulse(queue);
}
}
使用哪个类型的Pulse方法完全取决于算法。例如,在上面的生产者—消费者例子中,假设每个生产者有多个消费者,那么Pulse可能是最佳选择。假设一个线程一次可以消费一项,那么对所有有效的消费者使用PulseAll方法将造成它们都同时竞争进入监控程序,这增加了争用并且浪费生命周期。一次Pulse一个消费者将允许一次只有一个可用的消费者响应每个生产的项。
遗漏的脉冲
不管是使用Pulse还是PulseAll,有一种存在问题的情况,称为“遗漏的脉冲”,必须注意这种情况。这个问题发生在一个事件遗漏计划中的接收者时,有时候会造成接收者随后等待事件(已经发生的事件);如果消费者代码编写地不正确,这会造成死锁。
为了说明这种问题可能发生的情况,假设用Pulse来通知对象其感兴趣的条件已经引发。有许多线程,每个都在等待不同的条件引发,但是它们为事件通信共享同一个object。除非使用PulseAll,否则就不能确定当生成事件时哪个线程将被唤醒。事件可能传递给等待完全不同的条件引发的线程;它将迅速注意这一点并忽略事件。在这种情况下,实际上等待条件的线程将继续等待。根据线程间的互相交互,这个线程将永远等待。很明显的是,在这种情况中使用PulseAll将解决问题。
即使是使用PulseAll的时候,不适当设计的消费者仍然会遗漏事件。如果在事件发生时,它们不处于等待状态,它们必须小心确认在等待之前事件没有发生。如果一个消费者忽略在调用Wait前检查条件,它最终可能一直等待。这明显是因为不正确的代码,但这是遇到的一个比较简单的情况,尤其对System.Threading的初学者而言。
2. Win32事件
AutoResetEvent和ManualResetEvent类(由EventWaitHandle类型派生而来)都提供基于WaitHandle的事件功能。这种功能与上面描述的监控程序的功能相似,但是又包括一些额外的功能。正如它们的名称所指,两者主要的区别在于如何重置事件。
和其他Win32同步原语一样,可以用OpenExisting方法打开一个系统范围内的、在进程间共享的已有命名事件;也可以用构造函数构造本地的或者系统范围的事件。为了等待一个事件,只需要简单地调用WaitOne方法,或者传递一个等待的超时。与Mutex和Semaphore相似,可以用WaitHandle.WaitAny或者WaitAll方法来等待多个句柄。它们会一直阻塞,直到一个事件收到信号。
为了设置一个事件,可以调用Set方法。将句柄设置为收到信号的状态。如果使用一个自动重置的事件,这将最多唤醒一个在当前句柄中等待的线程。第一个进入事件的线程将信号重置为未收到信号的状态。对于手工重置的事件,所有等待此句柄的线程会被唤醒,并且这个事件一直保持收到信号的状态,直到显式地用Reset方法重置。可以使用自动或者手动重置变量构造非常丰富的线程交互。由于这个原因,EventWaitHandle经常倾向于配对使用Monitor.Wait、Pulse和PulseAll。
3. 定时器
System.Threading.Timer类允许在一定的时间间隔内异步执行代码。这个功能隐式地使用线程池来执行触发的事件。为了创建一个新的定时器,只要简单地调用其中的一个构造函数,传入将在循环的时间间隔中调用的callBack委托,以及传递给该委托的对象state。这个委托与上面的WaitCallback类似,并且有void TimerCallback(object state)的签名。
有几种构造函数的重载,允许指定激活定时器事件的开始时间和周期性时间间隔。不考虑选择的内容(有有符号的和无符号的32位整型、64位整型和基于TimeSpan的版本),dueTime参数指定在开始定时器前所延迟的时间量(0表示立即执行),period指定循环的事件之间延迟的时间量。可以为两个参数传递Timeout.Infinite。如果用于dueTime,Timer将不会开始执行,直到使用Change指定新的dueTime;如果用于period,它表明Timer应该永远继续执行。
为了能停止计时器的执行和释放所持有的任何本地资源,调用Timer实例的Dispose方法。必须保留一个创建的Timer的引用;否则,将创建一个临时的内存泄漏,并且将不能停止计时器的执行。
同样要注意的是,这个特别的Timer和WinForm不能很好地一起工作。在下面将从较高的层次上讨论GUI线程模型。但是,如果要修改回调中的WinFormsUI组件—— 计时器经常用来这样做—— 那么需要转换到UI线程(将很快看到如何手工完成这项工作)。System.Timers.Timer类提供一个SynchronizingObject属性。如果将它设置为目标UI的窗口小部件,当调用回调函数时,类将自动转换到UI线程。System.Windows.Forms.Timer类提供相似的功能。两个类都是组成部分,因此可以很方便地集成WinForms设计器。
10.1.7 异步编程模型(APM)
Framework的一些领域提供开销很大的同步操作的异步版本。System.IO.Stream API是一个很好的例子;在第7章中介绍过如何使用它们的例子。在Framework中多数这样的异步操作遵循一个标准的设计模式,称为异步编程模型(Asynchronous Programming Model,APM)。遵循这个模式的操作提供两种方法,BeginXxx和EndXxx,它们是与同步方法Xxx相对应的异步的开始和结束方法。例如,Stream提供BeginRead和EndRead来代替它的Read方法。
APM的方法对遵循标准的设计约定:
● BeginXxx接受和Xxx同样的参数,并且在结尾增加两个额外参数:一个异步回调(Async Callback)和一个状态对象(state object)。BeginXxx返回一个IAsyncResult对象,它可以用来完成异步的活动(可以很快看到这个接口)。执行这个功能将初始化异步活动;
● EndXxx接受一个IAsyncResult作为输入并且返回和Xxx操作相同的类型。这个函数阻塞执行,直到异步活动完成,并且返回由底层Xxx操作返回的值;如果底层的操作抛出一个异常,那么EndXxx将中继(也就是重新抛出)这个异常。
例如,下面是Stream类的Read、BeginRead和EndRead操作:
public abstract int Read([In, Out] byte[] buffer, int offset, int count);
public virtual IAsyncResult BeginRead(byte[] buffer, int offset, int count,
AsyncCallback callback, object state);
public virtual int EndRead(IAsyncResult asyncResult)
注意,它们完全遵循上面描述的规则。作为参考,下面是System.IAsyncResult的接口:
public interface IAsyncResult
{
object AsyncState { get; }
WaitHandle AsyncWaitHandle { get; }
bool CompletedSynchronously { get; }
bool IsCompleted { get; }
}
执行一个异步的活动并不能准确地保证操作执行到什么地方。它可能是来源于ThreadPool的线程,或者根本就没有线程(如I/O完成端口的情况—— 详见第7章),或者甚至是线程上调用BeginXxx方法自身的同步。
会合(Rendezvousing)是完成异步活动的动作。有3种选择:
● 提供一个BeginXxx活动的AsyncCallback委托,并且在调用该委托时,有选择地将一些state通过IAsyncResult的AsyncState属性透明地传递给该委托。一旦完成,异步活动将调用委托,此时方法能够结束活动。这需要在对象上调用EndXxx,在该对象上调用BeginXxx以获取返回值和编组异常(如果有的话)。即使Xxx的返回类型是void,调用EndXxx对于释放IAsyncResult保持的资源也是必要的。
● 查询IAsyncResult的IsCompleted属性以检查完成。如果它返回true,则活动完成,可以无阻塞地调用EndXxx。这可以用于使应用程序进一步发展,直到任务完成。注意不应该使用IsCompleted作为旋转循环的谓词,因为这会导致非常差的程序性能。考虑一个阻塞等待的情况,如果想暂停线程的执行直到活动完成为止。
● 阻塞直到异步任务完成。可以通过在IAsyncResult的AsynWaitHandle上调用WaitOne方法或者仅仅直接调用EndXxx来做这项工作。记住,EndXxx将一直阻塞直到异步操作完成。
永远不要试图混合使用回调方法和阻塞等待方法。回调和调用者共用同一个IAsyncResult;对其调用EndXxx会造成竞争条件导致的失败。除此以外,关于设置IsCompleted、给WaitHandle发信号和调用回调,没有顺序上的保证。如果试图混合使用这些技术,所有这些因素会导致产生麻烦。
注意委托遵循APM。定义的任何自定义委托类型将有一个同步的Invoke以及异步的BeginInvoke和EndInvoke方法。相比于使用ThreadPool.QueueUserWorkItem,通常应该优先使用APM方法;而对于使用异步委托,则应该优先使用ThreadPool。直接使用ThreadPool比用异步委托调用更有效率。
UI线程和后台工作者
在除专门的UI线程以外的任何线程上对用户界面(UI)控件进行操作是不合法的。每个Windows GUI应用程序至少有一个这样的线程(可以通过Win32的Get WindowsThread- ProcessId函数检索)。这意味着如果进行任何异步工作,例如在ThreadPool上、在显式的Thread上或在异步回调中,就要担心转换回UI线程来更新界面组件。System.Windows.Forms.Control类型(在System.Windows.Forms.dll程序集中)实现System.ComponentModel.ISynchronizeInvoke接口,它可以用来做这样的转换,以回到目标控件所属的线程。
例如,如果有一个Label,需要对它的Text属性进行更新,这时就需要担心这一点。Control提供一个InvokeRequired属性,它返回true以指示没有在UI线程上执行,并且因此要进行转换。通过调用Invoke方法并传递一个委托来执行转换,其中的委托将排队等待在UI线程上执行以及调用:
System.Windows.Forms.Label lb = /*...*/
void SomeAsyncWorkerCallback(IAsyncResult ar)
{
// Call some EndXxx function, get results...
lb.Invoke(delegate { lb.Text = "..."; }, null);
}
除Invoke之外,还有BeginInvoke和EndInvoke方法,它们只是APM版本,以防当消息传递给GUI线程并被它处理时不希望阻塞的情况。
为了辅助这种类型的逻辑,Framework 2.0在System.ComponentModel中包含了一个新的类型:BackgroundWorker。这个新的类型可以用来从GUI线程中启动新的异步任务并服务于UI线程中的各种回调。BackgroundWorker处理在合适的时候转换回UI线程。仅仅实例化一个新的实例并添加DoWork事件的处理程序。这是需要进行大量操作的位置。不需要接触这个函数内的UI组件,因为它在一个ThreadPool线程上执行。也可以增加ProgressChanged和RunWorkerCompleted事件的处理程序;这些处理程序在UI线程上激活,意味着可以为通信进行或者为操作的最终结果更新可视元素。执行RunWorkerAsync调用DoWork处理程序,传递作为DoWorkEventArgs.Argument的可选对象参数。
10.1.8 高级线程主题
本节讨论各种其他的高级线程主题。
1. 临界区和线程亲和区
因为宿主API允许复杂的主机将逻辑线程映射到任何一个它们选择的物理OS表示上,如何在任何给定的时间内使用逻辑工作量非常重要。除此之外,主机倾向于交换工作,试图进行AppDomain关闭(通过线程异常中止),或者任何数量的事情。Thread API的 Begin-和EndCriticalRegion以及Begin-和EndThreadAffinity方法传达了这方面的信息。
临界区是一部分代码,如果由于线程异常中止而中断,则会将整个AppDomain置于危险中。临界区是个很好的例子。当一个线程进入临界区时,不允许它释放临界区,并且对临界区进行部分更新会使AppDomain崩溃。因此,在临界区中的线程将通常不会被中断;如果主机必须升级,它将初始化整个的AppDomain卸载。
线程亲和(Thread affinity)是对依靠TEB中的状态来正确运行的代码的通俗一般性术语。例如,如果在TLS中隐藏了一些重要的状态,那么将工作迁移到另一个物理线程将造成不可预知的行为。使用线程亲和API通知主机这种情况。应该尽可能避免依赖于线程亲和。在一些情况下—— 例如,Win32的GetLastError—— 线程亲和是不可避免的。
2. 内存模型
因为CLR是一个虚拟执行机,它负责一定程度的抽象底层硬件平台。计算机体系结构经常是无序地执行读写指令。整个软件栈的优化(例如,编译器优化、JIT优化)会造成这样的结果,但是多数是由于硬件自身的原因。现代计算机以预测的方式,使用非常具有深度的流水线提前执行命令,甚至有时是没有顺序的。现代缓存层次结构使用了多层缓存,它们中的一些在CPU之间共享(例如,在超线程和多核处理器中),而有的就不是的。缓存是用来确保处理器缓存保持同步的机制。
所有这些体系结构都可保证基于内存一致性模型,这样就可以编写以预测方式运行的软件。所有模型确保单线程的程序的执行不能看到重新排序的效果。但是并发软件就可以。所有内存模型同样保证不会违反数据相关性和控件相关性顺序。顺序一致性(也称为程序顺序一致性)是一个模型,它确保所有的读写发生的顺序和编写它们的顺序相同。这很少有实际作用。按上面所描述的,软件和硬件无序地执行指令是为了性能的目的;限制它们这样做的能力会伤害性能。并且,多数对内存的读和写不能共享状态,在这种情况中永远观察不到这种优化。
内存模型以它们的强度为特征。顺序一致性具有最高的强度,而允许不考虑就重新安排读写的处理器具有最弱的强度。多数模型处在两者之间。IA-32(也称为x86)和AMD-64实质上是顺序一致的。没有建立一些较弱的、关于缓存一致性的全序保证,它在实践中很少见(尽管在较大数量的处理器上,这可能随着时间而变得显而易见)。IA-64是目前Intel或者AMD体系结构中实现的最弱的模型。它使用特殊的指令控制相关性。
选择使用Intel术语来控制指令重新排序。术语如下所示,并已在图10-4中用图形表示:
● 加载获取:防止在加载后可重新排序的指令(读和写)在加载前被移动。指令仍可以在加载获取之前移动到之后。IA-64在这里的操作码是ld.acq。
● 存储释放:防止存储前可重新排序的指令在存储之后被移动。在存储释放之后的指令仍可以在它之前被移动。IA-64在这里的操作码是st.rel。
● 内存栅栏(也称为屏障):防止所有可重新排序的指令进行与栅栏指令有关的移动。可以用Thread.MemoryBarrier方法手动插入屏障。

图10-4 指令重新排序(IA-64术语)
现在已经看过了所有的这些术语,接下来了解CLR的内存模型保证了什么。它的JIT编译器发出合适的指令,使这个工作可以在所有体系结构上完成。
ECMA规范仅阻止易变动的读和写被重新排序。将一个变量标记为volatile会使所有对某个位置的读和写分别变成加载获取和存储释放。作为选择,在这种方式下,可以用JIT内在的Thread.VolatileRead和VolatileWrite方法来标记具体的加载和存储操作。易变性使这个保证不考虑实现选择哪个内存模型。
CLR2.0的内存模型在一定程度上被加强,可以在IA-64上执行。它保证所有存储操作都是存储释放。然而,加载仍然是普通的加载。这个内存模型允许删除对一个位置的多余写入,而不是多余读取。这些规则对于使无锁定算法(例如重复检查锁)正确工作足够简单:
class Singleton {
private static Singleton instance;
private string state;
private bool initialized;
private Singleton()
{
state = "...";
initialized = true;
}
public Foo Instance
{
get
{
if (instance == null)
{
lock (this)
{
if (instance == null)
instance = new Singleton();
}
}
return instance;
}
}
}
这个模式比无条件锁住每个对get_Instance的调用更有效率。锁将只在首次构造instance的时候获取,并且用于在当时发生的任何争用。但是这个模式将在没有存储释放保证的情况下在IA-64中被破坏。这是因为构造单独的Singleton牵涉到3个写入器:它的构造函数的state和initialized写入,以及get_Instance方法的instance写入。这些操作相互之间可以自由地进行重新排序。然后造成instance在其状态被初始化前就被设置!
3. COM 套间
套间一般用在COM代码中,使得编写多线程代码更加简单。COM互操作性在第11章讨论。图10-5显示了几种套间:
● 单线程套间(Single Threaded Apartment,STA):每个STA只能包括一个线程。进程可以包含任何数量的STA。在STA中实例化的COM组件只能由单独的STA线程访问。这就是说试图从任何其他线程访问组件的代码必须首先迁移到STA线程上,这是就消除了同时访问的可能性。
● 多线程套间(Multi-Threaded Apartment,MTA):每个进程只能有一个MTA,但是一个MTA可以包含任何数量的线程。在一个MTA中建立的COM组件只能从MTA内部进行访问,就是说在访问这样的组件之前,STA线程必须迁移到MTA。在一个MTA中运行的组件是有效的自由线程,意味着组件必须手动通过锁解决并发访问的事情。
● 中性套间(Neutral Apartment,NA):每个进程总是有一个NA,并且NA中没有线程。在NA中建立的任何对象都可以在不迁移的情况下从STA线程或者MTA线程进行访问。
CLR将自动初始化线程到具有CoInitializeEx API的进程中。通过很多方式可以指明它应该加入哪种类型的套间。可以用STAThreadingAttribute或者MTAThreadingAttribute注释应用程序的入口点。许多项目类型自动注释入口点。例如,如果有一个GUI应用程序,主要工作者线程必须放在一个STA中。也可以在启动显式创建的线程之前,使用Thread类上的SetApartmentState函数。使用哪种类型的套间完全取决于场景以及在执行的COM互操作类型;当COM服务器被注册时,它们指示是否支持自由线程、单线程,或是两者都支持。

图10-5 COM套间(STA、MTA、NA)
当一个对COM组件代理的调用可能违反套间访问规则时,CLR会处理这个迁移。如果试图从MTA线程调用一个在STA中建立的COM组件,CLR会通过对STA的消息队列执行PostMessage作为回应。STA必须随后通过运行它的消息循环抽取它的队列,这将分发对目标COM对象进行调用的方法。与此同时,MTA抽取它的消息循环,等待从包含函数返回信息的STA返回的PostMessage。注意有一个特殊的类型,System.EnterpriseServices.ServicedComponent,它遵守COM套间规则。例如,如果在一个STA中建立该类型,那么任何来自MTA的调用将完全按上面所指定的情况进行工作。
这个行为引入两个复杂的问题。第一,如果一个STA不抽取消息,任何试图迁移方法调用的MTA将被延迟。实际上,如果STA进入一个它永远不抽取消息的情况,MTA代码可能会变成死锁。由于GC的终结函数线程从一个MTA运行,因此任何终结(Finalize)一个COM代理或者ServiceedComponent的尝试都需要迁移。这里的死锁会造成无限的资源泄漏。幸亏CLR将在托管代码(例如,争用的Monitor.Enter、WaitHandle.WaitOne、Thread.Join等)中阻塞的时候为STA代码完成抽取。第二,当一个STA抽取时,任何它分派的消息直接进入当前线程的物理栈中。因此,任何线程范围的状态可以被线程上当前运行的代码检测到。如果当前是在安全敏感的状态,则获取一个Monitor,或者在TLS中隐藏数据,这对代码将是可见的。
4. 旋转循环和超线程的处理器
Intel的超线程(Hyper-Threading,简称HT)技术在每个物理处理器上使用多个逻辑硬件线程。这很像多个核心的CPU,它使处理器能够处理OS线程级别的并行化。然而,和多个核心不同,硬件线程共享相同的执行单元和缓存。然而,每个硬件线程有自己的一组寄存器,减少了上下文切换的需要。
旋转循环必须注意不会消极地干扰HT CPU的预执行能力。这样做会阻止持有锁的线程尝试进一步被获得,这会造成浪费旋转。这由x86的PAUSE指令完成,该指令由Thread.SpinWait方法提供。这个方法按照传给它的参数所指定的次数进行循环,在每个循环上执行一个PAUSE;应该为该值至少传入1。(在非HT CPU上,这是非操作指令(no-op))这依赖于Win32的Yield Processor函数。参考上面的程序清单10-1,了解SpinWait的用法,了解实现可重用的旋转锁。

