第二部分: 基本同步
同步要点
到目前为止,我们已经描述了如何在线程上启动任务,配置线程以及双向传递数据。我们还描述了局部变量如何专用于线程,以及如何在线程之间共享引用,从而允许它们通过公共字段进行通信。
下一步是同步:协调线程的动作以实现可预测的结果。当线程访问相同的数据时,同步特别重要。在该区域搁浅非常容易。
同步构造可以分为四类:
简单的组织方法
它们等待另一个线程完成或等待一段时间。 Sleep,Join和Task.Wait是简单的阻止方法。
锁定构造
这些限制了可以一次执行某些活动或一次执行一段代码的线程数。排它锁定结构是最常见的-一次仅允许一个线程,并且允许竞争线程访问公共数据而不会互相干扰。标准的排他锁定结构是锁(Monitor.Enter / Monitor.Exit),互斥锁和SpinLock。非排他的锁定构造是Semaphore,SemaphoreSlim和读取器/写入器锁定
信号结构
这些允许线程暂停,直到接收到来自另一个线程的通知为止,从而避免了无效的轮询。常用的信号设备有两种:事件等待句柄和监视器的等待/脉冲方法。 Framework 4.0引入了CountdownEvent和Barrier类。
非阻塞同步构造
这些通过调用处理器原语来保护对公共字段的访问。 CLR和C#提供以下非阻塞构造:Thread.MemoryBarrier,Thread.VolatileRead,Thread.VolatileWrite,volatile关键字和Interlocked类。
除最后一个类别外,封锁对于所有其他人都是必不可少的。让我们简要地研究一下这个概念。
阻塞
当某个线程由于某种原因而被暂停执行时,该线程被视为阻塞,例如在休眠或通过Join或EndInvoke等待另一个线程结束时。被阻塞的线程会立即产生其处理器时间片,从那以后不消耗处理器时间,直到满足其阻塞条件为止。您可以通过其ThreadState属性测试线程是否被阻塞:
bool blocked = (someThread.ThreadState & ThreadState.WaitSleepJoin) != 0;
(鉴于线程的状态可能会在测试其状态然后对该信息执行操作之间发生变化,因此该代码仅在诊断情况下有用。)
当线程阻塞或解除阻塞时,操作系统将执行上下文切换。这产生了几微秒的开销。
解锁以以下四种方式之一发生(计算机的电源按钮不起作用!):
- 通过满足阻塞条件
- 通过操作超时(如果指定了超时)
- 通过Thread.Interrupt被中断
- 通过Thread.Abort中止
如果通过(不建议使用的)Suspend方法暂停了线程的执行,则该线程不会被视为阻塞。
阻止旋转
有时,线程必须暂停直到满足特定条件。信号和锁定结构通过阻塞直到满足条件来有效地实现这一目标。但是,有一个更简单的选择:线程可以通过轮询循环来等待条件。例如:
while (!proceed); or: while (DateTime.Now < nextStartTime);
通常,这在处理器时间上非常浪费:就CLR和操作系统而言,线程正在执行重要的计算,因此将相应地分配资源!
有时会在阻塞和旋转之间混合使用:
while (!proceed) Thread.Sleep (10);
尽管不优雅,但(通常)这比完全旋转的效率要高得多。但是,由于proceded flag上的并发问题可能会出现问题。正确使用锁定和发信号可以避免这种情况。 当您期望条件很快得到满足(可能在几微秒内)时,非常简短地旋转会很有效,因为它避免了上下文切换的开销和延迟。 .NET Framework提供了特殊的方法和类来提供帮助-并行编程部分将介绍这些方法和类。 |
线程状态
您可以通过线程的ThreadState属性查询线程的执行状态。这将返回一个ThreadState类型的标志枚举,该枚举以位方式组合三个数据“层”。但是,大多数值是冗余的,未使用的或已弃用的。下图显示了一个“层”:
以下代码将ThreadState剥离为四个最有用的值之一:Unstarted,Running,WaitSleepJoin和Stopped:
public static ThreadState SimpleThreadState (ThreadState ts) { return ts & (ThreadState.Unstarted | ThreadState.WaitSleepJoin | ThreadState.Stopped); }
ThreadState属性可用于诊断目的,但不适用于同步,因为线程状态可能会在测试ThreadState和对该信息进行操作之间发生变化。
锁定
排他锁定用于确保每次只有一个线程可以输入代码的特定部分。两个主要的互斥锁定结构是lock和Mutex。在这两者中,锁构造更快,更方便。但是,互斥锁有一个利基,因为它的锁可以跨越计算机上不同进程中的应用程序。
在本节中,我们将从锁定结构开始,然后继续进行互斥量和信号量(用于非排他性锁定)。稍后,我们将介绍读/写锁。
从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同时被两个线程调用,则可能会被除以零的错误,因为_val2可以在一个线程中设置为零,而另一个线程在执行之间if语句和Console.WriteLine。
锁定可以解决问题的方法如下:
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),并且所有竞争线程都将被阻塞,直到释放锁定为止。如果有多个线程争用该锁,则它们将在“就绪队列”中排队,并以先到先得的方式授予该锁(警告是Windows和CLR行为的细微差别意味着公平性)有时会违反队列数量)。有时称互斥锁可强制对受锁保护的对象进行序列化访问,因为一个线程的访问不能与另一个线程的访问重叠。在这种情况下,我们将保护Go方法中的逻辑以及字段_val1和_val2。
等待竞争锁而阻塞的线程的ThreadState为WaitSleepJoin。在“中断和中止”中,我们描述了如何通过另一个线程强制释放阻塞的线程。这是一项相当繁重的技术,可用于结束线程
锁定构造的比较
构造 |
目的 |
跨线程? |
开销* |
lock (Monitor.Enter / Monitor.Exit) |
确保一次只有一个线程可以访问资源或一段代码 |
- |
20ns |
Mutex |
Yes |
1000ns |
|
SemaphoreSlim (introduced in Framework 4.0) |
确保不超过指定数量的并发线程可以访问资源或代码段 |
- |
200ns |
Semaphore |
Yes |
1000ns |
|
ReaderWriterLockSlim (introduced in Framework 3.5) |
允许多个读者与一个作者共存 |
- |
40ns |
ReaderWriterLock (effectively deprecated) |
- |
100ns |
*在同一线程上锁定和解锁一次构造所花费的时间(假设没有阻塞),这在Intel Core i7 860上进行了测量。
Monitor.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.Exit而不先调用同一对象上的Monitor.Enter会引发异常。
lockTaken超载
我们刚刚演示的代码正是C#1.0、2.0和3.0编译器在翻译lock语句时产生的。
但是,此代码中存在一个细微的漏洞。考虑在Monitor.Enter的实现中,或者在Monitor.Enter的调用与try块之间抛出(不太可能发生)异常的事件(可能是由于在该线程上调用Abort或抛出了OutOfMemoryException) 。在这种情况下,可能会或可能不会采取锁定。如果该锁已被使用,它将不会被释放-因为我们永远不会输入try / finally块。这将导致锁泄漏。
为了避免这种危险,CLR 4.0的设计人员在Monitor.Enter中添加了以下过载:
公共静态无效Enter(对象obj,参考bool lockTaken);
当(且仅当)Enter方法引发异常且未采取锁定时,lockTaken在此方法之后为false。
这是正确的使用模式(这正是C#4.0转换lock语句的方式):
bool lockTaken = false; try { Monitor.Enter (_locker, ref lockTaken); // Do your stuff... } finally { if (lockTaken) Monitor.Exit (_locker); }
尝试输入
Monitor还提供了TryEnter方法,该方法允许指定超时(以毫秒为单位)或TimeSpan。然后,如果获得了锁定,则该方法返回true;否则,因为该方法超时而未获得任何锁定,则返回false。也可以不带任何参数调用TryEnter,该参数“测试”锁,如果无法立即获得锁,则立即超时。
与Enter方法一样,它在CLR 4.0中已重载以接受lockTaken参数。
选择同步对象
每个分配线程可见的任何对象都可以用作同步对象,但要遵循一个严格的规则:它必须是引用类型。同步对象通常是私有的(因为这有助于封装锁定逻辑),并且通常是实例或静态字段。同步对象可以作为其保护对象的两倍,如以下示例中的_list字段所示:
class ThreadSafe{ List <string> _list = new List <string>(); void Test() { lock (_list) { _list.Add ("Item 1"); ...
专门用于锁定的字段(例如_locker,在前面的示例中)允许精确控制锁定的范围和粒度。包含对象(此)或什至其类型也可以用作同步对象:
lock(this){...} else: lock(typeof(Widget)){...} //用于保护对静态变量的访问
以这种方式锁定的缺点是,您没有封装锁定逻辑,因此防止死锁和过度阻塞变得更加困难。对类型的锁定也可能会渗透到应用程序域边界(在同一过程中)。
您还可以锁定由lambda表达式或匿名方法捕获的局部变量。
锁定不会以任何方式限制对同步对象本身的访问。换句话说,x.ToString()不会阻塞,因为另一个线程调用了lock(x);两个线程都必须调用lock(x)才能发生阻塞。
何时锁定
作为基本规则,您需要锁定访问任何可写共享字段。即使在最简单的情况下(对单个字段进行赋值操作),也必须考虑同步。在下面的类中,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; } }
在非阻塞同步中,我们解释了这种需求的产生方式,以及内存屏障和Interlocked类如何在这些情况下提供替代锁定的方法。
锁定和原子性
如果总是在同一锁中读写一组变量,则可以说是原子地读写变量。假设始终在对象存储柜的锁内读取和分配字段x和y:
lock (locker) { if (x != 0) y /= x; }
可以说x和y是原子访问的,因为代码块不能被另一线程的操作所分割或抢占,以致它将改变x或y并使结果无效。只要x和y始终在同一排他锁中进行访问,就永远不会出现除零错误。
如果在锁块中引发异常,则会违反锁提供的原子性。例如,考虑以下内容:
十进制_savingsBalance,_checkBalance;
void Transfer (decimal amount) { lock (_locker) { _savingsBalance += amount; _checkBalance -= amount + GetBankFee(); } }
If an exception was thrown by GetBankFee(), the bank would lose money. In this case, we could avoid the problem by calling GetBankFee earlier. A solution for more complex cases is to implement “rollback” logic within a catch or finally block.
Instruction atomicity is a different, although analogous concept: an instruction is atomic if it executes indivisibly on the underlying processor (see Nonblocking Synchronization).
Nested Locking
A thread can repeatedly lock the same object in a nested (reentrant) fashion:
lock (locker) lock (locker) lock (locker) { // Do something... }
or:
Monitor.Enter (locker); Monitor.Enter (locker); Monitor.Enter (locker);
// do somthing ...
Monitor.Enter (locker); Monitor.Enter (locker); Monitor.Enter (locker);
在这些情况下,仅当最外面的lock语句已退出或执行了匹配数量的Monitor.Exit语句时,对象才被解锁。
static readonly object _locker = new object();static void Main() { lock (_locker) { AnotherMethod(); // We still have the lock - because locks are reentrant. } } static void AnotherMethod() { lock (_locker) { Console.WriteLine ("Another method"); } }
线程只能在第一个(最外层)锁上阻塞
死锁
当两个线程各自等待对方拥有的资源时,就会发生死锁,因此两个线程都无法继续进行。最简单的方法是使用两个锁:
o
bject locker1 = new object(); object locker2 = new object(); new Thread (() => { lock (locker1) { Thread.Sleep (1000); lock (locker2); // Deadlock } }).Start(); lock (locker2) { Thread.Sleep (1000); lock (locker1); // Deadlock }
可以使用三个或更多线程来创建更复杂的死锁链。
在标准托管环境中,CLR不像SQL Server,并且不会通过终止违规者之一来自动检测和解决死锁。线程死锁会导致参与线程无限期阻塞,除非您指定了锁定超时。 (但是,在SQL CLR集成主机下,将自动检测死锁,并在其中一个线程上引发[catchable]异常。)
死锁是多线程中最难解决的问题之一,尤其是当存在许多相互关联的对象时。从根本上说,困难的问题是您无法确定呼叫者将其锁出的原因。
因此,您可能无意中将x类中的私有字段锁定了,而没有意识到您的呼叫者(或呼叫者的呼叫者)已经将b类中的私有字段锁定了。同时,另一个线程正在做相反的工作-造成死锁。具有讽刺意味的是,(好的)面向对象设计模式加剧了这个问题,因为这种模式创建了直到运行时才确定的调用链。
流行的建议“以一致的顺序锁定对象以避免死锁”虽然在我们最初的示例中很有用,但很难应用于刚刚描述的场景。更好的策略是要警惕在对象可能具有对自己对象的引用的调用方法周围的锁定。另外,请考虑是否真的需要锁定其他类中的调用方法(通常会这样做(我们将在后面看到,但有时还有其他选择))。更加依赖于声明性和数据并行性,不可变类型和非阻塞同步构造,可以减少锁定的需求。
这是解决问题的另一种方法:当您在持有锁的情况下调出其他代码时,该锁的封装会微妙地泄漏。这不是CLR或.NET Framework中的故障,而是总体上锁定的基本限制。锁定的问题正在包括软件事务存储在内的各种研究项目中得到解决。
在拥有锁的情况下调用Dispatcher.Invoke(在WPF应用程序中)或Control.Invoke(在Windows Forms应用程序中)时,会出现另一个死锁情况。如果用户界面恰巧正在运行另一个正在等待同一锁的方法,则死锁将立即发生。通常可以通过调用BeginInvoke而不是Invoke来解决此问题。另外,您可以在调用Invoke之前释放锁,尽管如果您的呼叫者拿出了锁,这将不起作用。我们将在富客户端应用程序和线程关联中解释Invoke和BeginInvoke。
Performance 性能
锁定速度很快:如果无人争辩,您可以期望在2010年时代的计算机上在20纳秒内获取并释放一个锁定。如果有争议,则结果上下文切换会将开销移动到更接近微秒的区域,尽管在实际重新调度线程之前可能会花费更长的时间。如果锁定非常简短,则可以使用SpinLock类避免上下文切换的开销。
如果锁定时间太长,锁定会降低并发性。这也会增加死锁的机会。
Mutex 互斥体
互斥锁就像C#锁一样,但是可以跨多个进程工作。换句话说,互斥量可以在计算机范围内以及在应用程序范围内。
获取和释放无竞争的Mutex需花费几微秒的时间,比锁慢了大约50倍。
使用Mutex类,您可以调用WaitOne方法来锁定,并调用ReleaseMutex来解锁。关闭或处理Mutex会自动释放它。与lock语句一样,Mutex只能从获得它的同一线程中释放。
跨进程Mutex的常见用法是确保一次只能运行一个程序实例。操作方法如下:
class OneAtATimePlease { static void Main() { // Naming a Mutex makes it available computer-wide. Use a name that's // unique to your company and application (e.g., include your URL). using (var mutex = new Mutex (false, "oreilly.com OneAtATimeDemo")) { // Wait a few seconds if contended, in case another instance // of the program is still in the process of shutting down. 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(); } }
如果在“终端服务”下运行,则通常仅对同一终端服务器会话中的应用程序可见计算机范围的Mutex。要使其对所有终端服务器会话可见,请在其名称前添加Global \。
信号
信号量就像一个夜总会:它有一定的容量,由保镖来强制执行。装满后,将不再有其他人可以进入,并且队列在外面建立。然后,对于每个离开的人,一个人从队列的开头进入。构造函数至少需要两个参数:夜总会中当前可用的地点数和俱乐部的总容量。
容量为1的信号量与互斥锁或锁相似,不同之处在于该信号量没有“所有者”(与线程无关)。任何线程都可以在信号量上调用Release,而使用Mutex和锁,只有获得了锁的线程才能释放它。
该类有两个功能相似的版本:Semaphore和SemaphoreSlim。后者是在Framework 4.0中引入的,并已进行了优化以满足并行编程的低延迟需求。它在传统的多线程处理中也很有用,因为它使您可以在等待时指定取消令牌。但是,它不能用于进程间信令。
信号量在调用WaitOne或Release时花费大约1微秒; SemaphoreSlim大约占其中的四分之一。
信号量在限制并发性方面很有用-防止过多的线程一次执行特定的代码。在以下示例中,五个线程尝试进入一次只允许三个线程进入的夜总会:
class TheClub // No door lists! { static SemaphoreSlim _sem = new SemaphoreSlim (3); // Capacity of 3 static void Main() { for (int i = 1; i <= 5; i++) new Thread (Enter).Start (i); } static void Enter (object id) { Console.WriteLine (id + " wants to enter"); _sem.Wait(); Console.WriteLine (id + " is in!"); // Only three threads Thread.Sleep (1000 * (int) id); // can be here at Console.WriteLine (id + " is leaving"); // a time. _sem.Release(); } } 1 wants to enter 1 is in! 2 wants to enter 2 is in! 3 wants to enter 3 is in! 4 wants to enter 5 wants to enter 1 is leaving 4 is in! 2 is leaving 5 is in!
如果Sleep语句改为执行密集的磁盘I / O,则信号量将通过限制过多的并发硬盘驱动器活动来提高整体性能
信号量(如果命名)可以与互斥量相同的方式跨进程。
线程安全
如果程序或方法在任何多线程方案中都没有不确定性,则它是线程安全的。线程安全主要是通过锁定并减少线程交互的可能性来实现的。
出于以下原因,通用类型很少整体上都是线程安全的:
- 全线程安全性的开发负担可能会很大,尤其是在类型具有许多字段的情况下(每个字段在任意多线程上下文中都有可能进行交互)。
- 线程安全可能会带来性能成本(部分支付该类型是否实际由多个线程使用)。
- 线程安全类型不一定使使用它的程序具有线程安全性,而后者所涉及的工作通常会使前者变得多余。
- 因此,线程安全通常在需要的地方实现,以处理特定的多线程方案。
但是,有几种方法可以“欺骗”并使大型复杂类在多线程环境中安全运行。一种是通过将大量代码段(甚至是对整个对象的访问)包装在单个排他锁中来牺牲粒度,从而在较高级别上强制进行序列化访问。实际上,如果您要在多线程上下文中使用线程不安全的第三方代码(或大多数框架类型),则此策略至关重要。窍门就是简单地使用相同的互斥锁来保护对线程不安全对象上所有属性,方法和字段的访问。如果对象的方法全部快速执行,则该解决方案效果很好(否则,将有很多阻塞)。
除了原始类型,很少有.NET Framework类型被实例化时,除了并发只读访问权限以外,对线程安全无害。开发人员有责任叠加线程安全性,通常使用排他锁。 (System.Collections.Concurrent中的集合是一个例外。)
作弊的另一种方法是通过最小化共享数据来最小化线程交互。这是一种极好的方法,可隐式用于“无状态”中间层应用程序和网页服务器。由于可以同时到达多个客户端请求,因此它们调用的服务器方法必须是线程安全的。无状态设计(由于可伸缩性而广受欢迎)从本质上限制了交互的可能性,因为类不会在请求之间保留数据。然后,线程交互仅限于一个人可以选择创建的静态字段,以用于诸如在内存中缓存常用数据以及提供诸如身份验证和审核之类的基础结构服务的目的。
实现线程安全的最终方法是使用自动锁定机制。如果您将ContextBoundObject子类化并将Synchronization属性应用于该类,则.NET Framework会做到这一点。每当在此类对象上调用方法或属性时,都会为该方法或属性的整个执行自动获取对象范围的锁。尽管这减轻了线程安全的负担,但它却产生了自己的问题:否则将不会发生死锁,并发性降低和意外重入。由于这些原因,手动锁定通常是一个更好的选择-至少要等到不太简单的自动锁定机制可用为止。
线程安全和.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对象本身。如果我们有两个相互关联的列表,则必须选择一个要锁定的公共对象(我们可以指定一个列表,或者更好的方法是:使用一个独立的字段)。
枚举.NET集合也是线程不安全的,因为如果枚举在列表中被修改,则抛出异常。在此示例中,我们没有将其锁定在枚举期间,而是首先将项目复制到数组中。如果我们在枚举期间所做的操作很耗时,则可以避免过多地持有该锁。 (另一种解决方案是使用读取器/写入器锁。)
锁定线程安全对象
有时,您还需要锁定访问线程安全对象的权限。为了说明这一点,假设Framework的List类确实是线程安全的,并且我们想将一个项目添加到列表中:
if (!_list.Contains (newItem)) _list.Add (newItem);
无论列表是否是线程安全的,此语句肯定不是!整个if语句必须包装在一个锁中,以防止在测试集装箱船和添加新物品之间发生抢占。然后,在我们修改该列表的任何地方都需要使用相同的锁。例如,以下语句也需要包装在相同的锁中:
_list.Clear();
以确保它没有取代前一个声明。换句话说,我们必须与线程不安全的集合类完全锁定(使List类的假设线程安全成为多余)。
在高度并发的环境中,锁定访问集合可能导致过多的阻塞。为此,Framework 4.0提供了线程安全的队列,堆栈和字典。
静态成员
只有在所有并发线程都知道并使用了锁的情况下,才能对定制锁周围的对象进行访问包装。如果对象的范围很广,则情况可能并非如此。最坏的情况是使用公共类型的静态成员。例如,假设DateTime结构的静态属性DateTime.Now不是线程安全的,并且两次并发调用可能导致输出乱码或异常。用外部锁定解决此问题的唯一方法可能是在调用DateTime.Now之前锁定类型本身— lock(typeof(DateTime))。只有所有程序员都同意这样做(这是不可能的),这才起作用。此外,锁定类型会产生其自身的问题。
因此,已经对DateTime结构上的静态成员进行了精心编程,使其具有线程安全性。这是整个.NET Framework的通用模式:静态成员是线程安全的;实例成员不是。在编写供公众使用的类型时,遵循此模式也很有意义,以免造成不可能的线程安全难题。换句话说,通过使静态方法成为线程安全的,您正在进行编程,以免排除此类用户的线程安全。
您必须显式地编写静态方法中的线程安全性:由于方法是静态的,因此不会自动发生!
只读线程安全
使类型对并发只读访问是线程安全的(如果可能)是有利的,因为这意味着使用者可以避免过多的锁定。 .NET Framework的许多类型都遵循此原则:例如,集合对于并发阅读器是线程安全的。
遵循此原理很简单:如果您将类型记录为对并行并发只读访问是线程安全的,则不要写到消费者希望是只读的方法中的字段(或锁定这样做)。例如,在集合中实现ToArray()方法时,您可以先压缩集合的内部结构。但是,这对于希望将其设置为只读的使用者而言将导致线程不安全。
只读线程安全是枚举数与“枚举数”分开的原因之一:两个线程可以同时枚举一个集合,因为每个线程都获得一个单独的枚举数对象。
在没有文档的情况下,谨慎地假设方法本质上是只读的,这是值得谨慎的。一个很好的例子是Random类:调用Random.Next()时,其内部实现要求更新私有种子值。因此,您必须使用Random类来锁定,或者为每个线程维护一个单独的实例。
应用服务器中的线程安全
应用服务器需要使用多线程处理并发客户端请求。 WCF,ASP.NET和Web服务应用程序是隐式多线程的。远程处理使用网络通道(例如TCP或HTTP)的服务器应用程序也是如此。这意味着在服务器端编写代码时,如果处理客户端请求的线程之间可能存在交互,则必须考虑线程安全。幸运的是,这种可能性很少。典型的服务器类要么是无状态的(没有字段),要么具有一个激活模型,该模型为每个客户端或每个请求创建一个单独的对象实例。交互通常仅通过静态字段产生,有时用于在数据库的内存部分进行缓存以提高性能。
例如,假设您有一个查询数据库的RetrieveUser方法:
//用户是一个自定义类,其中包含用于用户数据的字段
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); // Method to retrieve user from database lock (_users) _users [id] = u; return u; } }
我们至少必须锁定阅读和更新字典,以确保线程安全。在此示例中,我们在简单性和锁定性能之间选择了一个实用的折衷方案。我们的设计实际上造成了效率低下的可能性很小:如果两个线程使用相同的先前未检索到的ID同时调用此方法,则RetrieveUser方法将被调用两次-并且将不必要地更新字典。在整个方法上一次锁定将防止这种情况的发生,但会带来更糟糕的低效率:整个缓存将在调用RetrieveUser的时间内被锁定,在此期间其他线程将被阻止检索任何用户。
富客户端应用程序和线程关联
Windows Presentation Foundation(WPF)和Windows Forms库都遵循基于线程相似性的模型。尽管每个都有单独的实现,但是它们的功能非常相似。
组成富客户端的对象在WPF中主要基于DependencyObject,在Windows Forms中则基于Control。这些对象具有线程亲和力,这意味着只有实例化它们的线程才能随后访问其成员。违反此规定将导致不可预知的行为或引发异常。
从积极的一面看,这意味着您不需要锁定访问UI对象的权限。不利的一面是,如果要在另一个线程Y上创建的对象X上调用成员,则必须封送对线程Y的请求。可以按如下所示明确地执行此操作:
- 在WPF中,调用元素的Dispatcher对象上的Invoke或BeginInvoke。
- 在Windows窗体中,在控件上调用Invoke或BeginInvoke。
Invoke和BeginInvoke都接受一个委托,该委托引用您要运行的目标控件上的方法。调用是同步进行的:调用者将一直阻塞直到统帅完成为止。 BeginInvoke异步工作:调用者立即返回,并且将经过封送处理的请求排队(使用处理键盘,鼠标和计时器事件的相同消息队列)。
假设我们有一个窗口,其中包含一个名为txtMessage的文本框,我们希望其工作线程可以更新其内容,这是WPF的示例:
public partial class MyWindow : Window { public MyWindow() { InitializeComponent(); new Thread (Work).Start(); } void Work() { Thread.Sleep (5000); // Simulate time-consuming task UpdateMessage ("The answer"); } 除了我们调用(Form's)的Invoke方法外,该代码与Windows Forms相似。 void UpdateMessage (string message) { Action action = () => txtMessage.Text = message; Dispatcher.Invoke (action); } }
框架提供了两种结构来简化此过程:
- 后台工作者
- 任务继续
工作线程与UI线程
富客户端应用程序具有两种不同的线程类别是有帮助的:UI线程和辅助线程。 UI线程实例化(并随后“拥有”)UI元素;工作线程没有。工作线程通常执行长时间运行的任务,例如获取数据。
大多数富客户端应用程序都有一个UI线程(也是主应用程序线程),并定期直接或使用BackgroundWorker生成工作线程。然后,这些工作人员将编组回到主UI线程,以便更新控件或报告进度。
那么,一个应用程序何时会有多个UI线程?主要方案是当您的应用程序带有多个顶层窗口时,通常称为“单文档界面(SDI)”应用程序,例如Microsoft Word。每个SDI窗口通常在任务栏上显示为一个单独的“应用程序”,并且在功能上大多与其他SDI窗口隔离。通过为每个这样的窗口提供自己的UI线程,可以使应用程序具有更高的响应速度。
不变的对象(Immutable Objects)
一个不可变的对象是其状态无法更改的对象-外部或内部。不可变对象中的字段通常被声明为只读,并在构造过程中完全初始化。
不变性是函数式编程的标志-在这里,您无需创建对象,而是创建具有不同属性的新对象。 LINQ遵循此范例。不可变性在多线程中也很有价值,因为它通过消除(或最小化)可写性避免了共享可写状态的问题。
一种模式是使用不可变对象来封装一组相关字段,以最大程度地减少锁定时间。举一个非常简单的例子,假设我们有两个字段,如下所示:
int _percentComplete; string_statusMessage;
我们想以原子方式读取/写入它们。除了锁定这些字段,我们可以定义以下不可变类:
1 class ProgressStatus //表示某些活动的进度 2 { 3 progress of some activity 4 { 5 public readonly int PercentComplete; 6 7 public readonly string StatusMessage; 8 9 //此类可能还有更多字段... 10 11 12 public ProgressStatus (int percentComplete, string statusMessage) 13 14 { 15 16 PercentComplete = percentComplete; 18 StatusMessage = statusMessage; 19 20 } 21 22 }
然后,我们可以定义该类型的单个字段以及一个锁定对象:
readonly object _statusLocker = new object(); ProgressStatus _status;
现在,我们可以读/写该类型的值,而无需为一个以上的分配持有锁:
var status = new ProgressStatus(50,“正在处理”); //想象我们要分配更多的字段... // ... lock (_statusLocker) _status = status; // Very brief lock
要读取对象,我们首先获取对象的副本(在锁内)。然后我们可以读取其值,而无需保持锁定:
ProgressStatus statusCopy; lock (_locker ProgressStatus) statusCopy = _status; // Again, a brief lock int pc = statusCopy.PercentComplete; string msg = statusCopy.StatusMessage; ...
从技术上讲,由于前面的锁执行了隐式的内存屏障,因此最后两行代码是线程安全的(请参见第4部分)。
请注意,这种无锁方法可以防止一组相关字段之间的不一致。但这并不能防止数据在您随后对其进行操作时发生更改-为此,您通常需要一个锁。在第5部分中,我们将看到更多使用不变性来简化多线程的示例,包括PLINQ。
还可以根据它的先前值安全地分配一个新的ProgressStatus对象(例如,可以“递增” PercentComplete值),而不必锁定一行以上的代码。实际上,通过使用显式内存屏障,Interlocked.CompareExchange和spin-waits,我们无需使用单个锁就可以做到这一点。这是一项高级技术,我们将在后面的并行编程部分中进行介绍。
使用事件等待句柄发信号
事件等待句柄用于发出信号。信号通知是一个线程等待直到收到另一个线程的通知。事件等待句柄是最简单的信令构造,并且与C#事件无关。它们具有三种形式:AutoResetEvent,ManualResetEvent和(从Framework 4.0起)CountdownEvent。前两个基于通用的EventWaitHandle类,它们在其中派生所有功能。
信令构造的比较
构造 |
目的 |
跨进程? |
开销* |
允许线程在从另一个线程接收到信号时取消阻塞一次 |
Yes |
1000ns |
|
允许线程在从另一个线程接收到信号时无限期解除阻塞(直到重置) |
Yes |
1000ns |
|
ManualResetEventSlim (在 Framework 4.0介绍) |
- |
40ns |
|
CountdownEvent (在 Framework 4.0介绍) |
允许线程在接收到预定数量的信号时解除阻塞 |
- |
40ns |
Barrier (在 Framework 4.0介绍) |
实现线程执行障碍 |
- |
80ns |
允许线程阻塞直到满足自定义条件 |
- |
120ns for a Pulse |
AutoResetEvent
AutoResetEvent就像票证旋转门:插入票证可以让一个人完全通过。班级名称中的“自动”是指有人经过后,打开的旋转闸门会自动关闭或“重置”的事实。线程通过调用WaitOne(在此“一个”旋转门上等待,直到其打开)在旋转门处等待或阻塞,并通过调用Set方法插入票证。如果有多个线程调用WaitOne,则会在旋转栅门后面建立队列。 (与锁一样,由于操作系统中的细微差别,有时可能会违反队列的公平性)。票证可以来自任何线程。换句话说,任何有权访问AutoResetEvent对象的(非阻塞)线程都可以对其调用Set来释放一个阻塞线程。
您可以通过两种方式创建AutoResetEvent。首先是通过其构造函数:
var auto = new AutoResetEvent(false);
(将true传递给构造函数等效于立即对其调用Set。)创建AutoResetEvent的第二种方法如下:
var auto = new EventWaitHandle(false,EventResetMode.AutoReset);
在以下示例中,启动了一个线程,其任务只是等待直到另一个线程发出信号:
class BasicWaitHandle { static EventWaitHandle _waitHandle = new AutoResetEvent (false); static void Main() { new Thread (Waiter).Start(); Thread.Sleep (1000); // Pause for a second... _waitHandle.Set(); //叫醒服务员 } static void Waiter() { Console.WriteLine ("Waiting..."); _waitHandle.WaitOne(); //等待通知 Console.WriteLine ("Notified"); }
}
>>>
等待中...(暂停)通知。
如果在没有线程等待时调用Set,则句柄将一直保持打开状态,直到某些线程调用WaitOne。这种行为有助于避免在通往旋转门的线程与插入票证的线程之间发生竞争(“糟糕,太早将票证插入微秒,运气不好,现在您将无限期等待!”)。但是,在没有人等待的旋转门上反复呼叫Set并不允许整个聚会到达:只有下一个人通过,多余的票都被“浪费”了。
在AutoResetEvent上调用Reset将关闭旋转门(应将其打开),而不会等待或阻塞。
在超时为0的情况下调用WaitOne会测试等待句柄是否“打开”,而不会阻塞调用者。不过请记住,这样做会在AutoResetEvent打开时将其重置。 |
WaitOne接受一个可选的超时参数,如果由于超时而结束等待,则返回false而不是获取信号。
在超时为0的情况下调用WaitOne会测试等待句柄是否“打开”,而不会阻塞调用者。不过请记住,这样做会在AutoResetEvent打开时将其重置。
设置等待句柄
完成等待句柄后,可以调用其Close方法来释放操作系统资源。或者,您可以简单地删除对等待句柄的所有引用,并允许垃圾回收器稍后再执行您的工作(等待句柄实现处置模式,终结器由此调用Close)。这是(可以说)可以接受这种备份的少数情况之一,因为等待句柄的操作系统负担很轻(异步委托完全依赖此机制来释放其IAsyncResult的等待句柄)。
卸载应用程序域时,将自动释放等待句柄。
双向信令
假设我们希望主线程连续三次向工作线程发出信号。如果主线程快速连续地多次在等待句柄上调用Set,则第二个或第三个信号可能会丢失,因为工作人员可能需要一些时间来处理每个信号。
解决方案是让主线程等待工作人员准备就绪后再发出信号。可以使用另一个AutoResetEvent完成此操作,如下所示:
class TwoWaySignaling { static EventWaitHandle _ready = new AutoResetEvent (false); static EventWaitHandle _go = new AutoResetEvent (false); static readonly object _locker = new object(); static string _message; static void Main() { new Thread (Work).Start(); _ready.WaitOne(); // First wait until worker is ready lock (_locker) _message = "ooo"; _go.Set(); // Tell worker to go _ready.WaitOne(); lock (_locker) _message = "ahhh"; // Give the worker another message _go.Set(); _ready.WaitOne(); lock (_locker) _message = null; // Signal the worker to exit _go.Set(); } static void Work() { while (true) { _ready.Set(); // Indicate that we're ready _go.WaitOne(); // Wait to be kicked off... lock (_locker) { if (_message == null) return; // Gracefully exit Console.WriteLine (_message); } } } }
ooo
ahhh
在这里,我们使用空消息表示工作人员应结束。对于无限期运行的线程,制定退出策略很重要!
生产者/消费者队列
生产者/消费者队列是线程中的常见要求。运作方式如下:
- 设置了一个队列来描述工作项或执行工作的数据。
- 当任务需要执行时,它就排队了,允许调用者继续进行其他操作。
- 一个或多个辅助线程会在后台插入,以提取并执行排队的项目。
该模型的优点是您可以精确控制一次执行多少个工作线程。这不仅可以限制CPU时间的消耗,而且还可以限制其他资源的消耗。例如,如果任务执行密集的磁盘I / O,则可能只有一个工作线程,以避免使操作系统和其他应用程序饿死。另一种类型的应用程序可能有20个。您还可以在队列的整个生命周期中动态添加和删除工作程序。 CLR的线程池本身是一种生产者/消费者队列。
生产者/消费者队列通常保存在其上执行(相同)任务的数据项。例如,数据项可能是文件名,而任务可能是加密那些文件。
在下面的示例中,我们使用单个AutoResetEvent来向工作程序发出信号,该工作程序在任务用完时(换句话说,当队列为空时)将等待。我们通过使空任务入队来结束工作器:
using System; using System.Threading; using System.Collections.Generic; class ProducerConsumerQueue : IDisposable { EventWaitHandle _wh = new AutoResetEvent (false); Thread _worker; readonly object _locker = new object(); Queue<string> _tasks = new Queue<string>(); public ProducerConsumerQueue() { _worker = new Thread (Work); _worker.Start(); } public void EnqueueTask (string task) { lock (_locker) _tasks.Enqueue (task); _wh.Set(); } public void Dispose() { EnqueueTask (null); // Signal the consumer to exit. _worker.Join(); // Wait for the consumer's thread to finish. _wh.Close(); // Release any OS resources. } void Work() { while (true) { string task = null; lock (_locker) if (_tasks.Count > 0) { task = _tasks.Dequeue(); if (task == null) return; } if (task != null) { Console.WriteLine ("Performing task: " + task); Thread.Sleep (1000); // simulate work... } else _wh.WaitOne(); // No more tasks - wait for a signal } } }
为了确保线程安全,我们使用了锁来保护对Queue <string>集合的访问。我们还显式地关闭了Dispose方法中的wait句柄,因为我们有可能在应用程序的生命周期内创建和销毁该类的许多实例。
这是测试队列的主要方法:
static void Main() { use(ProducerConsumerQueue q = new ProducerConsumerQueue()) { q.EnqueueTask(“ Hello”); for(int i = 0; i <10; i ++)q.EnqueueTask(“ Say” + i); q.EnqueueTask(“再见!”); } //退出using语句将调用q的Dispose方法,该方法 //使空任务入队,并等待直到使用者完成。 }>>>
执行任务:您好
执行任务:说1
执行任务:说2
执行任务:说3
...
...
执行任务:说9
再见!
Framework 4.0提供了一个称为BlockingCollection <T>的新类,该类实现了生产者/消费者队列的功能。
我们手动编写的生产者/消费者队列仍然很有价值-不仅用于说明AutoResetEvent和线程安全性,而且还可以作为更复杂结构的基础。例如,如果我们想要一个有界的阻塞队列(限制排队的任务的数量),并且还想支持取消(和删除)排队的工作项,那么我们的代码将提供一个很好的起点。在“等待和脉冲”的讨论中,我们将进一步使用“生产者/消费队列”示例。
ManualResetEvent
ManualResetEvent的功能类似于普通的门。调用Set将打开此门,从而允许任何数量的调用WaitOne的线程通过。调用重置将关闭门。在关闭的门上调用WaitOne的线程将阻塞;下次打开大门时,它们将立即全部释放。除了这些差异之外,ManualResetEvent的功能类似于AutoResetEvent。
与AutoResetEvent一样,您可以通过两种方式构造ManualResetEvent:
var manual1 = new ManualResetEvent(false); var manual2 = new EventWaitHandle(false,EventResetMode.ManualReset);
从Framework 4.0开始,还有另一个版本的ManualResetEvent,称为ManualResetEventSlim。后者针对短等待时间进行了优化-能够选择旋转一定次数的迭代。它还具有更有效的托管实现,并允许通过CancellationToken取消Wait。但是,它不能用于进程间信令。 ManualResetEventSlim不将WaitHandle子类化;但是,它公开了一个WaitHandle属性,该属性在调用时将返回一个基于WaitHandle的对象(具有传统等待句柄的性能配置文件)。
信令结构和性能
等待或发信号通知AutoResetEvent或ManualResetEvent大约需要一微秒(假设没有阻塞)。
由于不依赖操作系统以及明智地使用旋转结构,因此ManualResetEventSlim和CountdownEvent在短暂等待的情况下可以快50倍。
但是,在大多数情况下,信令类本身的开销不会造成瓶颈,因此很少考虑。高并发代码是一个例外,我们将在第5部分中进行讨论。
ManualResetEvent在允许一个线程取消阻止许多其他线程时很有用。 CountdownEvent涵盖了相反的情况。
倒计时事件
CountdownEvent使您可以等待多个线程。该类是Framework 4.0的新增功能,并且具有有效的完全托管的实现。
如果您使用的是.NET Framework的早期版本,那么一切都不会丢失!稍后,我们展示如何使用Wait和Pulse编写CountdownEvent。
要使用CountdownEvent,请使用您要等待的线程数或“计数”实例化该类:
var countdown = new CountdownEvent(3); //以“ count”为3初始化。 呼叫信号减少“计数”;调用Wait块,直到计数降至零为止。例如: static CountdownEvent _countdown = new CountdownEvent (3); static void Main() { new Thread (SaySomething).Start ("我是线程1");
new Thread (SaySomething).Start ("我是线程2"); new Thread (SaySomething).Start ("我是线程3"); _countdown.Wait(); //阻塞直到信号被调用3次
Console.WriteLine ("所有线程都讲完了!");} static void SaySomething (object thing) { Thread.Sleep (1000); Console.WriteLine (thing); _countdown.Signal(); }
使用第5部分(PLINQ和Parallel类)中介绍的结构化并行结构,有时可以更轻松地解决CountdownEvent有效的问题。
您可以通过调用AddCount来增加CountdownEvent的计数。但是,如果已经达到零,则会引发异常:您无法通过调用AddCount“取消信号传递” CountdownEvent。为避免引发异常,您可以调用TryAddCount,如果倒数为零,则返回false。
要取消对倒计时事件的信号,请调用Reset:这既取消对构造的信号,又将其计数重置为原始值。
与ManualResetEventSlim一样,CountdownEvent在某些其他类或方法期望基于WaitHandle的对象的情况下公开WaitHandle属性。
创建一个跨进程EventWaitHandle
EventWaitHandle的构造函数允许创建一个“命名的” EventWaitHandle,能够跨多个进程进行操作。名称只是一个字符串,并且可以是任何不会与其他人无意冲突的值!如果该名称已在计算机上使用,您将获得对同一基础EventWaitHandle的引用;否则,操作系统将创建一个新的操作系统。这是一个例子:
static ManualResetEvent _starter = new ManualResetEvent (false);
如果两个应用程序都运行此代码,则它们将能够相互发出信号:等待句柄将在两个进程中的所有线程之间工作。
等待句柄和线程池
如果您的应用程序有很多线程将大部分时间都花在等待句柄上,则可以通过调用ThreadPool.RegisterWaitForSingleObject来减少资源负担。此方法接受在发出等待句柄信号时执行的委托。在等待时,它不会占用线程:
public static void Main()
{
RegisteredWaitHandle reg = ThreadPool.RegisterWaitForSingleObject(_starter, Go, "Some Data", -1, true);
Thread.Sleep (5000);
Console.WriteLine ("Signaling worker...");
_starter.Set();
Console.ReadLine();
reg.Unregister (_starter); // Clean up when we’re done.
}
publicstaticvoid Go (object data, bool timedOut)
{
Console.WriteLine ("Started - " + data);
// Perform task...
}
(5 second delay)
Signaling worker...
Started - Some Data
当发出等待句柄信号(或超时)时,委托在池线程上运行。
除了等待句柄和委托外,RegisterWaitForSingleObject还接受传递给委托方法的“黑匣子”对象(类似于ParameterizedThreadStart),以及以毫秒为单位的超时(–1表示没有超时)和一个布尔型标志,指示是否该请求是一次性的,而不是重复发生的。
RegisterWaitForSingleObject在必须处理许多并发请求的应用程序服务器中特别有价值。假设您需要阻止一个ManualResetEvent并简单地调用WaitOne:
void AppServerMethod() { _wh.WaitOne(); // ... continue execution }
如果有100个客户端调用此方法,则在阻塞期间将占用100个服务器线程。用RegisterWaitForSingleObject替换_wh.WaitOne可使方法立即返回,而不会浪费线程
void AppServerMethod
{
RegisteredWaitHandle reg = ThreadPool.RegisterWaitForSingleObject
(_wh, Resume, null, -1, true);
...
}
staticvoid Resume (object data, bool timedOut)
{
// ... continue execution
}
传递给Resume的数据对象允许继续任何瞬态数据。
WaitAny,WaitAll和SignalAndWait
除了Set,WaitOne和Reset方法之外,WaitHandle类上还有静态方法可以破解更复杂的同步螺母。 WaitAny,WaitAll和SignalAndWait方法在多个句柄上执行信令和等待操作。等待句柄可以具有不同的类型(包括Mutex和Semphore,因为它们也从抽象的WaitHandle类派生)。 ManualResetEventSlim和CountdownEvent也可以通过它们的WaitHandle属性来参与这些方法。
WaitAll和SignalAndWait与旧式COM体系结构有着怪异的联系:这些方法要求调用者位于多线程单元中,该模型最不适合互操作性。例如,WPF或Windows应用程序的主线程在这种模式下无法与剪贴板交互。我们将在短期内讨论替代方案。
WaitHandle.WaitAny等待一系列等待句柄中的任何一个; WaitHandle.WaitAll自动地在所有给定的句柄上等待。这意味着,如果您等待两个AutoResetEvents:
- WaitAny将永远不会最终“锁定”这两个事件。
- WaitAll永远不会只“锁定”一个事件。
SignalAndWait在一个WaitHandle上调用Set,然后在另一个WaitHandle上调用WaitOne。向第一个句柄发出信号后,它将在等待第二个句柄时跳到队列的开头;这有助于它成功(尽管该操作并非真正的原子性)。您可以认为此方法是“交换”一个信号到另一个信号,并在一对EventWaitHandles上使用它来设置两个线程在同一时间会合或“开会”。 AutoResetEvent或ManualResetEvent均可解决问题。第一个线程执行以下操作:
WaitHandle.SignalAndWait (wh1, wh2);
而第二个线程则相反:
WaitHandle.SignalAndWait (wh2, wh1);
WaitAll和SignalAndWait的替代方案
WaitAll和SignalAndWait不会在单线程单元中运行。幸运的是,还有其他选择。在SignalAndWait的情况下,很少需要它的队列跳转语义:例如,在我们的集合示例中,仅在第一个等待句柄上调用Set,然后在另一个等待句柄上调用WaitOne才是有效的。仅用于该集合点。在“屏障类”中,我们将探讨实现线程集合的另一个选项。
对于WaitAll,在某些情况下,一种替代方法是使用Parallel类的Invoke方法,这将在第5部分中介绍。(我们还将介绍Tasks和continuation,并查看TaskFactory的ContinueWhenAny如何提供WaitAny的替代方法。 )
在所有其他情况下,答案是采用解决所有信令问题的低级方法:等待和脉冲。
Synchronization Contexts
手动锁定的另一种方法是声明式锁定。通过从ContextBoundObject派生并应用Synchronization属性,可以指示CLR自动应用锁定。例如:
using System;
using System.Threading;
using System.Runtime.Remoting.Contexts;
[Synchronization]
publicclassAutoLock : ContextBoundObject
{
publicvoid Demo()
{
Console.Write ("Start...");
Thread.Sleep (1000); // We can't be preempted here
Console.WriteLine ("end"); // thanks to automatic locking!
}
}
publicclassTest
{
publicstaticvoid Main()
{
AutoLock safeInstance = new AutoLock();
new Thread (safeInstance.Demo).Start(); // Call the Demo
new Thread (safeInstance.Demo).Start(); // method 3 times
safeInstance.Demo(); // concurrently.
}
}
>>
Start... end
Start... end
Start... end
CLR确保一次只有一个线程可以在safeInstance中执行代码。为此,它创建了一个同步对象,并将其锁定在对每个safeInstance方法或属性的每次调用周围。锁的范围(在本例中为safeInstance对象)称为同步上下文。
那么,这是如何工作的呢?线索位于“同步”属性的名称空间中:System.Runtime.Remoting.Contexts。可以将ContextBoundObject视为“远程”对象,这意味着所有方法调用都将被拦截。为了使这种拦截成为可能,当我们实例化AutoLock时,CLR实际上返回一个代理-一个具有与AutoLock对象相同的方法和属性的对象,该对象充当中介。正是通过这种中介,自动锁定发生了。总体而言,拦截使每个方法调用增加了大约一微秒的时间。
自动同步不能用于保护静态类型成员,也不能用于保护不是从ContextBoundObject派生的类(例如Windows窗体)。
锁定以相同的方式在内部应用。您可能希望以下示例将产生与上一个相同的结果:
[Synchronization]
publicclassAutoLock : ContextBoundObject
{
publicvoid Demo()
{
Console.Write ("Start...");
Thread.Sleep (1000);
Console.WriteLine ("end");
}
publicvoid Test()
{
new Thread (Demo).Start();
new Thread (Demo).Start();
new Thread (Demo).Start();
Console.ReadLine();
}
publicstaticvoid Main()
{
new AutoLock().Test();
}
}
(注意,我们已经潜入了Console.ReadLine语句)。由于在该类的对象中一次只能执行一个线程,因此三个新线程将在Demo方法完成之前一直处于阻塞状态,直到Test方法完成为止-这要求ReadLine完成。因此,我们最终得到与以前相同的结果,但只有在按Enter键之后。这是一个线程安全锤,几乎足以阻止类中任何有用的多线程处理!
此外,我们还没有解决前面描述的问题:例如,如果AutoLock是一个集合类,则假定它是从另一个类运行的,我们仍然需要围绕如下语句进行锁定:
if (safeInstance.Count > 0) safeInstance.RemoveAt (0);
除非此代码的类本身是同步的ContextBoundObject!
同步上下文可以扩展到单个对象的范围之外。默认情况下,如果一个同步对象是从另一个对象的代码中实例化的,则它们都共享相同的上下文(换句话说,一个大锁!)可以通过在Synchronization属性的构造函数中使用一个整数标志来指定此标志,从而更改此行为。在SynchronizationAttribute类中定义的常量
常量 |
意义 |
NOT_SUPPORTED |
等同于不使用同步属性 |
SUPPORTED |
如果从另一个同步对象实例化,则在现有同步上下文中存在,否则保持未同步状态 |
REQUIRED (default) |
如果从另一个同步对象实例化,则加入现有的同步上下文,否则创建新的上下文 |
REQUIRES_NEW |
始终创建一个新的同步上下文 |
因此,如果SynchronizedA类的对象实例化了SynchronizedB类的对象,则在声明SynchronizedB时,它们将获得单独的同步上下文:
[Synchronization (SynchronizationAttribute.REQUIRES_NEW)]
publicclassSynchronizedB : ContextBoundObject { ...
同步上下文的范围越大,管理起来就越容易,但是有用的并发机会就越少。在规模的另一端,单独的同步上下文会引发死锁。例如:
[Synchronization]
publicclassDeadlock : ContextBoundObject
{
publicDeadLock Other;
publicvoid Demo() { Thread.Sleep (1000); Other.Hello(); }
void Hello() { Console.WriteLine ("hello"); }
}
publicclassTest
{
staticvoid Main()
{
Deadlock dead1 = new Deadlock();
Deadlock dead2 = new Deadlock();
dead1.Other = dead2;
dead2.Other = dead1;
new Thread (dead1.Demo).Start();
dead2.Demo();
}
}
因为Deadlock的每个实例都是在Test(一个未同步的类)中创建的,所以每个实例将获得自己的同步上下文,因此也拥有自己的锁。当两个对象相互调用时,死锁发生的时间不会很长(准确地说是一秒钟!)如果Deadlock和Test类是由不同的编程团队编写的,则该问题尤其隐患。期望负责Test类的人员甚至意识到自己的过错,更不用说知道如何解决它了,这可能是不合理的。这与显式锁相反,在显式锁中,死锁通常更为明显。
Reentrancy
线程安全的方法有时称为可重入方法,因为它可以在执行过程中被抢占,然后在另一个线程上再次调用而不会产生不良影响。在一般意义上,术语线程安全和可重入被视为同义词或紧密相关。
但是,在自动锁定机制中,重入具有另一个更险恶的含义。如果在可重入参数为true的情况下应用了Synchronization属性,则:
[Synchronization(true)]
那么当执行离开上下文时,同步上下文的锁将被临时释放。在前面的示例中,这将防止死锁的发生;显然是可取的。但是,副作用是,在此过渡期间,任何线程都可以自由调用原始对象上的任何方法(“重新输入”同步上下文),并且释放了多线程的复杂性,这是人们首先要避免的。这是可重入的问题。
因为[Synchronization(true)]是在类级别应用的,所以此属性会将类进行的每个上下文外方法调用都转换为Trojan以便重新进入。
虽然重入可能很危险,但有时几乎没有其他选择。例如,假设一个方法是通过将逻辑委托给在单独上下文中运行对象的工作人员在同步类内部实现多线程。这些工作人员可能无法合理地阻碍彼此之间或与原始对象之间的沟通,而无需重新进入。
这凸显了自动同步的一个根本缺陷:应用锁定的广泛范围实际上可能制造出其他情况下从未出现过的困难。这些困难-死锁,重入和并发并发-在简单情况之外的其他任何情况下,都可以使手动锁定更可口。
来源:https://www.cnblogs.com/wxs121/p/12545329.html