C#中的多线程
摩 |
尔定律已经改变了。计算机仍然在不断变快,不过所倚赖的不再是时钟频率,而是更多的处理器核心。大势所趋,开发者也愈发频繁地处理多线程编程的任务。
多线程编程更加困难,且很容易出错。很多难以察觉的bug都出现在线程的切换过程中。除非仔细检查程序中的每一行代码,并分析线程切换时的各种情况,否则很容易引入潜在的问题。也许有一天,线程的切换发生在了你测试时没有覆盖到的地方,从而导致了程序的崩溃。这样,编写正确的程序变得更加困难,验证程序正确性的难度也大大提高。因此,虽然多线程程序更为流行了,但开发难度仍旧要比单线程程序大得多。
本章并不会让你成为多线程编程的专家,不过其中给出的条目却都是.NET多线程程序设计中的一些常见的建议和规则。若想完整地学习多线程技术的方方面面,我推荐阅读Joe Duffy的Concurrent Programming on Windows Vista: Architecture, Principles, and Patterns(Addison-Wesley, 2008)。在取得以上共识后,下面我们先来看看多线程编程相对于单线程编程所增加的挑战。
简单地将单线程程序转为并行执行将导致很多问题。我们来看一个简单的银行账户的定义。
public class BankAccount
{
public string AccountNumber
{
get;
private set;
}
public decimal Balance
{
get;
private set;
}
public BankAccount(string accountNumber)
{
AccountNumber = accountNumber;
}
public void MakeDeposit(decimal amount)
{
Balance += amount;
}
public decimal MakeWithdrawal(decimal amount)
{
if (Balance > amount)
{
Balance -= amount;
return amount;
}
return 0M;
}
}
如此简单的一段代码,无需过多检查即可保证其正确性。不过在多线程环境中却并非如此。为什么呢?因为上述代码中包含了很多潜在的竞争条件。存款和取款的方法实际上均由几个不同的操作组成。+=操作符首先将当前的账户余额从内存中读取出来并保存于寄存器中。随后CPU将执行相加操作。随后,新的余额才会被写回至内存中。
问题在于,在多核心的处理器上,应用程序中的多个线程可能同时运行于多个核心上。这样,不同的线程就有可能交替地对同一块内存地址进行读写,进而造成数据错误。考虑如下的场景。
(1) 线程A开始存入10 000美元的操作。
(2) 线程A获取到当前的余额为2 000美元。
|
(4) 线程B获取到当前的余额为2 000美元。
(5) 线程B计算出新的余额为6 000美元。
(6) 线程A计算出新的余额为12 000美元。
(7) 线程A将余额12 000美元保存。
(8) 线程B将余额6 000美元保存。
这样,这种线程之间的交替操作就导致了从前单线程情况下不会发生的错误。
之所以会出现这样的竞争条件,是因为该类中没有为可能引起副作用的操作提供任何同步机制。Deposit()和Withdrawal()方法都会产生副作用:它们均修改了当前的状态,并返回新的值。这两个方法依赖于调用方法时系统的当前状态。例如,若是账户中的余额不足,那么取款操作将失败。没有副作用的方法一般均不需要过多的同步。此类方法不依赖于当前状态,因此在方法执行过程中,即使当前状态发生了改变,也不会影响到其执行结果。
修复这个问题非常简单,只需添加一些锁定即可。
public void MakeDeposit(decimal amount)
{
lock (syncHandle)
{
Balance += amount;
}
}
public decimal MakeWithdrawal(decimal amount)
{
lock (syncHandle)
{
if (Balance > amount)
{
Balance -= amount;
return amount;
}
}
return 0M;
}
这样似乎解决了问题,不过却可能导致死锁。例如,若某个客户拥有两个银行账户,一个储蓄账户,一个支票账户。客户可能需要进行转账操作——从一个账户中提款,并存入另一个账户中。逻辑上,这是个单一的操作。不过在实现上,它却包含了一系列的操作。首先从一个账户中取款(这本身也是个多步的操作),随后存入另一个账户(也是个多步操作)中。这似乎不会有什么问题:在从一个账户中取款时锁定该账户,然后获取第二个账户的锁定,并执行存款操作。
不过若是多个线程在同时执行转账操作,那么就可能发生死锁。死锁将会发生的情况是,两个线程互相持有另一个线程完成工作所需要的锁。这样,无论等待多长时间,情况都不会有所转变。应用程序看起来就像是崩溃了。实际上并没有崩溃,不过却在等待着一些永远都不会发生的事情。
另一种比死锁略微好一点的情况是活锁。活锁涉及一种较为复杂的锁定机制,这种机制把对同一块数据的读取和写入区分对待。这种机制允许让多个读取线程同时检查一块数据,不过同时仅允许一个写入线程修改该数据。此外,当某一写入线程在修改数据时,也不允许读取线程读取该数据。活锁将发生于这样的情况下:不停有读取线程在检查某块数据,而让写入线程无法插入到其中。这样,该数据实际上就变为了只读的。
没有什么好的方法可以避免此类问题。多线程编程本身就很复杂,所有操作的复杂性也有了很大的提高。不过多线程编程前景广阔,因此每个C#开发人员至少都需要对多线程技术有一些基本的了解。
.NET Framework在很多地方都使用到了多线程。例如在Web应用程序和Web服务中,每个请求都由一个专门的ASP.NET工作线程负责。remoting类库也用同样的方式处理每个请求。有些计时器的事件处理程序运行于新的线程之上。WCF(Windows Communication Foundation)类库也使用了多个线程。你还可以在调用Web服务时采用异步的方式。
你总会用到上述这些技术。因此若想对.NET有更深入的理解,那么必须了解一些多线程技术。
条目11:使用线程池而不是创建线程
你无法知道应用程序中需要的线程的最佳数量。你的应用程序可能运行于拥有多个核心的计算机上,不过无论你今天假设将会有多少个核,6个月后都十有八九会不正确。此外,你也无法控制CLR为完成其自身工作(例如垃圾收集器等)所创建的线程的数量。在服务器应用程序中,例如ASP.NET或WCF服务,每个请求都由一个不同的线程处理。作为应用程序或类库开发者的我们,将很难优化目标系统上的线程数量。但.NET线程池却能够获取到所有的必要信息,来优化指定系统上活动线程的数量。此外,即使你创建了过多的任务或线程,那么线程池也能用队列把无法及时处理的请求保存起来,直至有线程释放出来。
.NET线程池能够替你完成很多线程资源的管理工作。当应用程序开始执行重复的后台任务,且并不需要经常与这些任务交互时,使用.NET线程池管理这些资源将会让程序的性能更佳。
调用QueueUserWorkItem方法即可让线程池来为你管理资源。在其中添加项目之后,该项目将在有空余线程时得以执行。根据正在运行的任务的数量和线程池的大小,项目可能会立即执行,或等待直至有空余的线程出现。线程池由每个处理器中一定数量的就绪线程和一系列I/O读取线程组成。具体的数字因版本的不同而不同。在开始向队列中插入新的任务时,线程池可能会创建更多的线程,这也取决于当前内存以及其他资源的可用情况。
这里并没有仔细解释线程池的实现,因为线程池本身是用来减轻我们的工作,并让框架去帮我们分担的。简而言之,线程池中的线程数量将在可用线程数量和最小化已分配但尚未使用的资源之间自动平衡。在添加了工作项之后,若当前还有可用线程,那么就会开始执行。线程池的工作是保证尽可能快地提供出可用线程。不过对于你来说,只要发起请求,就不必再担心其内部处理机制了。
线程池同样也会自动管理任务结束后的维护工作。当任务结束之后,线程并不会被销毁,而是返回到可用状态,以便执行其他任务。稍后,该线程可能会被线程池分配去处理另外的任务。接下来的任务并不一定和前面的任务一样,可能为应用程序需要的任何一个较耗时的方法。只需调用QueueUserWorkItem并传入需要的方法,线程池将自动为你管理好所有的一切。
线程池还能够用另一种方法帮你管理运行于其他线程中的任务。所有QueueUserWorkItem使用的线程池中的线程均为后台线程(background thread)。这也就意味着你并不需要在应用程序退出之前手工清理资源。若是应用程序在这些后台线程仍在运行时就退出了,那么系统将停止这些后台任务,并释放所有与应用程序相关的资源。你只需要在退出应用程序之前确保停止了所有非后台线程即可。不过若没有做到这一点,那么应用程序很可能在不做任何事情的时候还占用着资源。
从另一个角度考虑,因为后台线程将在没有任何警告的情况下被终止,因此需要小心其中对系统资源的访问方式,免得当应用程序终止时,让系统停留在不稳定的状态中。很多情况下,当线程终止时,运行时将在该线程上抛出ThreadAbortException异常。而若是应用程序在尚有后台线程运行时突然终止,那么后台线程将不会收到任何通知,而只是被立即终止。因此,若某个线程可能会让系统资源处于不稳定状态中,那么则不要使用后台线程。好在这种情况并不多见。
系统将管理线程池中活动线程的数量。线程池将根据当前可用的系统资源数量来适当地执行任务。若系统负荷已经接近极限,那么线程池将暂停开启新任务。而若是系统并不繁忙,那么线程池将立即开始新任务。我们无需手工编写负载均衡的逻辑,线程池将为你做好这一切。
或许你会认为,同时执行的任务的最佳数量就等于计算机上的CPU内核数量。这虽然不算是个最差的策略,不过却过于简单了一些,基本上也不会是最好的答案。等待时间、对除了CPU之外其他资源的争夺以及其他你无法控制的线程均会影响到应用程序中最佳的线程数量。若创建的线程过少,那么将无法完全利用到系统资源,白白浪费了计算能力。而若是线程数量过多,那么计算机将花费过多的时间在线程调度上,进而影响到线程的实际执行时间,反倒降低了整体效率。
为了给出一些通用的原则,我编写了一个使用“亚历山大港的希罗”(Hero of Alexandria)提出的海伦算法计算平方根的小程序。这些原则均比较通用,因为每种算法都有它自己的特性。在这个程序中,核心算法比较简单,且并不需要与其他线程进行交互。
算法的开始将作一次猜测,取得某个数字的平方根,比如猜测1。若想找到下一个近似值,只需计算出当前猜测和原输入与当前猜测的商的平均值。例如,若想得到10的平方根,首先猜测为1,接下来的猜测为[1 + (10/1)]/2,即5.5。重复上述步骤,直至得到符合条件的正确值即可。其代码如下。
public static class Hero
{
private const double TOLERANCE = 1.0E-8;
public static double FindRoot(double number)
{
double guess = 1;
double error = Math.Abs(guess * guess - number);
while (error > TOLERANCE)
{
guess = (number / guess + guess) / 2.0;
error = Math.Abs(guess * guess - number);
}
return guess;
}
}
为了比较线程池、手工创建线程和单线程三种情况下应用程序的性能,这里我还给出了相应的测试程序,其中将重复多次进行该计算。
private static double OneThread()
{
Stopwatch start = new Stopwatch();
start.Start();
for (int i = LowerBound; i < UpperBound; i++)
{
double answer = Hero.FindRoot(i);
}
start.Stop();
return start.ElapsedMilliseconds;
}
private static double ThreadPoolThreads(int numThreads)
{
Stopwatch start = new Stopwatch();
using (AutoResetEvent e = new AutoResetEvent(false))
{
int workerThreads = numThreads;
start.Start();
for (int thread = 0; thread < numThreads; thread++ )
System.Threading.ThreadPool.QueueUserWorkItem(
(x) =>
{
for (int i = LowerBound;
i < UpperBound; i++)
{
// 进行计算
if (i % numThreads == thread)
{
double answer = Hero.FindRoot(i);
}
}
// 减少计数器的值
if (Interlocked.Decrement(
ref workerThreads) == 0)
{
// 设置事件
e.Set();
}
});
// 等待信号
e.WaitOne();
// 跳出
start.Stop();
return start.ElapsedMilliseconds;
}
}
private static double ManualThreads(int numThreads)
{
Stopwatch start = new Stopwatch();
using (AutoResetEvent e = new AutoResetEvent(false))
{
int workerThreads = numThreads;
start.Start();
for (int thread = 0; thread < numThreads; thread++)
{
System.Threading.Thread t = new Thread(
() =>
{
for (int i = LowerBound;
i < UpperBound; i++)
{
// 进行计算
if (i % numThreads == thread)
{
double answer = Hero.FindRoot(i);
}
}
// 减少计数器的值
if (Interlocked.Decrement(
ref workerThreads) == 0)
{
// 设置事件
e.Set();
}
});
t.Start();
}
// 等待信号
e.WaitOne();
// 跳出
start.Stop();
return start.ElapsedMilliseconds;
}
}
单线程版本非常简单。而两个多线程版本均使用了lambda表达式语法(参考第1章条目6)来定义后台线程将要执行的操作。当然,如条目6中所述,也可以将lambda表达式替换成匿名委托。
System.Threading.ThreadPool.QueueUserWorkItem(
delegate(object x)
{
for (int i = LowerBound; i < UpperBound; i++)
{
// 进行计算
if (i % numThreads == thread)
{
double answer = Hero.FindRoot(i);
}
}
// 减少计数器的值
if (Interlocked.Decrement(
ref workerThreads) == 0)
{
// 设置事件
e.Set();
}
});
若使用显式的方法并显式创建委托的话,需要增加很多代码。因为外部方法中定义的很多局部变量(重置事件、线程数和当前线程的索引)都会被用于内部的后台线程中。而C#编译器则会自动为使用了lambda表达式的内联方法创建一个闭包(参见第4章条目33和第5章条目41),省去了我们的工作。此外,注意labmda表达式语法也可以用于多语句的方法,而不仅限于单一的表达式中。
主程序为三个版本的算法均统计了时间,这样可以看到使用线程给算法带来的影响。图2-1即为统计结果。从该示例中,我们可以学到一些东西。首先,与线程池线程相比,手工创建线程的开销更大。若创建了10个以上的线程,那么过多的线程将成为最主要的性能瓶颈。即使在这个并不需要太多等待时间的算法中,其影响也显而易见。
|
|
|
|
|
图2-1 单线程、使用System.Threading.Thread和使用System.Threa- ding.ThreadPool.QueueUserWorkItem程序的计算时间结果。y轴为在一台双核笔记本电脑上执行100 000次计算所花费的时间(单位为ms)
若使用线程池,那么必须添加40个以上的项目,才会看到额外开销逐渐占据了支配地位。而这只是在一台双核的笔记本电脑上而已。对于那些服务器级别的计算机,更多的核心将支持更多的并发线程。让线程数量多于内核数量通常将带来更好的结果。不过,其实际效果非常依赖于应用程序本身,还依赖于应用程序线程花费在等待资源上的时间。
之所以线程池实现要优于手工创建线程,主要有两个因素。首先,线程池将重用那些被释放了的线程。而在手工创建线程时,必须为每个任务创建一个全新的线程。线程的创建和销毁所花费的时间要高于.NET线程池管理所带来的开销。
第二,线程池将为你管理活动线程的数量。若创建了过多的线程,那么系统将挂起一部分,直到有足够的资源执行。QueueUserWorkItem则将工作交给线程池中接下来的一个可用线程,并帮你完成一定的线程管理工作。若应用程序线程池中所有的线程均被占用,那么线程池也会挂起任务,直至出现可用线程。
随着多核处理器的一天天普及,你会越来越频繁遇到多线程的应用程序。若你在开发.NET服务器端应用程序,例如WCF、ASP.NET或.NET远程处理,那么则已经开始与多线程打交道了。这些.NET子系统均使用线程池来管理线程,因此你也应该采用同样的做法。线程池能够降低额外开销,进而提高性能。此外,.NET线程池也能够帮你更好地管理当前用于执行工作的活动线程数量。
条目12:使用BackgroundWorker实现线程间通信
条目11演示了使用ThreadPool.QueueUserWorkItem来执行多个后台任务。该API非常易于使用,因为大多数的线程管理工作都交给了框架和底层的操作系统来完成。其中的很多功能也能够重用,因此若在应用程序中需要使用到后台线程来执行任务的话,那么QueueUserWorkItem将是你首要考虑使用的工具。但对于将要执行的后台任务,QueueUserWorkItem有着几个假设。若是程序的实际需求不符合这些假设,那么还是需要额外的工作。不过不是直接使用System.Threading.Thread来创建线程,而是使用System.Compon- entModel.BackgroundWorker。BackgroundWorker不仅构造于ThreadPool之上,还为线程间通信提供了很多支持。
其中需要处理的最重要的问题就是在WaitCallback中抛出的异常。WaitCallback即为实际执行任务的后台线程,若是该方法中有任何异常抛出,那么系统将终止应用程序,而不仅仅是终止该后台线程。这个行为和其他后台线程API的行为一致,不过处理的难点在于,QueueUserWorkItem并没有提供任何内建的错误处理机制。
此外,QueueUserWorkItem也没有提供实现后台线程和前台线程之间交互的内建支持——你不能检查完成情况、跟踪进度、暂停任务或是取消任务。若程序需要这些功能,那么则要使用建立在QueueUserWorkItem功能之上的BackgroundWorker组件。
BackgroundWorker组件包含了System.ComponentModel.Component的功能,用来提供设计时的支持。不过在没有设计器支持时,BackgroundWorker在代码中也非常有用。实际上,我在使用BackgroundWorker的大多数情况时,都不是在Windows窗体中。
BackgroundWorker的最简单使用方法是创建一个符合委托签名的方法,将该方法附加到BackgroundWorker的DoWork事件上,然后调用Background- Worker的RunWorkerAsync()方法。
BackgroundWorker backgroundWorkerExample =
new BackgroundWorker();
backgroundWorkerExample.DoWork += new
DoWorkEventHandler(backgroundWorkerExample_DoWork);
backgroundWorkerExample.RunWorkerAsync();
// 其他位置:
void backgroundWorkerExample_DoWork(object sender,
DoWorkEventArgs e)
{
// 方法的内容省略
}
在这个模式中,BackgroundWorker提供的功能和ThreadPool.Queue- UserWorkItem完全一致。BackgroundWorker将使用ThreadPool执行其后台任务,其内部也会用到QueueUserWorkItem。
BackgroundWorker的强大之处在于,其整套框架已经为一些常见的场景提供了支持。例如,BackgroundWorker使用事件在前台和后台线程之间通信。当前台线程发起一个请求时,BackgroundWorker将触发后台线程上的DoWork事件。随后DoWork事件的处理程序将读取其参数,并开始相应的执行工作。
在后台线程任务结束(即DoWork事件处理函数执行完成)之后,Back- groundWorker将在前台线程中触发RunWorkerCompleted事件,如图2-2所示。这样,前台线程即可根据需要在后台线程结束时执行必要的后续处理。
图2-2 BackgroundWorker类能够在后台线程执行完毕时触发前台线程中的事件处理程序。只要注册了完成事件,那么在DoWork委托完成执行之后,BackgroundWorker就会触发该事件
除了BackgroundWorker触发的事件,也可以用属性来维护控制前台线程和后台线程间的交互。WorkerSupportsCancellation属性让Background- Worker知道后台线程是否能够中止该操作并退出。WorkerReportsProgress属性则会告知BackgroundWorker对象,每过一段时间后台线程将会通知前台线程其执行进度,如图2-3所示。此外,BackgroundWorker还能将取消请求从前台线程转发给后台线程。这样,后台线程即可检查CancellationPending标记,并根据需要停止执行。
图2-3 BackgroundWorker支持使用多个事件来取消执行任务,向前台线程报告执行进度以及错误报告。BackgroundWorker定义了线程间通信的协议,并在需要的时候通过事件来支持通信机制。若想报告执行进度,那么后台线程必须触发定义在BackgroundWorker中的事件。前台线程的代码也必须支持此类事件,提供必要的事件处理程序
此外,BackgroundWorker还拥有内建的协议来支持报告后台线程中发生的错误。在条目11中,我曾经解释过异常不能从某个线程抛到另一个线程中。若是后台线程中抛出了异常,且没有被捕获,那么该线程将被终止。不仅如此,前台线程也不会收到任何有关后台线程已被终止的通知。BackgroundWorker通过在DoWorkEventArgs中添加Error属性,并将异常信息保存在其中来解决了这个问题。后台线程可以捕获所有的异常,并将其设置到Error属性中。(需要注意的是,这是仅有的几个需要捕获所有异常的场景之一。)随后在后台线程返回时,前台线程即可在事件处理程序中处理该异常。
前面曾提到过,我经常在非Form类中使用BackgroundWorker,甚至在非Windows窗体中,例如服务或Web服务等。不过有几点注意之处。当Backgro- undWorker检测到其正运行于Windows窗体程序中,且该窗体为可见,那么ProgressChanged和RunWorkerCompleted事件将通过转发控制以及Control.BeginInvoke被转发给GUI线程中(参见本章条目16)。在其他情况下,这些委托只是运行于线程池中的某个空闲线程上。在条目16中你将看到,这个行为可能会影响到接收到事件的顺序。
最后一点,因为BackgroundWorker构建于QueueUserWorkItem之上,所以我们可以使用BackgroundWorker来处理多个后台请求。通过检查BackgroundWorker的IsBusy属性即可判断BackgroundWorker当前是否在执行任务。若需要同时执行多个后台任务,那么可以创建多个BackgroundWorker对象。这些BackgroundWorker对象均会共享同一个线程池,因此其实际的执行效果和QueueUserWorkItem一样。这样就需要保证事件处理程序要访问到正确的线程,以便保证后台线程和前台线程之间通信的正确性。
BackgroundWorker支持创建后台任务时的很多常用模式。借助于BackgroundWorker,我们即可提高代码的重用性,根据需要使用这些模式,而并不用手工定义前后台线程之间的通信协议。
条目13:让lock()作为同步的第一选择
线程之间需要进行通信。我们需要提供一种安全的方式,让应用程序中的线程能够发送并接收数据。不过,在线程间共享数据可能会引发同步问题,导致数据完整性方面的错误。因此,必须保证每一块共享数据的当前状态都是一致的。实现该目标需要使用同步原语(synchronization primitive)来保护对同享数据的访问。同步原语能够保证在完成一系列必要操作之前,当前线程不会被打断。
.NET BCL中提供了很多不同的同步原语,均可用来保证共享数据的同步。不过仅有一对——Monitor.Enter()和Monitor.Exit()——在C#语言上得到了原生支持。Monitor.Enter()和Monitor.Exit()共同组成了一个临界区。临界区在保证同步方面的应用非常广泛,因此语言本身就对其提供了支持——lock()语句。这样,我们应该尽可能遵循语言设计者的意愿,让lock()作为保证同步的第一选择。
原因很简单:编译器生成的代码永远是一致的,而开发者则可能会犯错误。C#语言引入了lock关键字来控制多线程程序的同步操作。lock语句将生成与正确使用Monitor.Enter()和Monitor.Exit()同样的代码。此外,该关键字更见简单,且能够自动生成所需的可以安全处理异常的代码。
不过,在两种情况下,Monitor也能提供两种lock()无法实现的功能。首先,lock必须使用在同一个上下文中。也就是说,在使用lock时,你无法在一个上下文中进入锁定却在另一个上下文中退出。例如,无法在某个方法中进入Monitor,然后在该方法中的某个lambda表达式中退出(参见第5章条目41)。其次,Monitor.Enter支持制定一个超时时间,这一点将在稍后介绍。
按如下方式使用lock语句即可锁定某一引用类型。
public int TotalValue
{
get
{
lock(syncHandle)
{
return total;
}
}
}
public void IncrementTotal()
{
lock (syncHandle)
{
total++;
}
}
lock语句将独占地锁定某一对象,并确保在锁定被释放之前其他线程无法再次锁定。上述使用lock()的示例代码将生成与下面使用Monitor.Enter()和Monitor.Exit()代码同样的IL。
public void IncrementTotal()
{
object tmpObject = syncHandle;
System.Threading.Monitor.Enter(tmpObject);
try
{
total++;
}
finally
{
System.Threading.Monitor.Exit(tmpObject);
}
}
为了避免常见错误,lock语句还提供了很多检查,例如检查被锁定对象为引用类型。而Monitor.Enter则并没有包含这些检查。如下这段使用lock()的代码无法通过编译。
public void IncrementTotal()
{
lock (total) // 编译期错误:无法锁定值类型
{
total++;
}
}
不过如下代码则可以编译通过。
public void IncrementTotal()
{
// 并没有真正锁定住total
// 锁住的是total的一个装箱对象
Monitor.Enter(total);
try
{
total++;
}
finally
{
// 会抛出异常
// 释放了包含total的另一个装箱对象
Monitor.Exit(total);
}
}
之所以Monitor.Enter()可以通过编译,是因为其签名接受的是System. Object对象,因此程序将把total装箱成对象传入。这样,Monitor.Enter()实际上锁定的是total的一个装箱对象,因此埋下了一个潜在的bug。假设第一个线程进入到了IncrementTotal()中并获取了锁定。然后在对total进行操作时,第二个线程也调用了IncrementTotal()。这时,第二个线程依旧可以成功地获取锁定,因为total可被装箱成另外一个对象。第一个线程获取了total的一个装箱,第二个线程则获取了total的另一个装箱。可以看到,这样做不但增加了代码,也没有实现保证同步所需的要求。
这段代码中还有一个bug:在任意一个线程尝试释放total上的锁时,Mon- itor.Exit()均会抛出SynchronizationLockException异常。这是因为调用Monitor.Exit()时total又被装箱到了另外的一个新对象中,因为Monitor. Exit()接受的也是System.Object类型。在释放锁时,释放的对象和开始时锁定的对象并不是同一个,自然也就导致Monitor.Exit()调用失败并抛出异常。
或许有人聪明一些,想出了这样的做法。
public void IncrementTotal()
{
// 同样无法正常执行:
object lockHandle = total;
Monitor.Enter(lockHandle);
try
{
total++;
}
finally
{
Monitor.Exit(lockHandle);
}
}
虽然这段代码不会抛出异常,不过也不能保证共享数据的同步。每次调用IncrementTotal()时,均会为total创建一个新的装箱,并锁定该对象。这样,每个线程都能立即获取到所需要的锁,但是却没有锁定到任何共享的资源上。导致的结果就是,虽然每个线程都不会阻塞,但total却无法保持一致。
lock还能预防一些比较易于忽视的问题。Enter()和Exit()是两个独立的调用,很容易写错,导致获取和释放的是两个不同的对象。这将导致SynchronizationLockException。而若是需要锁定多个对象,那么也很有可能在临界区结束时释放了错误的对象。
lock语句还能够自动地生成能够安全处理异常的代码,而这些往往是开发者所忽视的。此外,它生成的代码也要比Monitor.Enter()和Monitor.Exit()更加高效,因为其只需要对目标对象进行一次求值。因此在默认情况下,我们应该在C#程序中尽可能地使用lock语句来保证同步性。
不过,lock语句所生成的MSIL也存在着局限:Monitor.Enter()将在获取到锁之前永远等待下去。这样就可能造成死锁。在大规模的企业系统中,访问关键资源的策略应该更加小心仔细、趋于保守。这时,使用Monitor.TryEnter()即可给出一个等待的超时时间,并给出无法访问到关键资源时的处理方法。
public void IncrementTotal()
{
if (!Monitor.TryEnter(syncHandle, 1000)) // 等待1s
throw new PreciousResourceException
("Could not enter critical section");
try
{
total++;
}
finally
{
Monitor.Exit(syncHandle);
}
}
还可使用泛型类对其进行简单包装。
public sealed class LockHolder
where T : class
{
private T handle;
private bool holdsLock;
public LockHolder(T handle, int milliSecondTimeout)
{
this.handle = handle;
holdsLock = System.Threading.Monitor.TryEnter(
handle, milliSecondTimeout);
}
public bool LockSuccessful
{
get { return holdsLock; }
}
#region IDisposable Members
public void Dispose()
{
if (holdsLock)
System.Threading.Monitor.Exit(handle);
// 不要重复释放
holdsLock = false;
}
#endregion
}
随后这样使用该泛型类。
object lockHandle = new object();
using (LockHolder
(lockHandle, 1000))
{
if (lockObj.LockSuccessful)
{
// 具体操作省略
}
}
// 在此处析构
之所以C#开发团队为Monitor.Enter()和Monitor.Exit()添加了语言级别的支持(即lock语句),是因为这是一种最常用的同步机制。编译器所做的额外检查也能让你更容易地编写出要求保证同步的代码。因此对于大多数C#应用程序来讲,lock()都是保证同步的最佳选择。
不过lock并不是同步的唯一选择。实际上,若是需要同步地访问某个值类型,或替换某个引用类型,那么System.Threading.Interlocked类型即可直接支持对象上的单一操作。System.Threading.Interlocked提供了一系列方法,可用来访问共享数据,并保证在其他线程访问该数据之前就完成上一次操作。Interlocked还能帮你预防操作共享数据时将会遇到的一些常见的同步问题。
例如如下方法:
public void IncrementTotal()
{
total++;
}
在这样的实现中,多线程访问可能会造成数据的不一致。因为自增操作符并不是单一的一条机器指令。total变量的值需要首先从内存读入到寄存器中,然后在寄存器中自增,最后再从寄存器写回到内存中的特定位置里。若是另外一个线程在第一个线程之后再次读取该变量,此时第一个线程已经在寄存器中完成了增加但尚未写回内存,就会造成数据的不一致。
假设两个线程几乎在同时调用了IncrementTotal。线程A读取了total的值为5。此时,活动线程切换到了线程B。于是线程B读取到了5,自增,然后把6写回到total中。这时活动线程又切换回了线程A。线程A将在寄存器中将数值自增到6,然后写回到total中。这样,虽然IncrementTotal()被调用了两次(线程A和线程B),不过结果是total仅仅自增了一次。此类问题很难发现,因为只有在非常凑巧的时候才会发生这类交叉访问的情况。
虽然可以使用lock()来保证同步,不过还有一种更好的办法。Inter- locked类提供了一个专门的InterlockedIncrement方法来解决这个问题。按照如下方法重写IncrementTotal,即可保证自增操作不会被打断,两次自增操作均会成功。
public void IncrementTotal()
{
System.Threading.Interlocked.Increment(ref total);
}
Interlocked类还提供了另外一些处理内建类型的方法。例如Interlo cked.Decrement()能够自减某个值,Interlocked.Exchange()能够将变量的值交换成新的值,并将原始值返回。你可以用Interlocked.Exchange()来设定一个新的状态,并将从前的状态返回。例如,若需要将最后一个访问某资源的用户ID保存起来,即可调用Interlocked.Exchange()来保存当前的用户ID,并同时获取到前一个访问的用户ID。
Interlocked还提供了CompareExchange()方法,用来读取某个共享的数据,随后判断若其与某一值相同的话,则赋以新值,否则不作任何操作。两种情况下CompareExchange都会返回从前的值。在下一节中,条目14将演示如何使用CompareExchange来在类中创建一个私有的锁对象。
同步原语中并不只包含Interlocked和lock()。Monitor类还提供了Pulse和Wait方法,可用来实现消费者/生产者模型。在很多线程读取某一资源,且很少线程修改该资源时,可以使用ReaderWriterLockSlim实现该设计。ReaderWriterLockSlim对早先版本的ReaderWriterLock做了一些改进,因此在开发中应该选用ReaderWriterLockSlim。
对于大多数同步问题,都可以先看看Interlocked是否能够满足你的需要。很多单一的操作都可以用它来实现。否则应尽可能地使用lock()语句。只有在确实需要某些特定的锁实现时,再考虑使用别的方法。
条目14:尽可能地减小锁对象的作用范围
在编写并发程序时,我们需要选择最合适的同步原语。应用程序中对同步原语使用得越多,也就越难以避免发生死锁或失锁等并发上的错误。这是个规模的问题:需要检查的地方越多,也就越难发现某个特定的错误。
在面向对象编程中,我们使用私有成员变量来尽可能减少(不是移除,而是减少)发生状态变化的位置的数量。在并发程序中,同样也应该尽可能地减小用来实现同步对象的作用范围。
若从上述角度来看,两种广泛应用的锁方式均不满足要求。lock(this)和lock(TypeOf (MyType))都使用了公共实例来创建锁对象。
若是你像下面这样编写代码:
public class LockingExample
{
public void MyMethod()
{
lock (this)
{
// 省略
}
}
// 省略
}
再假如你的某个客户——叫他亚历山大好了——说他需要锁住一个对象。于是亚历山大这样写了代码:
LockingExample x = new LockingExample();
lock (x)
x.MyMethod();
此类锁定策略很容易就造成了死锁。客户代码在LockingExample对象上获取了锁,而MyMethod中却又尝试在同一个对象上获取锁。虽然这里的问题很容易看出来,不过也许某一天,另一个线程又在其他什么地方锁定了该Locking- Example对象。这时发生的死锁将很难找到其原因。
我们需要改变锁定的策略,你可以采用下面将要介绍的三种方法。
第一种方法是,若你需要保护整个一个方法,那么可以使用MethodImpl- Attribute属性来指定该方法是同步的。
[MethodImpl(MethodImplOptions.Synchronized)]
public void IncrementTotal()
{
total++;
}
不过显然,这种需求并不常见。
第二种方法是,强制所有的开发者都必须仅锁定当前的类型或当前的对象,即使用lock(this)或lock(MyType)。若是每个人都遵守该规则的话,那么也不会出现什么问题。不过若想达到这个目标,需要你的程序的每个使用者都清楚地了解并遵守这个规则,即只能够锁定当前对象或当前类型,而不能是别的对象。这种理想的假设显然不够现实。
第三种则是最好的办法。你可以在类中创建一个同步对象,专门用来保护访问共享的资源。该同步对象是一个私有成员变量,因此无法在类型之外访问到。你也可以保证该同步对象为私有,且不能被任何非私有属性访问。这样即可确保锁定语句安全地锁定到指定的对象上。
通常,我们会创建一个System.Object对象作为同步对象。随后在类中访问需要保护的成员时即可锁定该同步对象。但在创建同步对象时需要小心,不要因为线程的交替执行创建出了多个同步对象的副本。Interlocked类的CompareExchange方法能够验证某个值,并在必要时替换成新值。我们可以使用该方法来保证类型中仅分配了一个同步对象。
下面就是第三种做法的最简单实现。
private object syncHandle = new object();
public void IncrementTotal()
{
lock (syncHandle)
{
// 代码省略
}
}
或许你会发现,程序并不需要经常地锁定,因此只要在需要锁定时创建同步对象即可。这时,创建锁定对象可采用如下的一种非常巧妙方式。
private object syncHandle;
private object GetSyncHandle()
{
System.Threading.Interlocked.CompareExchange(
ref syncHandle, new object(), null);
return syncHandle;
}
public void AnotherMethod()
{
lock (GetSyncHandle())
{
// 代码省略
}
}
syncHandle用来控制类中对共享资源的访问。私有的GetSyncHandle()方法将返回同步对象。而不能被打断的CompareExchange调用则保证了程序仅会创建出一个同步对象。CompareExchange首先比较syncHandle和null,若syncHandle的当前值为null,那么CompareExchange将创建一个新的对象,并将其指派给syncHandle。
这种做法适用于实例方法中的任何一种锁定,不过静态方法又该如何实现呢?仍旧使用类似的方法,不过创建的是一个静态的同步对象,从而让该类的所有实例都共用一个同步对象。
当然,你可以在方法(属性访问器或索引器也可)内的任意一段代码上创建同步区块。不过不管怎样,你都应该尽可能地减少被锁定的代码。
public void YetAnotherMethod()
{
DoStuffThatIsNotSynchronized();
int val = RetrieveValue();
lock (GetSyncHandle())
{
// 代码省略
}
DoSomeFinalStuff();
}
若是在lambda表达式中使用锁,那么必须小心处理。C#编译器将在lambda表达式外面创建一个闭包。该闭包和C# 3.0支持的延迟执行模型让开发者很难判断锁定何时才能结束,也就更容易发生死锁情况。因为开发者可能无法判断某段代码是否位于某个锁定区域内。
在结束这个话题之前,还有另外两个锁定相关的建议。若你发现需要在某个类中创建不同的锁对象,那么或许应该考虑将该类拆分成多个类。因为该类做的事情太多了。若是需要保护对某些变量的访问,同时也需要用其他的锁来保护类中其他的变量,那么则非常建议你将类拆成具有不同责任的类。若每个类都是一个独立的单元,那么将更容易保证一致性。每个拥有共享数据(将由不同线程访问或修改)的类都应该仅用一个同步对象来保证其一致性。
在考虑锁定时,应选择一个外部不可见的私有字段。不要锁定公共对象,因为锁定公共对象要求所有的开发者都永远遵循同样的规范,且非常容易导致死锁。
条目15:避免在锁定区域内调用外部代码
有些情况下,问题的出现是因为没有进行足够的锁定。不过当你再创建新的锁时,接下来却又可能发生死锁。当两个线程各持有了一个资源,同时也在等待对方的资源时,死锁自然难以避免。在.NET Framework中,有一种特殊情况是两个线程的执行几乎不分前后,同时进行。这时,虽然只有一个资源被锁定,但仍可能发生死锁。(条目16将介绍这种情况。)
我们已经介绍了一种避免这个问题的最简单方法:条目13使用一个私有的数据成员作为同步对象,从而锁定了共享的数据。不过即使这样,仍有可能会导致死锁。若是在某段同步区域中调用了外部代码,那么另外的线程也有可能引发死锁。
例如,使用如下代码来处理一个后台操作。
public class WorkerClass
{
public event EventHandler
private object syncHandle = new object();
public void DoWork()
{
for(int count = 0; count < 100; count++)
{
lock (syncHandle)
{
System.Threading.Thread.Sleep(100);
progressCounter++;
if (RaiseProgress != null)
RaiseProgress(this, EventArgs.Empty);
}
}
}
private int progressCounter = 0;
public int Progress
{
get
{
lock (syncHandle)
return progressCounter;
}
}
}
RaiseProgress()方法将通知所有的事件监听程序。所有的监听程序都可以注册并处理该事件。在多线程程序中,一个典型的事件处理程序将如下所示。
static void engine_RaiseProgress(object sender, EventArgs e)
{
WorkerClass engine = sender as WorkerClass;
if (engine != null)
Console.WriteLine(engine.Progress);
}
程序运行不会出现什么问题,不过这也是只因为幸运而已。之所以没有问题,是因为事件处理程序运行于后台线程中。
不过,假设该程序是一个Windows窗体应用程序,且你需要让事件处理程序在UI层上执行(参见条目16)。这可以使用Control.Invoke来实现。不仅如此,Control.Invoke还将阻塞原有线程,直到目标委托执行完毕为止。但这也不会出现什么问题——我们的操作运行于另一个线程上,因此不会导致死锁。
而第二个重要的操作却导致了整个程序的死锁。为了获取进度的详细情况,事件处理程序使用了engine对象。不过其Progress访问器目前却是运行在另一个线程上,因此无法获取同样的锁。
Progress访问器锁定了该同步对象。在本地上下文中,这没什么问题,不过实际并非如此。UI线程将需要锁定这个已经在后台线程中被锁定的同步对象。不过后台线程却也处于阻塞状态中,等待事件处理程序返回,同时后台线程已经锁定了该同步对象。这就难以避免地发生了死锁。
表2-1给出了调用栈。可以看到查出此类问题并不容易。调用栈中,在第一个锁定和第二次尝试锁定之间有8种方法。而且,这些线程的交替执行均在框架内部发生,在真正发生问题时,你甚至无法看到这些详细的信息。
表2-1 用来更新窗体显示的前后台线程执行过程的调用栈
方 法 |
线 程 |
DoWork |
BackgroundThread |
raiseProgress |
BackgroundThread |
OnUpdateProgress |
BackgroundThread |
engine_OnUpdateProgress |
BackgroundThread |
Control.Invoke |
BackgroundThread |
UpdateUI |
UIThread |
Progress (property access) |
UIThread (deadlock) |
核心问题是代码需要再次获取一个锁。因为你不知道控件外部的代码将如何工作,所以应该尽量避免在同步区域内调用外部代码。在这个示例中,这就意味着你必须在同步区域之外触发进度报告事件。
public void DoWork()
{
for(int count = 0; count < 100; count++)
{
lock (syncHandle)
{
System.Threading.Thread.Sleep(100);
progressCounter++;
}
if (RaiseProgress != null)
RaiseProgress(this, EventArgs.Empty);
}
}
既然你已经看到了问题的所在,那么现在有必要让你完全理解调用未知代码所可能给应用程序带来的影响了。显然,触发公开的访问事件将调用外部代码。使用以参数形式传入或通过公开API设定的委托将调用外部代码。使用以参数形式传入的lambda表达式也可能会调用到外部代码(参见第5章条目40)。
此类外部代码很容易发现,不过还有一个未知却并不那么容易找到:虚方法。虚方法调用的可能是派生类的重写版本,而这个重写的类中则可能调用任意的方法,也就有可能导致死锁。
无论具体是什么情况,问题的成因都是相似的。你的类首先获取了一个锁。随后在同步区域内,调用了外部代码。该外部代码有可能会最终调用回你的类中,甚至在另外的线程上。你无法确保这些外部代码不做任何有害的事情。因此则必须从源头上杜绝:不要在代码的同步区域内调用外部代码。
条目16:理解Windows窗体和WPF中的跨线程调用
若你曾开发过Windows窗体程序,可能会注意到有时事件处理程序将抛出InvalidOperationException异常,信息为“跨线程调用非法:在非创建控件的线程上访问该控件”。这种Windows窗体应用程序中跨线程调用时的一个最为奇怪的行为就是,有些时候它没什么问题,可有些时候却会出现问题。在WPF(Windows Presentation Foundation)中,这个行为有所改变。WPF中跨线程调用将永远不会成功。不管怎样,至少这能让你在开发过程中更容易地找到问题的所在。
在Windows窗体中,解决方法是首先检查Control.InvokeRequired属性,若Control. InvokeRequired属性为true,那么调用ControlInvoke()。在WPF中,可以使用System.Windows.Threading.Dispatcher中的Invoke()和BeginInvoke()方法。这两种情况中都发生了很多事情,你也同样有别的选择。这两个API为你做了很多事情,不过在某些情况下仍有可能会失败。因为这些方法将用来处理跨线程调用,因此若是没有正确使用(甚至是正确使用但没有完全理解其行为)的话,也有可能会导致竞争条件的出现。
无论是Windows窗体还是WPF,问题的成因都很简单:Windows控件使用的是组件对象模型(Component Object Model,COM)单线程单元(Single-threaded Apartment,STA)模型,因为其底层的控件是单元线程(apartment-threaded)的。此外,很多控件都用消息泵(message pump)来完成操作。因此,这种模型就需要所有调用该控件的方法都和创建该控件的方法位于同一个线程上。Invoke、BeginInvoke和EndInvoke调度方法都需要在正确的线程上调用。两种模型的底层代码非常相似,因此这里将以Windows窗体的API为例。不过当调用方法有所区别时,我将同时给出两个版本。其具体的做法非常复杂,但仍需要深入了解。
首先,我们来看一段简单的泛型代码,能够让你在遇到此种情况时得到一定的简化。匿名委托让仅在一处使用的小方法更加易于编写。不过,匿名委托却并不能与接受System.Delegate类型的方法(例如Control.Invoke)配合使用。因此,你需要首先定义一个非抽象的委托类型,随后在使用Control. Invoke时传入。
private void OnTick(object sender, EventArgs e)
{
Action action = () =>
toolStripStatusLabel1.Text =
DateTime.Now.ToLongTimeString();
if (this.InvokeRequired)
this.Invoke(action);
else
action();
}
C# 3.0大大简化了上述代码。System.Core.Action委托定义了一类专门的委托类型,用来表示不接受任何参数并返回void的方法。lambda表达式也能够更加简单地定义方法体。但若你仍旧需要支持C# 2.0,那么需要编写如下的代码。
delegate void Invoker();
private void OnTick20(object sender, EventArgs e)
{
Action action = delegate()
{
toolStripStatusLabel1.Text =
DateTime.Now.ToLongTimeString();
};
if (this.InvokeRequired)
this.Invoke(action);
else
action();
}
在WPF中,则需要使用控件上的System.Threading.Dispatcher对象来执行封送操作。
private void UpdateTime()
{
Action action = () => textBlock1.Text =
DateTime.Now.ToString();
if (System.Threading.Thread.CurrentThread !=
textBlock1.Dispatcher.Thread)
{
textBlock1.Dispatcher.Invoke
(System.Windows.Threading.DispatcherPriority.Normal,
action);
}
else
{
action();
}
}
这种做法让事件处理程序的实际逻辑变得更加模糊,让代码难以阅读和维护。这种做法还需要引入一个委托定义,仅仅用来满足方法的签名。
使用一小段泛型代码即可改善这种情况。下面的这个ControlExtensions静态类所包含的泛型方法适用于调用不超过两个参数的委托。再添加一些重载即可支持更多的参数。此外,其中的方法还可使用委托定义来调用目标方法,既可以直接调用,也可以通过Control.Invoke的封送。
public static class ControlExtensions
{
public static void InvokeIfNeeded(this Control ctl,
Action doit)
{
if (ctl.InvokeRequired)
ctl.Invoke(doit);
else
doit();
}
public static void InvokeIfNeeded
Action
{
if (ctl.InvokeRequired)
ctl.Invoke(doit, args);
else
doit(args);
}
}
在多线程环境中使用InvokeIfNeeded能够很大程度上简化事件处理程序的代码。
private void OnTick(object sender, EventArgs e)
{
this.InvokeIfNeeded(() => toolStripStatusLabel1.Text =
DateTime.Now.ToLongTimeString());
}
对于WPF控件,也可以创建出一系列类似的扩展。
public static class WPFControlExtensions
{
public static void InvokeIfNeeded(
this System.Windows.Threading.DispatcherObject ctl,
Action doit,
System.Windows.Threading.DispatcherPriority priority)
{
if (System.Threading.Thread.CurrentThread !=
ctl.Dispatcher.Thread)
{
ctl.Dispatcher.Invoke(priority,
doit);
}
else
{
doit();
}
}
public static void InvokeIfNeeded
this System.Windows.Threading.DispatcherObject ctl,
Action
T args,
System.Windows.Threading.DispatcherPriority priority)
{
if (System.Threading.Thread.CurrentThread !=
ctl.Dispatcher.Thread)
{
ctl.Dispatcher.Invoke(priority,
doit, args);
}
else
{
doit(args);
}
}
}
WPF版本没有检查InvokeRequired,而是检查了当前线程的标识,并于将要进行控件交互的线程进行比较。DispatcherObject是很多WPF控件的基类,用来为WPF控件处理线程之间的分发操作。注意,在WPF中还可以指定事件处理程序的优先级。这是因为WPF应用程序使用了两个UI线程。一个线程用来专门处理UI呈现,以便让UI总是能够及时呈现出动画等效果。你可以通过指定优先级来告诉框架哪类操作对于用户更加重要:要么是UI呈现,要么是处理某些特定的后台事件。
这段代码有几个优势。虽然使用了匿名委托定义,不过事件处理程序的核心仍位于事件处理程序中。与直接使用Control.IsInvokeRequired或ControlInvoke相比,这种做法更加易读且易于维护。在ControlExtensions中,使用了泛型方法来检查InvokeRequired或是比较两个线程,这也就让使用者从中解脱了起来。若是代码仅在单线程应用程序中使用,那么我也不会使用这些方法。不过若是程序最终可能在多线程环境中运行,那么不如使用上面这种更加完善的处理方式。
若想支持C# 2.0,那么还要做一些额外的工作。主要在于无法使用扩展方法和lambda表达式语法。这样,代码将变得有些臃肿。
// 定义必要的Action:
public delegate void Action;
public delegate void Action
// 3个和4个参数的Action定义省略
public static class ControlExtensions
{
public static void InvokeIfNeeded(Control ctl, Action doit)
{
if (ctl.InvokeRequired)
ctl.Invoke(doit);
else
doit();
}
public static void InvokeIfNeeded
Action
{
if (ctl.InvokeRequired)
ctl.Invoke(doit, args);
else
doit(args);
}
}
// 其他位置:
private void OnTick20(object sender, EventArgs e)
{
ControlExtensions.InvokeIfNeeded(this, delegate()
{
toolStripStatusLabel1.Text =
DateTime.Now.ToLongTimeString();
});
}
在将这个方法应用到事件处理程序之前,我们来仔细看看InvokeRequired和Control.Invoke所做的工作。这两个方法并非没有什么代价,也不建议将这种模式应用到各处。Control.InvokeRequired用来判断当前代码是运行于创建该控件的线程之上,还是运行于另一个线程之上。若是运行于另一个线程之上,那么则需要使用封送。大多数情况下,这个属性的实现还算简单:只要检查当前线程的ID,并与创建该控件的线程ID进行比较即可。若二者匹配,那么则无需Invoke,否则就需要Invoke。这个比较并不需要花费太多时间,WPF版本的这类扩展方法也是执行了同样的检查。
不过其中还有一些边缘情况。若需要判断的控件还没有被创建,在父控件已创建好,正在创建子控件时就可能发生这个情况。那么此时,虽然C#对象已经存在,不过其底层的窗口句柄仍旧为null。此时也就无法进行比较,因此框架本身将花费一定代价来处理这种情况。框架将沿着控件树向上寻找,看看是否有上层控件已被创建。若是框架能够找到一个创建好了的窗体,那么该窗体将作为封送窗体。这是一个非常合理的假设,因为父控件将要负责创建子控件。这种做法可以保证子控件将会与父控件在同一个线程上创建。找到合适的父控件之后,框架即可执行同样的检查,比较当前线程的ID和创建该父控件的线程的ID。
不过,若是框架无法找到任何一个已创建的父窗体,那么则需要找到一些其他类型的窗体。若在层次体系中无法找到可用的窗体,那么框架将开始寻找暂存窗体(parking window),暂存窗体让你不会被某些Win32 API奇怪的行为所干扰。简而言之,有些对窗体的修改(例如修改某些样式)需要销毁并重新创建该窗体。暂存窗体就是用来在父窗体被销毁并重新创建的过程中用来临时保存其中的控件的。在这段时间内,UI线程仅运行于暂存窗体中。
在WPF中,得益于Dispatcher类的使用,上述很多过程都得到了简化。每个线程都有一个Dispatcher。在第一次访问某个控件的Dispatcher时,类库将察看该线程是否已经拥有了Dispatcher。若已经存在,那么直接返回。如果没有的话,那么将创建一个新的Dispatcher对象,并关联在控件及其所在的线程之上。
不过这其中仍旧有可能存在着漏洞和发生失败。有可能所有的窗体,包括暂存窗体都没有被创建。在这种情况下,InvokeRequired将返回false,表示无需将调用封送到另一个线程上。这种情况可能会比较危险,因为这个假设可能是错误的,但框架也仅能做到如此了。任何需要访问窗体句柄的方法都无法成功执行,因为现在还没有任何窗体。此外,封送也自然会失败。若是框架无法找到任何可以封送的控件,自然也无法将当前调用封送到UI线程上。于是框架选择了一个可能在稍后出现的失败,而不是当前会立即出现的失败。幸运的是,这种情况在实际中非常少见。不过在WPF中,Dispatcher还是包含了额外的代码来预防这种情况。
总结一下InvokeRequired的相关内容。一旦控件创建完成,那么InvokeRequired的效率将会不错,且也能保证安全。不过若是目标控件尚未被创建,那么InvokeRequired则可能会耗费比较长的时间。而若是没有创建好任何控件,那么InvokeRequired则可能要相当长的时间,同时其结论也无法保证正确。但虽然Control.InvokeRequired有可能耗时较长,也比非必要地调用Control.Invoke要高效得多。且在WPF中,很多边缘情况都得到了优化,性能要比Windows窗体的实现提高不少。
接下来看看Control.Invoke的执行过程。(Control.Invoke的执行非常复杂,因此这里将仅做简要介绍。)首先,有一个特殊情况是虽然调用了Invoke方法,不过当前线程却和控件的创建线程一样。这是个最为简单的特例,框架将直接调用委托。即当InvokeRequired返回false时仍旧调用Control.Invoke()将会有微小的损耗,不过仍旧是安全的。
在真正需要调用Invoke时会发生一些有趣的情况。Control.Invoke能够通过将消息发送至目标控件的消息队列来实现跨线程调用。Control.Invoke还创建了一个专门的结构,其中包含了调用委托所需要的所有信息,包括所有的参数、调用栈以及委托的目标等。参数均会被预先复制出来,以避免在调用目标委托之前被修改(记住这是在多线程的世界中)。
在创建好这个结构并添加到队列中之后,Control.Invoke将向目标对象发送一条消息。Control.Invoke随后将在等待UI线程处理消息并调用委托时组合使用旋转等待(spin wait)和休眠。这部分的处理包含了一个重要的时间问题。当目标控件开始处理Invoke消息时,它并不会仅仅执行一个委托,而是处理掉队列中所有的委托。若你使用的是Control.Invoke的同步版本,那么不会看到任何效果。不过若是混合使用了Control.Invoke和Control.BeginInvoke,那么行为将有所不同。这部分内容将在稍后继续介绍,目前需要了解的是,控件的WndProc将在开始处理消息时处理掉每一个等待中的Invoke消息。对于WPF,可控制的要多一些,因为可以指定异步操作的优先级。你可以让Dispatcher将消息放在队列中时给出三种优先级:(1)基于系统或应用程序的当前状况;(2)使用普通优先级;(3)高优先级。
当然,这些委托中可能会抛出异常,且异常无法跨线程传递。因此框架将把对委托的调用用try/catch包围起来并捕获所有的异常。随后在UI线程完成处理之后,其中发生的异常将被复制到专门的数据结构中,供原线程分析。
在UI线程处理结束之后,Control.Invoke将察看UI线程中抛出的所有异常。如果确有异常发生,那么将在后台线程中重新抛出。若没有异常,那么将继续进行普通的处理。可以看到,调用一个方法的过程并不简单。
Control.Invoke将在执行封送调用时阻塞后台线程,虽然实际上在多线程环境中运行,不过仍旧让人觉得是同步的行为。
不过这可能不是你所期待的。很多时候,你希望让工作线程触发一个事件之后继续进行下面的操作,而不是同步地等待UI。这时则应该使用BeginInvoke。该方法的功能和Control.Invoke基本相同,不过在向目标控件发送消息之后,BeginInvoke将立即返回,而不是等待目标委托完成。BeginInvoke支持发送消息(可能在稍后才会处理)后立即返回到调用线程上。你可以根据需要为ControlExtensions类添加相应的异步方法,以便简化异步跨线程UI调用的操作。虽然与前面的那些方法相比,这些方法带来的优势不那么明显,不过为了保持一致,我们还是在ControlExtensions中给出。
public static void QueueInvoke(this Control ctl, Action doit)
{
ctl.BeginInvoke(doit);
}
public static void QueueInvoke
Action
{
ctl.BeginInvoke(doit, args);
}
QueueInvoke并没有在一开始检查InvokeRequired。这是因为即使当前已经运行于UI线程之上,你仍可能想要异步地调用方法。BeginInvoke()就实现了这个功能。Control.BeginInvoke将消息发送至目标控件,然后返回。随后目标控件将在其下一次检查消息队列时处理该消息。若是在UI线程中调用的BeginInvoke,那么实际上这并不是异步的:当前操作后就会立即执行该调用。
这里我忽略了BeginInvoke所返回的Asynch结果对象。实际上,UI更新很少带有返回值。这会大大简化异步处理消息的过程。只需简单地调用BeginInvoke,然后等待委托在稍后的某个时候执行即可。但编写委托方法时需要格外小心,因为所有的异常都会在跨线程封送中被默认捕获。
在结束这个条目之前,我再来简单介绍一下控件的WndProc。当WndProc接收到了Invoke消息之后,将执行InvokeQueue中的每一个委托。若是希望按照特定的顺序处理事件,且你还混合使用了Invoke和BeginInvoke,那么可能会在时间上出现问题。可以保证的是,使用Control. BeginInvoke或Control.Invoke调用的委托将按照其发出的顺序执行。BeginInvoke仅仅会在队列中添加一个委托。不过稍后的任意一个Control.Invoke调用均会让控件开始处理队列中所有的消息,包括先前由BeginInvoke添加的委托。“稍后的某一时间”处理委托意味着你无法控制“稍后的某一事件”到底是何时。“现在”处理委托则意味着应用程序先执行所有等待的异步委托,然后处理当前的这一个。很有可能的是,某个由BeginInvoke发出的异步委托将在Invoke委托调用之前改变了程序的状态。因此需要小心地编写代码,确保在委托中重新检查程序的状态,而不是依赖于调用Control.Invoke时传入的状态。
简单举例,如下版本的事件处理程序很难显示出那段额外的文字。
private void OnTick(object sender, EventArgs e)
{
this.InvokeAsynch(() => toolStripStatusLabel1.Text =
DateTime.Now.ToLongTimeString());
toolStripStatusLabel1.Text += " And set more stuff";
}
这是因为第一个修改会被暂存于队列中,随后在开始处理接下来的消息时才会修改文字。而此时,第二条语句已经给标签添加了额外的文字。
Invoke和InvokeRequired为你默默地做了很多的工作。这些工作都是必需的,因为Windows窗体控件构建于STA模型之上。这个行为在最新的WPF中依旧存在。在所有最新的.NET Framework代码之下,原有的Win32 API并没有什么变化。因此这类消息传递以及线程封送仍旧可能导致意料之外的行为。你必须对这些方法的工作原理及其行为有着充分的理解。