internal static volatile int s_x = 0; internal static volatile int s_xa = 0; internal static volatile int s_y = 0; internal static volatile int s_ya = 0;
void ThreadA() { s_x = 1; s_ya = s_y; }
void ThreadB() { s_y = 1; s_xa = s_x; }
是否有可能在 ThreadA 和 ThreadB 均运行完成后,s_ya 和 s_xa 都包含值 0?看上去这个问题很可笑。或者 s_x = 1 或者 s_y = 1 会首先发生,在这种情况下,其他线程会在开始处理其自身的更新时见证这一更新。至少理论上如此。
遗憾的是,处理器随时都可能重新排序此代码,以使在写入之前加载操作更有效。您可以借助一个显式内存屏障来避免此问题:
void ThreadA() { s_x = 1;
Thread.MemoryBarrier(); s_ya = s_y; }
.NET Framework 为此提供了一个特定 API,C++ 提供了 _MemoryBarrier 和类似的宏。但这个示例并不是想说明您应该在各处都插入内存屏障。它要说明的是在完全弄清内存模型之前,应避免使用无锁定代码,而且即使在完全弄清之后也应谨慎行事。
在 Windows(包括 Win32 和 .NET Framework)中,大多数锁定都支持递归获得。这只是意味着,即使当前线程已持有锁但当它试图再次获得时,其要求仍会得到满足。这使得通过较小的原子操作构成较大的原子操作变得更加容易。实际上,之前给出的 BankAccount 示例依靠的就是递归获得:Transfer 对 Withdraw 和 Deposit 都进行了调用,其中每个都重复获得了 Transfer 已获得的锁定。 但是,如果最终发生了递归获得操作而您实际上并不希望如此,则这可能就是问题的根源。这可能是因为重新进入而导致的,而发生重新进入的原因可能是由于对动态代码(如虚拟方法和委托)的显式
调用或由于隐式重新输入的代码(如 STA 消息提取和异步过程调用)。因此,最好不要从锁定区域对动态方法进行调用。
例如,设想某个方法暂时破坏了不变体,然后又调用委托:
class C {
private int m_x = 0;
private object m_xLock = new object(); private Action m_action = ...;
internal void M() { lock (m_xLock) { m_x++;
try { m_action(); } finally {
Debug.Assert(m_x == 1); m_x--; } } } }
C 的方法 M 可确保 m_x 不发生改变。但会有很短的一段时间,m_x 会先递增 1,然后再重新递减。对 m_action 的调用看起来没有任何问题。遗憾的是,如果它是从 C 类用户接受的委托,则表示任何代码都可以执行它所请求的操作。这包括回调到同一实例的 M 方法。如果发生了这种情况,finally 中的声明可能会被触发;同一堆栈中可能存在多个针对 M 的活动的调用(即使您未直接执行此操作),这必然会导致 m_x 包含的值大于 1。
当多个线程遇到死锁时,系统会直接停止响应。多篇《MSDN 杂志》文章都介绍了死锁的发生原因以及使死锁变得能够接受的一些方法,其中包括我自己的文章 \to Avoid and Detect Deadlocks in .NET Apps\(网址为 msdn.microsoft.com/magazine/cc163618)以及 Stephen Toub 的 2007 年 10 月 .NET 相关问题专栏(网址
为 msdn.microsoft.com/magazine/cc163352),因此这里只做简单的讨论。总而言之,只要出现了循环等待链 — 例如,ThreadA 正在等待 ThreadB 持有的资源,而 ThreadB 反过来也在等待 ThreadA 持有的资源(也许是间接等待第三个 ThreadC 或其他资源)— 则所有向前的推进工作都可能会停下来。 此问题的常见根源是互斥锁。实际上,之前所示的 BankAccount 示例遇到的就是这个问题。如果 ThreadA 试图将 $500 从帐户 #1234 转移到帐户 #5678,与此同时 ThreadB 试图将 $500 从 #5678 转移到 #1234,则代码可能发生死锁。
使用一致的获得顺序可避免死锁,如图 3 所示。此逻辑可概括为“同步锁获得”之类的名称,通过此操
作可依照各个锁之间的某种顺序动态排序多个可锁定的对象,从而使得在以一致的顺序获得两个锁的同时必须维持两个锁的位置。另一个方案称为“锁矫正”,可用于拒绝被认定以不一致的顺序完成的锁获得。
图 3 一致的获得顺序
class BankAccount {
private int m_id; // Unique bank account ID. internal static void Transfer(
BankAccount a, BankAccount b, decimal delta) { if (a.m_id < b.m_id) {
Monitor.Enter(a.m_balanceLock); // A first Monitor.Enter(b.m_balanceLock); // ...and then B } else {
Monitor.Enter(b.m_balanceLock); // B first
Monitor.Enter(a.m_balanceLock); // ...and then A } try {
Withdraw(a, delta); Deposit(b, delta); } finally {
Monitor.Exit(a.m_balanceLock); Monitor.Exit(b.m_balanceLock); } }
// As before ... }
但锁并不是导致死锁的唯一根源。唤醒丢失是另一种现象,此时某个事件被遗漏,导致线程永远休眠。在 Win32 自动重置和手动重置事件、CONDITION_VARIABLE、CLR Monitor.Wait、Pulse 以及 PulseAll 调用等同步事件中经常会发生这种情况。唤醒丢失通常是一种迹象,表示同步不正确,无法重置等待条件或在 wake-all(WakeAllConditionVariable 或 Monitor.PulseAll)更为适用的情况下使用了 wake-single 基元(WakeConditionVariable 或 Monitor.Pulse)。
此问题的另一个常见根源是自动重置事件和手动重置事件信号丢失。由于此类事件只能处于一个状态(有信号或无信号),因此用于设置此事件的冗余调用实际上将被忽略不计。如果代码认定要设置的两个调用始终需要转换为两个唤醒的线程,则结果可能就是唤醒丢失。
锁保护
当某个锁的到达率与其锁获得率相比始终居高不下时,可能会产生锁保护。在极端的情况下,等待某个锁的线程超过了其承受力,就会导致灾难性后果。对于服务器端的程序而言,如果客户端所需的某些受锁保护的数据结构需求量大增,则经常会发生这种情况。
例如,请设想以下情况:平均来说,每 100 毫秒会到达 8 个请求。我们将八个线程用于服务请求(因为我们使用的是 8-CPU 计算机)。这八个线程中的每一个都必须获得一个锁并保持 20 毫秒,然后才能展开实质的工作。
遗憾的是,对这个锁的访问需要进行序列化处理,因此,全部八个线程需要 160 毫秒才能进入并离开锁。第一个退出后,需要经过 140 毫秒第九个线程才能访问该锁。此方案本质上无法进行调整,因此备份的请求会不断增长。随着时间的推移,如果到达率不降低,客户端请求就会开始超时,进而发生灾难性后果。
众所周知,在锁中是通过公平性对锁进行保护的。原因在于在锁本来已经可用的时间段内,锁被人为封闭,使得到达的线程必须等待,直到所选锁的拥有者线程能够唤醒、切换上下文以及获得和释放该锁为止。为解决这种问题,Windows 已逐渐将所有内部锁都改为不公平锁,而且 CLR 监视器也是不公平的。
对于这种有关保护的基本问题,唯一的有效解决方案是减少锁持有时间并分解系统以尽可能减少热锁(如果有的话)。虽然说起来容易做起来难,但这对于可伸缩性来说还是非常重要的。
“蜂拥”是指大量线程被唤醒,使得它们全部同时从 Windows 线程计划程序争夺关注点。例如,如果在单个手动设置事件中有 100 个阻塞的线程,而您设置该事件…嗯,算了吧,您很可能会把事情弄得一团糟,特别是当其中的大部分线程都必须再次等待时。
实现阻塞队列的一种途径是使用手动设置事件,当队列为空时变为无信号而在队列非空时变为有信号。遗憾的是,如果从零个元素过渡到一个元素时存在大量正在等待的线程,则可能会发生蜂拥。这是因为只有一个线程会得到此单一元素,此过程会使队列变空,从而必须重置该事件。如果有 100 个线程在等待,那么其中的 99 个将被唤醒、切换上下文(导致所有缓存丢失),所有这些换来的只是不得不再次等待。
两步舞曲
有时您需要在持有锁的情况下通知一个事件。如果唤醒的线程需要获得被持有的锁,则这可能会很不凑巧,因为在它被唤醒后只是发现了它必须再次等待。这样做非常浪费资源,而且会增加上下文切换的总数。此情况称为两步舞曲,如果涉及到许多锁和事件,可能会远远超出两步的范畴。
Win32 和 CLR 的条件变量支持在本质上都会遇到两步舞曲问题。它通常是不可避免的,或者很难解决。
两步舞曲问题在单处理器计算机上情况更糟。在涉及到事件时,内核会将优先级提升应用到唤醒的线程。这几乎可以保证抢先占用线程,使其能够在有机会释放锁之前设置事件。这是在极端情况下的两步舞曲,其中设置 ThreadA 已切换出上下文,使得唤醒的 ThreadB 可以尝试获得锁;当然它无法做到,因此它将进行上下文切换以使 ThreadA 可再次运行;最终,ThreadA 将释放锁,这将再次提升 ThreadB 的优先级,使其优先于 ThreadA,以便它能够运行。如您所见,这涉及了多次无用的上下文切换。
优先级反转
修改线程优先级常常是自找苦吃。当不同优先级的许多线程共享对同样的锁和资源的访问权时,可能会发生优先级反转,即较低优先级的线程实际无限期地阻止较高优先级线程的进度。这个示例所要说明的道理就是尽可能避免更改线程优先级。
下面是一个优先级反转的极端示例。假设低优先级的 ThreadA 获得某个锁 L。随后高优先级的 ThreadB 介入。它尝试获得 L,但由于 ThreadA 占用使得它无法获得。下面就是“反转”部分:好像 ThreadA 被人为临时赋予了一个高于 ThreadB 的优先级,这一切只是因为它持有 ThreadB 所需的锁。
当 ThreadA 释放了锁后,此情况最终会自行解决。遗憾的是,如果涉及到中等优先级的 ThreadC,设想一下会发生什么情况。虽然 ThreadC 不需要锁 L,但它的存在可能会从根本上阻止 ThreadA 运行,这将间接地阻止高优先级 ThreadB 的运行。
最终,Windows Balance Set Manager 线程会注意到这一情况。即使 ThreadC 保持永远可运行状态,ThreadA 最终(四秒钟后)也将接收到操作系统发出的临时优先级提升指令。但愿这足以使其运行完毕并释放锁。但这里的延迟(四秒钟)相当巨大,如果涉及到任何用户界面,则应用程序用户肯定会注意到这一问题。
实现安全性的模式
现在我已经找出了一个又一个的问题,好消息是我这里还有几种设计模式,您可以遵循它们来降低上述问题(尤其是正确性危险)的发生频率。大多数问题的关键是由于状态在多个线程之间共享。更糟的是,此状态可被随意控制,可从一致状态转换为不一致状态,然后(但愿)又重新转换回来,具有令人惊讶的规律性。
当开发人员针对单线程程序编写代码时,所有这些都非常有用。在您向最终的正确目标迈进的过程中,很可能会使用共享内存作为一种暂存器。多年来 C 语言风格的命令式编程语言一直使用这种方式工作。
但随着并发现象越来越多,您需要对这些习惯密切加以关注。您可以按照 Haskell、LISP、Scheme、ML 甚至 F#(一种符合 .NET 的新语言)等函数式编程语言行事,即采用不变性、纯度和隔离作为一类设计概念。
不变性
具有不变性的数据结构是指在构建后不会发生改变的结构。这是并发程序的一种奇妙属性,因为如果数据不改变,则即使许多线程同时访问它也不会存在任何冲突风险。这意味着同步并不是一个需要考虑的因素。
不变性在 C++ 中通过 const 提供支持,在 C# 中通过只读修饰符支持。例如,仅具有只读字段的 .NET 类型是浅层不变的。默认情况下,F# 会创建固定不变的类型,除非您使用可变修饰符。再进一步,如果这些字段中的每个字段本身都指向字段均为只读(并仅指向深层不可变类型)的另一种类型,则该类型是深层不可变的。这将产生一个保证不会改变的完整对象图表,它会非常有用。 所有这一切都说明不变性是一个静态属性。按照惯例,对象也可以是固定不变的,即在某种程度上可以保证状态在某个时间段不会改变。这是一种动态属性。Windows Presentation Foundation (WPF) 的可冻结功能恰好可实现这一点,它还允许在不同步的情况下进行并行访问(但是无法以处理静态支持的方式对其进行检查)。对于在整个生存期内需要在固定不变和可变之间进行转换的对象来说,动态不变性通常非常有用。
不变性也存在一些弊端。只要有内容需要改变,就必须生成原始对象的副本并在此过程中应用更改。另外,在对象图表中通常无法进行循环(除动态不变性外)。
例如,假设您有一个 ImmutableStack