无锁定重新排序
有时编写无锁定代码来实现更好的可伸缩性和可靠性是一种非常诱人的想法。这样做需要深入了解目标平台的内存模型(有关详细信息,请参阅 Vance Morrison 的文章 "Memory Models:Understand the Impact of Low-Lock Techniques in Multithreaded Apps",网址为 msdn.microsoft.com/magazine/cc163715)。如果不了解或不注意这些规则可能会导致内存重新排序错误。之所以发生这些 错误,是因为编译器和处理器在处理或优化期间可自由重新排序内存操作。
例如,假设 s_x 和 s_y 均被初始化为值 0,如下所示:
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 杂志》文章都介绍了死锁的发生原因以及使死锁变得能够接受的一些方法,其中包括我自己的文章 "No More Hangs:Advanced Techniques 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,则代码可能发生死锁。
使用一致的获得顺序可避免死锁,此逻辑可概括为“同步锁获得”之类的名称,通过此操作可依照各个锁之间的某种顺序动态排序多个可锁定的对象,从而使得在 以一致的顺序获得两个锁的同时必须维持两个锁的位置。另一个方案称为“锁矫正”,可用于拒绝被认定以不一致的顺序完成的锁获得。
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)。
此问题的另一个常见根源是自动重置事件和手动重置事件信号丢失。由于此类事件只能处于一个状态(有信号或无信号),因此用于设置此事件的冗余调用实际上将被忽略不计。如果代码认定要设置的两个调用始终需要转换为两个唤醒的线程,则结果可能就是唤醒丢失。