解决多线程代码中的 11 个常见的问题
并发现象无处不在。服务器端程序长久以来都必须负责处理基本并发编程模型,而随着多核处理器的日益普及,客户端程序也将需要执行一些任务。随着并发操作的不断增加,有关确保安全的问题也浮现出来。也就是说,在面对大量逻辑并发操作和不断变化的物理硬件并行性程度时,程序必须继续保持同样级别的稳定性和可靠性。
与对应的顺序代码相比,正确设计的并发代码还必须遵循一些额外的规则。对内存的读写以及对共享资源的访问必须使用同步机制进行管制,以防发生冲突。另外,通常有必要对线程进行协调以协同完成某项工作。
这些附加要求所产生的直接结果是,可以从根本上确保线程始终保持一致并且保证其顺利向前推进。同步和协调对时间的依赖性很强,这就导致了它们具有不确定性,难于进行预测和测试。
这些属性之所以让人觉得有些困难,只是因为人们的思路还未转变过来。没有可供学习的专门 API,也没有可进行复制和粘贴的代码段。实际上的确有一组基础概念需要您学习和适应。很可能随着时间的推移某些语言和库会隐藏一些概念,但如果您现在就开始执行并发操作,则不会遇到这种情况。本文将介绍需要注意的一些较为常见的挑战,并针对您在软件中如何运用它们给出一些建议。
首先我将讨论在并发程序中经常会出错的一类问题。我把它们称为“安全隐患”,因为它们很容易发现并且后果通常比较严重。这些危险会导致您的程序因崩溃或内存问题而中断。
当从多个线程并发访问数据时会发生数据争用(或竞争条件)。特别是,在一个或多个线程写入一段数据的同时,如果有一个或多个线程也在读取这段数据,则会发生这种情况。之所以会出现这种问题,是因为 Windows 程序(如 C++ 和 Microsoft .NET Framework 之类的程序)基本上都基于共享内存概念,进程中的所有线程均可访问驻留在同一虚拟地址空间中的数据。静态变量和堆分配可用于共享。
请考虑下面这个典型的例子:
static class Counter { internal static int s_curr = 0; internal static int GetNext() { return s_curr++; } }
Counter 的目标可能是想为 GetNext 的每个调用分发一个新的唯一数字。但是,如果程序中的两个线程同时调用 GetNext,则这两个线程可能被赋予相同的数字。原因是 s_curr++ 编译包括三个独立的步骤:
- 将当前值从共享的 s_curr 变量读入处理器寄存器。
- 递增该寄存器。
- 将寄存器值重新写入共享 s_curr 变量。
按照这种顺序执行的两个线程可能会在本地从 s_curr 读取了相同的值(比如 42)并将其递增到某个值(比如 43),然后发布相同的结果值。这样一来,GetNext 将为这两个线程返回相同的数字,导致算法中断。虽然简单语句 s_curr++ 看似不可分割,但实际却并非如此。忘记同步这是最简单的一种数据争用情况:同步被完全遗忘。这种争用很少有良性的情况,也就是说虽然它们是正确的,但大部分都是因为这种正确性的根基存在问题。这种问题通常不是很明显。例如,某个对象可能是某个大型复杂对象图表的一部分,而该图表恰好可使用静态变量访问,或在创建新线程或将工作排入线程池时通过将某个对象作为闭包的一部分进行传递可变为共享图表。当对象(图表)从私有变为共享时,一定要多加注意。这称为发布,在后面的隔离上下文中会对此加以讨论。反之称为私有化,即对象(图表)再次从共享变为私有。对这种问题的解决方案是添加正确的同步。在计数器示例中,我可以使用简单的联锁:static class Counter { internal static volatile int s_curr = 0; internal static int GetNext() { return Interlocked.Increment(ref s_curr); } }它之所以起作用,是因为更新被限定在单一内存位置,还因为(这一点非常方便)存在硬件指令 (LOCK INC),它相当于我尝试进行原子化操作的软件语句。或者,我可以使用成熟的锁定:static class Counter { internal static int s_curr = 0; private static object s_currLock = new object(); internal static int GetNext() { lock (s_currLock) { return s_curr++; } } }lock 语句可确保试图访问 GetNext 的所有线程彼此之间互斥,并且它使用 CLR System.Threading.Monitor 类。C++ 程序使用 CRITICAL_SECTION 来实现相同目的。虽然对这个特定的示例不必使用锁定,但当涉及多个操作时,几乎不可能将其并入单个互锁操作中。粒度错误即使使用正确的同步对共享状态进行访问,所产生的行为仍然可能是错误的。粒度必须足够大,才能将必须视为原子的操作封装在此区域中。这将导致在正确性与缩小区域之间产生冲突,因为缩小区域会减少其他线程等待同步进入的时间。例如,让我们看一看 图 1 所示的银行帐户抽象。一切都很正常,对象的两个方法(Deposit 和 Withdraw)看起来不会发生并发错误。一些银行业应用程序可能会使用它们,而且不担心余额会因为并发访问而遭到损坏。class BankAccount { private decimal m_balance = 0.0M; private object m_balanceLock = new object(); internal void Deposit(decimal delta) { lock (m_balanceLock) { m_balance += delta; } } internal void Withdraw(decimal delta) { lock (m_balanceLock) { if (m_balance < delta) throw new Exception("Insufficient funds"); m_balance -= delta; } } }但是,如果您想添加一个 Transfer 方法该怎么办?一种天真的(也是不正确的)想法会认为由于 Deposit 和 Withdraw 是安全隔离的,因此很容易就可以合并它们:class BankAccount { internal static void Transfer( BankAccount a, BankAccount b, decimal delta) { Withdraw(a, delta); Deposit(b, delta); } // As before }这是不正确的。实际上,在执行 Withdraw 与 Deposit 调用之间的一段时间内资金会完全丢失。正确的做法是必须提前对 a 和 b 进行锁定,然后再执行方法调用:class BankAccount { internal static void Transfer( BankAccount a, BankAccount b, decimal delta) { lock (a.m_balanceLock) { lock (b.m_balanceLock) { Withdraw(a, delta); Deposit(b, delta); } } } // As before }事实证明,此方法可解决粒度问题,但却容易发生死锁。稍后,您会了解到如何修复它。读写撕裂如前所述,良性争用允许您在没有同步的情况下访问变量。对于那些对齐的、自然分割大小的字 — 例如,用指针分割大小的内容在 32 位处理器中是 32 位的(4 字节),而在 64 位处理器中则是 64 位的(8 字节)— 读写操作是原子的。如果某个线程只读取其他线程将要写入的单个变量,而没有涉及任何复杂的不变体,则在某些情况下您完全可以根据这一保证来略过同步。但要注意。如果试图在未对齐的内存位置或未采用自然分割大小的位置这样做,可能会遇到读写撕裂现象。之所以发生撕裂现象,是因为此类位置的读或写实际上涉及多个物理内存操作。它们之间可能会发生并行更新,并进而导致其结果可能是之前的值和之后的值通过某种形式的组合。例如,假设 ThreadA 处于循环中,现在需要仅将 0x0L 和 0xaaaabbbbccccddddL 写入 64 位变量 s_x 中。ThreadB 在循环中读取它(参见 图 2)。图 2 将要发生的撕裂现象
internal static volatile long s_x; void ThreadA() { int i = 0; while (true) { s_x = (i & 1) == 0 ? 0x0L : 0xaaaabbbbccccddddL; i++; } } void ThreadB() { while (true) { long x = s_x; Debug.Assert(x == 0x0L || x == 0xaaaabbbbccccddddL); } }您可能会惊讶地发现 ThreadB 的声明可能会被触发。原因是 ThreadA 的写入操作包含两部分(高 32 位和低 32 位),具体顺序取决于编译器。ThreadB 的读取也是如此。因此 ThreadB 可以见证值 0xaaaabbbb00000000L 或 0x00000000aaaabbbbL。
无锁定重新排序有时编写无锁定代码来实现更好的可伸缩性和可靠性是一种非常诱人的想法。这样做需要深入了解目标平台的内存模型(有关详细信息,请参阅 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; }