C#中的多线程-同步基础
C#中的多线程 - 同步基础
1同步概要
在第 1 部分:基础知识中,我们描述了如何在线程上启动任务、配置线程以及双向传递数据。同时也说明了局部变量对于线程来说是私有的,以及引⽤是如何在线程之间共享,允许其通过公共字段进⾏通信。
下⼀步是同步(synchronization):为期望的结果协调线程的⾏为。当多个线程访问同⼀个数据时,同步尤其重要,但是这是⼀件⾮常容易搞砸的事情。
同步构造可以分为以下四类:
简单的阻塞⽅法
这些⽅法会使当前线程等待另⼀个线程结束或是⾃⼰等待⼀段时间。Sleep、Join与Task.Wait都是简单的阻塞⽅法。
锁构造
锁构造能够限制每次可以执⾏某些动作或是执⾏某段代码的线程数量。排它锁构造是最常见的,它每次只允许⼀个线程执⾏,从⽽可以使得参与竞争的线程在访问公共数据时不会彼此⼲扰。标准的排它锁构造是lock(Monitor.Enter/Monitor.Exit)、Mutex
与 SpinLock。⾮排它锁构造是Semaphore、SemaphoreSlim以及读写锁。
信号构造
信号构造可以使⼀个线程暂停,直到接收到另⼀个线程的通知,避免了低效的轮询。有两种经常使⽤的信号设施:事件等待句柄(event wait handle )和Monitor类的Wait / Pluse⽅法。Framework 4.0 加⼊了CountdownEvent与Barrier类。
⾮阻塞同步构造
⾮阻塞同步构造通过调⽤处理器指令来保护对公共字段的访问。CLR 与 C# 提供了下列⾮阻塞构造:Thread.MemoryBarrier 、Thread.VolatileRead、Thread.VolatileWrite、volatile关键字以及Interlocked类。
阻塞这个概念对于前三类来说都⾮常重要,接下来我们简要的剖析下它。
1.1阻塞
当线程的执⾏由于某些原因被暂停,⽐如调⽤Sleep等待⼀段时间,或者通过Join或EndInvoke⽅法等待其它线程结束时,则认为此线程被阻塞(blocked)。被阻塞的线程会⽴即出让(yields)其处理器时间⽚,之后不再消耗处理器时间,直到阻塞条件被满⾜。可以通过线程的ThreadState属性来检查⼀个线程是否被阻塞:
bool blocked = (someThread.ThreadState & ThreadState.WaitSleepJoin) != 0;
(上⾯例⼦中线程状态可能在进⾏状态判断和依据状态进⾏操作之间发⽣改变,因此这段代码仅可⽤于调试诊断的场景。)
当⼀个线程被阻塞或是解除阻塞时,操作系统会进⾏上下⽂切换(context switch),这会带来⼏微秒的额外时间开销。
阻塞会在以下 4 种情况下解除(电源按钮可不能算╮(╯▽╰)╭):
阻塞条件被满⾜
操作超时(如果指定了超时时间)
通过Thread.Interrupt中断
通过Thread.Abort中⽌
通过Suspend⽅法(已过时,不应该再使⽤)暂停线程的执⾏不被认为是阻塞。
1.2阻塞 vs ⾃旋
有时线程必须暂停,直到特定条件被满⾜。信号构造和锁构造可以通过在条件被满⾜前阻塞线程来实现。但是还有⼀种更为简单的⽅法:线程可以通过⾃旋(spinning)来等待条件被满⾜。例如:
while (!proceed);
⼀般来说,这会⾮常浪费处理器时间:因为对 CLR 和操作系统来说,这个线程正在执⾏重要的计算,就给它分配了相应的资源。
有时会组合使⽤阻塞与⾃旋:
while (!proceed)
Thread.Sleep (10);
尽管并不优雅,但是这⽐仅使⽤⾃旋更⾼效(⼀般来说)。然⽽这样也可能会出现问题,这是由procee
d标识上的并发问题引起的。正确的使⽤和锁构造和信号构造可以避免这个问题。
⾃旋在等待的条件很快(⼤致⼏微秒)就能被满⾜的情况下更⾼效,因为它避免了上下⽂切换带来的额外开销。.NET Framework 提供了专门的⽅法和类型来辅助实现⾃旋,在第 5 部分会讲到。
1.3线程状态
可以通过线程的ThreadState属性来查询线程状态,它会返回⼀个ThreadState类型的按位⽅式组合的枚举值,其中包含了三“层”信息。然⽽⼤多数值都是冗余的、⽆⽤的或者过时不建议使⽤的。下图是其中⼀“层”信息:
下⾯的代码可以提取线程状态中最有⽤的 4 个值: Unstarted、Running、WaitSleepJoin和Stopped:
public static ThreadState SimpleThreadState (ThreadState ts){ return ts & (ThreadState.Unstarted | ThreadState.WaitSleepJoin | ThreadState.Stopped);}
ThreadState属性在进⾏调试诊断时有⽤,但不适合⽤来进⾏同步,因为线程状态可能在判断状态和依据状态进⾏操作之间发⽣改变。
2锁
排它锁⽤于确保同⼀时间只允许⼀个线程执⾏指定的代码段。主要的两个排它锁构造是lock和Mutex(互斥体)。其中lock更快,使⽤也更⽅便。⽽Mutex的优势是它可以跨进程的使⽤。
在这⼀节⾥,我们从介绍lock构造开始,然后介绍Mutex和信号量(semaphore)(⽤于⾮排它场景)。稍后在第 4 部分会介绍读写锁
(reader / writer lock)。
Framework 4.0 加⼊了SpinLock结构体,可以⽤于⾼并发场景。
让我们从下边这个类开始:
class ThreadUnsafe{ static int _val1 = 1, _val2 = 1; static void Go() { if (_val2 != 0) Console.WriteLine (_val1 / _val2); _val2 = 0; }}
这个类不是线程安全的:如果Go⽅法同时被两个线程调⽤,可能会产⽣除数为零错误,因为可能在⼀个线程刚好执⾏完if的判断语句但还没执⾏Console.WriteLine语句时,_val2就被另⼀个线程设置为零。
下边使⽤lock解决这个问题:
class ThreadSafe{ static readonly object _locker = new object(); static int _val1, _val2; static void Go() { lock (_locker) {
if (_val2 != 0) Console.WriteLine (_val1 / _val2); _val2 = 0; } }}
同⼀时间只有⼀个线程可以锁定同步对象(这⾥指_locker),并且其它竞争锁的线程会被阻塞,直到锁被释放。如果有多个线程在竞争锁,它们会在⼀个“就绪队列(ready queue)”中排队,并且遵循先到先得的规则(需要说明的是,Windows 系统和 CLR 的差别可能导致这个队列在有时会不遵循这个规则)。因为⼀个线程的访问不能与另⼀个线程相重叠,排它锁有时也被这样描述:它强制对锁保护的内容进⾏顺序(serialized)访问。在这个例⼦中,我们保护的是Go⽅法的内部逻辑,还有_val1与_val2字段。
在竞争锁时被阻塞的线程,它的线程状态是WaitSleepJoin。在中断与中⽌中,我们会描述如何通过其它线程强制释放被阻塞的线程,这是⼀种可以⽤于结束线程的重型技术(译者注:这⾥指它们应该被作为在没有其它更为优雅的办法时的最后⼿段)。
锁构造⽐较Permalink
构造⽤途跨进程开销*
lock (Monitor.Enter/Monitor.Exit)
确保同⼀时间只有⼀个线程可以访问资源或代码-20ns
Mutex1000ns
SemaphoreSlim (Framework 4.0 中加⼊)
确保只有不超过指定数量的线程可以并发访问资源或代码-200ns
Semaphore1000ns
ReaderWriterLockSlim (Framework 3.5 中加⼊)
允许多个读线程和⼀个写线程共存-40ns
ReaderWriterLock (已过时)-100ns
* 时间代表在同⼀线程上⼀次进⾏加锁和释放锁(假设没有阻塞)的开销,在 Intel Core i7 860 上测得。
2.1Monitor.Enter 与 Monitor.Exit
C# 的lock语句是⼀个语法糖,它其实就是使⽤了try / finally来调⽤Monitor.Enter与Monitor.Exit⽅法。下⾯是在之前⽰例中的Go⽅法内部所发⽣的事情(简化的版本):
Monitor.Enter (_locker);try{ if (_val2 != 0) Console.WriteLine (_val1 / _val2); _val2 = 0;}finally { Monitor.Exit (_locker); }
如果在同⼀个对象上没有先调⽤Monitor.Enter就调⽤Monitor.Exit会抛出⼀个异常。
lockTaken 重载
刚刚所描述的就是 C# 1.0、2.0 和 3.0 的编译器翻译lock语句产⽣的代码。
然⽽它有⼀个潜在的缺陷。考虑这样的情况:在Monitor.Enter的实现内部或者在Monitor.Enter与try中间有异常被抛出(可能是因为在线程上调⽤了Abort,或者有OutOfMemoryException异常被抛出),这时不⼀定能够获得锁。如果获得了锁,那么该锁就不会被释放,因为不可能执⾏到try / finally内,这会导致锁泄漏。
为了避免这种危险,CLR 4.0 的设计者为Monitor.Enter添加了下⾯的重载:
public static void Enter (object obj, ref bool lockTaken);
当(且仅当)Enter⽅法抛出异常,锁没有能够获得时,lockTaken为false。
下边是正确的使⽤⽅式(这就是 C# 4.0 对于lock语句的翻译):
bool lockTaken = false;try{ Monitor.Enter (_locker, ref lockTaken); // 你的代码...}finally { if (lockTaken) Monitor.Exit (_locker); }
TryEnter
Monitor还提供了⼀个TryEnter⽅法,允许以毫秒或是TimeSpan⽅式指定超时时间。如果获得了锁,该⽅法会返回true,⽽如果由于超时没有获得锁,则会返回false。TryEnter也可以以⽆参数的形式进⾏调⽤,这是对锁进⾏“测试”,如果不能⽴即获得锁就会⽴即返回false。
类似于Enter⽅法,该⽅法在 CLR 4.0 中也被重载来接受lockTaken参数。
2.2选择同步对象
对所有参与同步的线程可见的任何对象都可以被当作同步对象使⽤,但有⼀个硬性规定:同步对象必须为引⽤类型。同步对象⼀般是私有的(因为这有助于封装锁逻辑),并且⼀般是⼀个实例或静态字段。同步对象也可以就是其要保护的对象,如下⾯例⼦中的_list字段:
class ThreadSafe{ List <string> _list = new List <string>(); void Test() { lock (_list) { _list.Add ("Item 1"); // ...
⼀个只被⽤来加锁的字段(例如前⾯例⼦中的_locker)可以精确控制锁的作⽤域与粒度。对象⾃⼰(this),甚⾄是其类型都可以被当作同步对象来使⽤:
lock (this) { ... }// 或者:lock (typeof (Widget)) { ... } // 保护对静态资源的访问
这种⽅式的缺点在于并没有对锁逻辑进⾏封装,从⽽很难避免死锁与过多的阻塞。同时类型上的锁也可能会跨越应⽤程序域
(application domain)边界(在同⼀进程内)。
你也可以在被 lambda 表达式或匿名⽅法所捕获的局部变量上加锁。
锁在任何情况下都不会限制对同步对象本⾝的访问。换句话说,x.ToString()不会因为其它线程调⽤lock(x)⽽阻塞,两个线程都要调⽤lock(x)才能使阻塞发⽣。
2.3何时加锁
简单的原则是,需要在访问任意可写的共享字段(any writable shared field)时加锁。即使是最简单的操作,例如对⼀个字段的赋值操作,都必须考虑同步。在下⾯的类中,Increment与Assign⽅法都不是线程安全的:
class ThreadUnsafe{ static int _x; static void Increment() { _x++; } static void Assign() { _x = 123; }}
以下是线程安全的版本:
class ThreadSafe{ static readonly object _locker = new object(); static int _x; static void Increment() { lock (_locker) _x++; }
static void Assign() { lock (_locker) _x = 123; }}
在⾮阻塞同步(nonblocking synchronization)中,我们会解释这种需求是如何产⽣的,以及在这些场景下内存屏障(memory barrier,内存栅栏,内存栅障)和Interlocked类如何提供替代⽅法进⾏锁定。
2.4锁与原⼦性
如果⼀组变量总是在相同的锁内进⾏读写,就可以称为原⼦的(atomically)读写。假定字段x与y总是在对locker对象的lock内进⾏读取与赋值:
lock (locker) { if (x != 0) y /= x; }
可以说x和y是被原⼦的访问的,因为上⾯的代码块⽆法被其它的线程分割或抢占。如果被其它线程分割或抢占,x和y就可能被别的线程修改导致计算结果⽆效。⽽现在 x和y总是在相同的排它锁中进⾏访问,因此不会出现除数为零的错误。
在lock锁内抛出异常将打破锁的原⼦性,考虑如下代码:
decimal _savingsBalance, _checkBalance;void Transfer (decimal amount){ lock (_locker) { _savingsBalance += amount;
_checkBalance -= amount + GetBankFee(); }}
如果GetBankFee()⽅法内抛出异常,银⾏可能就要损失钱财了。在这个例⼦中,我们可以通过更早的调⽤GetBankFee()来避免这个问题。对于更复杂情况,解决⽅案是在catch或finally中实现“回滚(rollback)”逻辑。
指令原⼦性是⼀个相似但不同的概念:如果⼀条指令可以在 CPU 上不可分割地执⾏,那么它就是原⼦的。(见⾮阻塞同步)
2.5嵌套锁
线程可以⽤嵌套(重⼊)的⽅式重对相同的对象进⾏加锁:
lock (locker) lock (locker) lock (locker) { // ... }spring framework表达式assign
或者:
Monitor.Enter (locker);
Monitor.Enter (locker);
Monitor.Enter (locker);
Monitor.Exit (locker);
Monitor.Exit (locker);
Monitor.Exit (locker);
在这样的场景中,只有当最外层的lock语句退出或是执⾏了匹配数⽬的Monitor.Exit语句时,对象才会被解锁。
嵌套锁可以⽤于在锁中调⽤另⼀个⽅法(也使⽤了同⼀对象来锁定):
static readonly object _locker = new object();
static void Main(){ lock (_locker) { AnotherMethod(); // 这⾥依然拥有锁,因为锁是可重⼊的 }}static void AnotherMethod(){
lock (_locker) { Console.WriteLine ("Another method"); }}
线程只会在第⼀个(最外层)lock处阻塞。
2.6死锁
当两个线程等待的资源都被对⽅占⽤时,它们都⽆法执⾏,这就产⽣了死锁。演⽰死锁最简单的⽅法就是使⽤两个锁:
object locker1 = new object();object locker2 = new object();new Thread (() => { lock (locker1) {
Thread.Sleep (1000); lock (locker2); // 死锁 } }).Start();lock (locker2){
Thread.Sleep (1000); lock (locker1); // 死锁}
更复杂的死锁链可能由三个或更多的线程创建。
在标准环境下,CLR 不会像SQL Server⼀样⾃动检测和解决死锁。除⾮你指定了锁定的超时时间,否则死锁会造成参与的线程⽆限阻塞。(在SQL CLR 集成宿主环境中,死锁能够被⾃动检测,并在其中⼀个线程上抛出可捕获的异常。)
死锁是多线程中最难解决的问题之⼀,尤其是在有很多关联对象的时候。这个困难在根本上在于⽆法确定调⽤⽅(caller)已经拥有了哪些锁。
你可能会锁定类x中的私有字段a,⽽并不知道调⽤⽅(或者调⽤⽅的调⽤⽅)已经锁住了类y中的字段b。同时,另⼀个线程正在执⾏顺序相反的操作,这样就创建了死锁。讽刺的是,这个问题会由于(良好的)⾯向对象的设计模式⽽加剧,因为这类模式建⽴的调⽤链直到运⾏时才能确定。
流⾏的建议:“以⼀致的顺序对对象加锁以避免死锁”,尽管它对于我们最初的例⼦有帮助,但是很难应⽤到刚才所描述的场景。更好的策略是:如果发现在锁区域中的对其它类的⽅法调⽤最终会引⽤回当前对象,就应该⼩⼼,同时考虑是否真的需要对其它类的⽅法调⽤加锁(往往是需要的,但是有时也会有其它选择)。更多的依靠声明⽅式(declarative)与数据并⾏(data parallelism)、不可变类型(immutable types)与⾮阻塞同步构造( nonblocking synchronization constructs),可以减少对锁的需要。
有另⼀种思路来帮助理解这个问题:当你在拥有锁的情况下访问其它类的代码,对于锁的封装就存在潜在的泄露。这不
是 CLR 或 .NET Framework 的问题,⽽是因为锁本⾝的局限性。锁的问题在许多研究项⽬中被分析,包括软件事务内存
(Software Transactional Memory)。
另⼀个死锁的场景是:如果已拥有⼀个锁,在调⽤Dispatcher.Invoke(在 WPF 程序中)或是Control.Invoke(在 Windows Forms 程序中)时,如果 UI 恰好要运⾏等待同⼀个锁的另⼀个⽅法,就会在这⾥发⽣死锁。这通常可以通过调⽤BeginInvoke⽽不是Invoke来简单的修复。或者,可以在调⽤Invoke之前释放锁,但是如果是调⽤⽅获得的锁,那么这种⽅法可能并不会起作⽤。我们在富客户端应⽤与线程亲和中来解释Invoke和BeginInvoke。
2.7性能
锁是⾮常快的,在⼀个 2010 时代的计算机上,没有竞争的情况下获取并释放锁⼀般只需 20 纳秒。如果存在竞争,产⽣的上下⽂切换会把开销增加到微秒的级别,并且线程被重新调度前可能还会等待更久的时间。如果需要锁定的时间很短,那么可以使⽤⾃旋锁(SpinLock)来避免上下⽂切换的开销。
如果获取锁后保持的时间太长⽽不释放,就会降低并发度,同时也会加⼤死锁的风险。
2.8互斥体(Mutex)
互斥体类似于 C# 的lock,不同在于它是可以跨越多个进程⼯作。换句话说,Mutex可以是机器范围(computer-wide)的,也可以是程序范围(application-wide)的。
没有竞争的情况下,获取并释放Mutex需要⼏微秒的时间,⼤约⽐lock慢 50 倍。
使⽤Mutex类时,可以调⽤WaitOne⽅法来加锁,调⽤ReleaseMutex⽅法来解锁。关闭或销毁Mutex会⾃动释放锁。与lock语句⼀样,Mutex 只能被获得该锁的线程释放。
跨进程Mutex的⼀种常见的应⽤就是确保只运⾏⼀个程序实例。下⾯演⽰了这是如何实现的:
class OneAtATimePlease{ static void Main() { // 命名的 Mutex 是机器范围的,它的名称需要是唯⼀的 // ⽐如使⽤公司名+程序名,或者也可以⽤ URL using (var mutex = new Mutex (false, "oreilly OneAtATimeDemo")) { // 可能其它程序实例正在关闭,所以可以等待⼏秒来让其它实例完成关闭 if (!mutex.WaitOne (TimeSpan.FromSeconds (3), false)) {
Console.WriteLine ("Another app instance is running. Bye!"); return; } RunProgram(); } } static void RunProgram() {
Console.WriteLine ("Running. Press Enter to exit"); Console.ReadLine(); }}
如果在终端服务(Terminal Services)下运⾏,机器范围的Mutex默认仅对于运⾏在相同终端服务器会话的应⽤程序可见。要使其对所有终端服务器会话可见,需要在其名字前加上Global\。
2.9信号量(Semaphore)
信号量类似于⼀个夜总会:它具有⼀定的容量,并且有保安把守。⼀旦满员,就不允许其他⼈进⼊,这些⼈将在外⾯排队。当有⼀个⼈离开时,排在最前头的⼈便可以进⼊。这种构造最少需要两个参数:夜总会中当前的空位数以及夜总会的总容量。
容量为 1 的信号量与Mutex和lock类似,所不同的是信号量没有“所有者”,它是线程⽆关(thread-agnostic)的。任何线程都可以在调⽤Semaphore上的Release⽅法,⽽对于Mutex和lock,只有获得锁的线程才可以释放。
SemaphoreSlim是 Framework 4.0 加⼊的轻量级的信号量,功能与Semaphore相似,不同之处是它对于并⾏编程的低延迟需求做了优化。在传统的多线程⽅式中也有⽤,因为它⽀持在等待时指定取消标记(cancellation token)。但它不能跨进程使⽤。
在Semaphore上调⽤WaitOne或Release会产⽣⼤概 1 微秒的开销,⽽SemaphoreSlim产⽣的开销约是
其四分之⼀。
信号量在有限并发的需求中有⽤,它可以阻⽌过多的线程同时执⾏特定的代码段。在下⾯的例⼦中,五个线程尝试进⼊⼀个只允许三个线程进⼊的夜总会:
class TheClub{ static SemaphoreSlim _sem = new SemaphoreSlim (3); // 容量为 3 static void Main() {
for (int i = 1; i <= 5; i++) new Thread (Enter).Start (i); } static void Enter (object id) { _sem.Wait();// 同时只能有
Thread.Sleep (1000 * (int) id); // 3个线程
_sem.Release(); }}
如果Sleep语句被替换为密集的磁盘 I/O 操作,由于Semaphore限制了过多的并发硬盘活动,就可能改善整体性能。
类似于Mutex,命名的Semaphore也可以跨进程使⽤。
3线程安全
说⼀个程序或⽅法是线程安全( thread-safe)的,是指它在任意的多线程场景中都不存在不确定性。线程安全主要是通过锁以及减少线程交互来实现。
⼀般的类型很少有完全线程安全的,原因如下:
完全线程安全的开发负担很重,特别是如果⼀个类型有很多字段的情况(在任意多线程并发的情况下每个字段都有交互的潜在可能)。
线程安全可能会损失性能(某种程度上,⽆论类型是否实际被⽤于多线程都会增加损耗)。
线程安全的类型并不能确保使⽤该类型的程序也是线程安全的,为了实现程序线程安全所涉及的⼯作经常会使得类型线程安全成为多余。
因此线程安全通常只会在需要时再实现,只为了处理特定的多线程场景。
然⽽,有些⽅法可以⽤来“作弊” ,使庞⼤和复杂的类在多线程环境中安全运⾏。⼀种⽅法是牺牲粒度,将⼤段代码甚⾄是访问的整个对象封装在⼀个排它锁内,从⽽保证在⾼层上能进⾏顺序访问。事实上,如果我们希望在多线程环境中使⽤线程不安全的第三⽅代码(或⼤多
数 Framework 的类型)时,这种策略是⼗分有⽤的。它仅仅是简单的使⽤了相同的排它锁,来保护对
⾮线程安全对象上所有属性、⽅法和字段的访问。这种解决⽅案适⽤于对象的⽅法都能够快速执⾏的场景(否则会导致⼤量的阻塞)。
除基本类型外,很少有 .NET Framework 的类型能在⽐并发读取更⾼的需求下保证其实例成员是线程安全的。实现线程安全的责任就落在了开发⼈员⾝上,⼀般就是使⽤排它锁。(命名空间System.Collections.Concurrent中的类型是个例外,它们是线程安全的数据结构。)
另⼀种“作弊”的⽅法是通过最⼩化共享数据来减少线程交互。这是⼀种优秀的⽅法,隐式的⽤于“ ⽆状态(stateless)”的中间层程序和⽹页服务器中。由于多个客户端请求可以同时到达,服务端⽅法就必须是线程安全的。⽆状态设计(因可伸缩性(scalability)好⽽流⾏)在本质上限制了交互的可能性,因为类并不需要持久化请求之间的数据。线程交互仅限于静态字段,⽐如在内存中缓存通⽤数据,或者提供认证和审计这样的基础服务时需要考虑。
实现线程安全的最后⼀种⽅式是使⽤⾃动锁机制(automatic locking regime)。如果继承 ContextBoundObject 类并使
⽤ Synchronization 特性,.NET Framework 就可以实现这种机制。当该对象上的⽅法或属性被调⽤时,⼀个对象范围(object-wide)的锁就会⾃动作⽤于整个⽅法或属性的调⽤。尽管这样降低了实现线程安全的负担,但是也有它的问题:它很可能造成死锁、降低并发度并引起并⾮有意的重⼊。正是由于
这些原因,⼿动加锁通常是更好的选择(直到有更好⽤的⾃动锁机制出现)。
3.1线程安全与 .NET Framework 类型
锁可以⽤来将线程不安全的代码转换为线程安全的代码。.NET Framework 就是⼀个好例⼦:⼏乎所有的⾮基本类型的实例成员都不是线程安全的(对于⽐只读访问更⾼的需求),然⽽如果对指定对象的所有访问都通过锁进⾏保护,它们就可以被⽤于多线程代码中。例如,两个线程同时向同⼀个List中添加对象,然后枚举它:
class ThreadSafe{ static List <string> _list = new List <string>(); static void Main() { new Thread (AddItem).Start();
new Thread (AddItem).Start(); } static void AddItem() { lock (_list) _list.Add ("Item " + _list.Count); string[] items;
lock (_list) items = _list.ToArray(); foreach (string s in items) Console.WriteLine (s); }}
在这个例⼦中,我们使⽤_list对象本⾝来加锁。如果有两个关联的List,就需要选择⼀个公共对象来加锁(可以使⽤其中⼀个List对象,然⽽更好的⽅式是使⽤⼀个独⽴的字段)。
枚举 .NET 的集合也不是线程安全的,因为如果在枚举的过程中集合被修改则会抛出异常。在这个例⼦中,我们并没有将整个枚举过程加锁,⽽是⾸先将其中的对象复制到⼀个数组中。如果我们要进⾏的枚举可能很耗时,那么可以通过上述⽅式避免过长时间锁定。(另⼀种解决⽅案是使⽤读写锁(reader / writer lock))
对线程安全的对象加锁
有时也需要对线程安全的对象加锁,为了举例说明,假设 Framework 的List类是线程安全的,我们要给它添加⼀个条⽬:
if (!_list.Contains (newItem)) _list.Add (newItem);
⽆论List本⾝是否线程安全,上⾯的语句都不是线程安全的!为了防⽌if条件判断执⾏后,在实际添加条⽬之前,被其它线程抢占修改了
_list,整个if所包含的代码都需要封装在⼀个锁中。并且在所有要修改_list的地⽅都要使⽤这个锁。例如,下⾯的语句也需要封装在相同的锁中:
_list.Clear();
这也是为了确保了它不会在前⾯语句的执⾏过程中抢先执⾏。换句话说,我们不得不像对于⾮线程安全的集合⼀样锁定线程安全的集合(这使得对于List类是线程安全的假设变得多余)。
在⾼并发的环境下,对集合的访问加锁可能会产⽣⼤量阻塞,为此 Framework 4.0 提供了线程安全的队列、栈和字典。
静态成员
将对对象的访问封装在⼀个⾃定义锁中的⽅式,只有当所有参与并发的线程都知道并使⽤这个锁时才能起作⽤。然⽽如果需要加锁的逻辑有更⼤范围那就不是这么简单了。最糟糕的情况就是public类型中的静态成员。⽐如,我们假设DateTime结构体上的静态属性DateTime.Now 不是线程安全的,即两个并发线程调⽤会导致错误的输出或是异常。使⽤外部加锁进⾏修正的唯⼀⽅法就是在调⽤DateTime.Now之前对类型本⾝加锁:lock(typeof(DateTime))。这仅适⽤于所有的程序员都接受这样做(这不太可能)。此外,对类型加锁也有其⾃⾝的问题。
因此,DateTime结构体的静态成员都经过细致的处理,来保证它是线程安全的。这在 .NET Framework 中是⼀个通⽤模式:静态成员是线程安全的,⽽实例成员则不是。编写类型让别⼈使⽤时,遵守这种模式就不会令别⼈感到困惑和遇到难以解决的线程安全问题。换句话说,保证静态成员的线程安全,就不会妨碍你的类型的使⽤者实现线程安全。
静态⽅法的线程安全是必须由明确的编码实现的,不是说把⽅法写成静态的就能⾃动实现线程安全!
只读线程安全
使类型对于并发只读访问是线程安全的会很有益,这意味着使⽤者可以避免使⽤排它锁。许多 .NET Framework 类型都遵循这⼀原则:例如集合对于并发读是线程安全的。
⾃⼰遵循这⼀愿则也很简单:如果我们希望⼀个类型对于并发只读访问是线程安全的,那么不要在使⽤者期望是只读的⽅法内修改字段(也不要加锁后修改)。例如,在集合的ToArray()⽅法的实现中,也许会从压紧(compacting)集合的内部结构开始。然⽽,这会导致使⽤者认为是只读的操作并⾮线程安全。
只读线程安全也是枚举器与可枚举类型分离的原因之⼀:两个线程可以在⼀个集合上同时进⾏枚举,因为它们会分别获得单独的枚举器。
如果缺乏⽂档,在认为⼀个⽅法是只读前⼀定要谨慎。⼀个很好的例⼦是Random类:当调⽤Random.Next()时,它会更新私有的种⼦(seed)值。因此,或者对Random类的使⽤加锁,或者每个线程使⽤单独的实例。
3.2应⽤服务器中的线程安全
应⽤服务器需要使⽤多线程来处理多个客户端的同时请求。WCF、ASP.NET 以及 Web Services 应⽤都是隐式多线程的。使
⽤ TCP 或 HTTP 之类⽹络通道的远程(Remoting)服务应⽤程序也是如此。这意味着服务端编程必须考虑线程安全,考虑在处理客户端请求的线程间是否存在交互的可能。幸运的是,这种交互的可能性不⼤,⼀般服务端类要不然是⽆状态的(⽆字段),要不然就有为每个客户端或每个请求创建单独对象实例的激活模型。交互通常仅在静态字段上出现,有时是⽤于在内存中缓存数据库数据来提⾼性能。
例如,有⼀个查询数据库的RetrieveUser⽅法:
// User 是⼀个⾃定义类型,包含⽤户数据的字段internal User RetrieveUser (int id) { ... }
如果对这个⽅法的调⽤很频繁,可以通过在⼀个静态Dictionary中缓存查询结果来提⾼性能。下边是⼀个考虑了线程安全的⽅案:
static class UserCache{ static Dictionary <int, User> _users = new Dictionary <int, User>(); internal static User GetUser (int id) {
User u = null; lock (_users) if (_users.TryGetValue (id, out u)) return u; u = RetrieveUser (id); // 从数据库获取数
据 lock (_users) _users [id] = u; return u; }}
⾄少必须要在读取和更新字典时加锁来保证线程安全。在这个例⼦中,在加锁的便捷和性能之间进⾏了平衡。我们的设计略有⼀些效率问题:如果两个线程同时使⽤未缓存过数据的id调⽤这个⽅法,RetrieveUser就可能被调⽤两次,并且其中⼀次对字典的更新是不必要的。对整个⽅法加锁可以避免这⼀问题,但会导致更糟的效率:整个缓存在调⽤RetrieveUser的期间都会被加锁,在这段时间内,其它需要这样获取⽤户信息的线程都会被阻塞。
3.3富客户端应⽤与线程亲和
(译者注:这⾥的 thread affinity 译为线程亲和,是指 UI 控件与线程的⼀种“绑定”关系,⽽不是通常理解中的线程与 CPU 核⼼的绑定关系。)
WPF 与 Windows Forms 库都遵循基于线程亲和的模型。尽管它们有各⾃的实现,但是原理⾮常相似。
富客户端的构成主要基于DependencyObject(WPF 中)或是Control(Windows Forms 中)。这些对象具有线程亲和性
(thread affinity),意思是只有创建它们的线程才能访问其成员。违反这⼀原则会引起不可预料的⾏为,或是抛出异常。
这样的好处是访问 UI 对象时并不需要加锁。⽽坏处是,如果希望调⽤在另⼀线程 Y 上创建的对象 X 的成员,就必须将请求封送(marshal)到线程 Y 。通过下列⽅法显式实现:
WPF 中:在其Dispatcher对象上调⽤Invoke或BeginInvoke。
Windows Forms 中:调⽤Control对象上的Invoke或BeginInvoke。
Invoke和BeginInvoke都接受⼀个委托,代表我们希望在⽬标控件上运⾏的的⽅法。Invoke是同步⼯作的:调⽤⽅在封送的委托执⾏完成前会被阻塞;BeginInvoke是异步⼯作的:调⽤⽅⽴即返回,封送请求被加⼊队列(使⽤与处理键盘、⿏标、定时器事件相同的消息队列)。
版权声明:本站内容均来自互联网,仅供演示用,请勿用于商业和其他非法用途。如果侵犯了您的权益请与我们联系QQ:729038198,我们将在24小时内删除。
发表评论