[转]关于多线程并发:每个开发人员都应了解的内容

Concurrency: What Every Dev Must Know About Multithreaded Apps


本文讨论:

 

  • 多线程和共享内存线程模型
  • 争用及并发访问如何能够打破不变量
  • 作为争用标准解决方案的锁定
  • 何时需要锁定
  • 如何使用锁定;理解开销
  • 锁定如何能够各行其道

 

十年前,只有核心系统程序员会担心在多个执行线程的情况下编写正确代码的复杂性。绝大多数程序员编写的是顺序执行程序,可以彻底避免这个问题。但是现在,多处理器计算机正在普及。很快,非多线程程序将处于劣势,因为它们无法利用可用计算资源中很大的一部分。

不幸的是,编写正确的多线程程序并不容易。这主要是因为程序员们还没有习惯“其他线程可能正在改变不属于它们下面的内存”这种思维方式。更糟糕的是,出现错误时,程序在绝大多数时候会继续运行下去。只有在有压力(正式运行)条件下,Bug 才会显示出来;发生故障时,极少有足够的信息可供有效地调试应用程序。图 1 汇总了顺序执行程序和多线程程序之间的主要不同之处。如图所示,要让多线程程序一次成功需要很大的精力。

concurrency1

图1

本文有三个目标。首先,我将说明多线程程序并不是那么神秘。无论程序是顺序运行的还是多线程的,编写正确程序的基本要求是一样的:程序中的所有代码都必须保护程序其他部分需要的任何不变量。其次,我将证明虽然这条原则非常简单,但在多线程情形中,保护程序不变量要困难得多。通过示例将能看出,顺序运行环境中的小细节在多线程环境中具有惊人的复杂性。最后,我将告诉您如何对付可能潜伏在多线程程序中的复杂问题。本指南总结了非常系统地保护程序不变量的策略。如图 2 中的表格所示,这在多线程情形中更加复杂。造成这种在使用多线程时更加复杂的原因有很多,我将在以下各节中进行解释。

concurrency2

图2

 

线程和内存

就核心部分而言,多线程编程似乎非常简单。在这种模式下,不是仅有一个处理单元按顺序进行工作,而是有两个或多个处理单元同时执行。因为多个处理器可能是真实的硬件,也可能是通过对单个处理器进行时序多路复用来实现的,所以我们使用术语“线程”来代替处理器。多线程编程的棘手之处在于线程之间的通讯方式。

图 3 共享内存线程模型
图 3 共享内存线程模型

最常部署的多线程通讯模型称为共享内存模型。在此模型中,所有线程都可以访问同一个共享内存池,如图 3 所示。此模型的优势在于,编写多线程程序的方式与顺序执行程序几乎相同。但这种优势同时也是它最大的问题。该模型不区分正在严格地供线程局部使用的内存(例如局部声明的变量)与正在用于和其他线程通讯的内存(例如某些全局变量及堆内存)。因为与分配给线程的局部内存相比,可能会被共享的内存需要更仔细地处理,所以非常容易犯错误。

 

争用

设想一个处理请求的程序,它拥有全局计数器 totalRequests,每完成一次请求就计数一次。正如您看到的,对于顺序执行程序来说,进行此操作的代码非常简单:

totalRequests = totalRequests + 1

但如果程序采用多线程来处理请求和更新 totalRequests,就会出现问题。编译器可能会把递增运算编译成以下机器码:

MOV EAX, [totalRequests]  // load memory for totalRequests into register
INC EAX // update register
MOV [totalRequests], EAX // store updated value back to memory

考虑一下,如果两个线程同时运行此代码,会产生什么结果?如图 4 所示,两个线程会加载同一个 totalRequests 值,均进行递增运算,然后均存回 totalRequests。最终结果是,两个线程都处理了请求,但 totalRequests 中的值只增加了一。这显然不是我们想要的结果。类似这种因为线程之间计时错误而造成的 Bug 称为争用。

图 4 争用图解
图 4 争用图解

虽然这个示例看上去比较简单,但对于更复杂的实际争用,问题的基本结构是一样的。要形成争用,需要具备四个条件。

第一个条件是,存在可以从多个线程中进行访问的内存位置。这类位置通常是全局/静态变量(例如 totalRequests 的情形)或可从全局/静态变量中进行访问的堆内存。

第二个条件是,存在程序正常运行必需的与这些共享内存位置关联的属性。在此示例中,该属性即为 totalRequests 准确地表示任何线程已执行的递增语句的任何一部分的总次数。通常,属性需要在更新发生前包含 true 值(即 totalRequests 必须包含准确的计数),以保证更新是正确的。

第三个条件是,在实际更新的某一段过程中,属性不包含值。在此特定情形中,从捕获 totalRequests 到存储它,totalRequests 不包含不变量。

发生争用的第四个,也是最后一个必备条件是,当打破不变量时有其他线程访问内存,从而造成错误行为。

 

锁定

防止争用的最常见方法是使用锁定,在打破不变量时阻止其他线程访问与该不变量关联的内存。这可以避免上述争用成因中的第四个条件。

最通用的一类锁定有多个不同的名称,例如监视程序、关键部分、互斥、二进制信号量等。但无论叫什么,提供的基本功能是相同的。锁定可提供 Enter 和 Exit 方法,一个进程调用 Enter 后,其他进程调用 Enter 的所有尝试都会导致自己受阻(等待),直到该进程调用 Exit。调用 Enter 的线程是锁定的拥有者,如果非锁定拥有者调用了 Exit,将被视为编程错误。锁定提供了确保在任何给定的时间,只有一个进程能够执行特定代码区域的机制。

在 Microsoft® .NET Framework 中,锁定是由 System.Threading.Monitor 类来实现的。Monitor 类略微与众不同,因为它不定义实例。这是由于锁定功能是由 System.Object 有效地实现的,这样能够锁定任何对象。以下是如何使用锁定来解决与 totalRequests 相关的争用:

static object totalRequestsLock = new Object();  // executed at program
// init
...
System.Threading.Monitor.Enter(totalRequestsLock);
totalRequests = totalRequests + 1;
System.Threading.Monitor.Exit(totalRequestsLock);

虽然这段代码确实解决了争用,但它会带来另一个问题。如果在锁定过程中发生了异常,那么将不会调用 Exit。这将导致尝试运行此代码的所有其他线程永远被阻。在许多程序中,任何异常都会被视作程序的致命错误,因此在那些情形中发生的事情并不有趣。 但对于希望能够从异常中恢复的程序来说,在 finally 子句中放入对 Exit 的调用可以增加解决方案的健壮性:

System.Threading.Monitor.Enter(totalRequestsLock);
try {
totalRequests = totalRequests + 1;
} finally {
System.Threading.Monitor.Exit(totalRequestsLock);
}

此模式很常用,C# 和 Visual Basic® .NET 都有支持它的特殊语句结构。以下 C# 代码和前例中的 try/finally 语句等效:

lock(totalRequestsLock) {
totalRequests = totalRequests + 1;
}
就个人而言,我对 lock 语句的心情是自相矛盾的。一方面,它是一个方便的捷径。但另一方面,它让程序员们对自己正在编写的代码是否健壮心存疑虑。请记住,引入锁定区域是因为重要的程序不变量没有使用该区域。如果在该区域中引发异常,那么很可能在引发异常的同时打破了不变量。允许程序在不尝试修复不变量的情况下继续下去是个不恰当的做法。

在 totalRequests 示例中,没有有用的清理可做,所以 lock 语句是适当的。此外,如果 lock 语句主体所做的一切都是只读的,那么 lock 语句也是适当的。但总体而言,如果发生异常,那么需要做更多的清理工作。在此情形中,lock 语句不会带来太多价值,因为无论如何都将需要明确的 try/finally 语句。

 

正确使用锁定

大多数程序员都遇到过争用,也明白如何使用锁定来防止争用的简单示例。但如果没有详细解释,示例自身并不能使人了解在实际程序中有效使用锁定的重要原则。

我们最重要的观察结果是,锁定为代码区域提供互斥,但总体上,程序员想要保护内存区域。在 totalRequests 示例中,目标是确定不变量包含 totalRequests 上的 true 值(内存位置)。但为了做到这一点,我们实际在代码区域附近放置了锁定(totalRequests 的增量)。这提供了围绕 totalRequests 的互斥,因为它是唯一引用 totalRequests 的代码。如果有不进入锁定便更新 totalRequests 的其他代码,那么将失去内存互斥,继而代码将具备争用条件。

这引申出以下原则。对于为内存区域提供互斥的锁定,不进入同一锁定便不得写入该内存。在正确设计的程序中,与每个锁定相关联的是该锁定提供互斥的内存区域。不幸的是,至今还没有一种可行的编码方式能够让这种关联变得清晰,而这个信息对于要推论程序的多线程行为的任何人来说,无疑都是至关重要的。

以此推断,每个锁定都应有一个与之关联的说明,其中记载了该锁定为之提供互斥的准确内存区域(数据结构集)。在 totalRequests 的示例中,totalRequestsLock 保护 totalRequests 变量,仅此而已。在实际程序中,锁定尝试保护更大的区域,例如整个数据结构、若干相关数据结构或者从数据结构中可以访问的所有内存。有时锁定仅保护数据结构的一部分(例如哈希表的哈希存储桶链),但不论保护的区域到底为何,重要的是程序员要记下它。有了写下的说明,才有可能通过系统的方法来验证在更新关联内存之前,是否已进入相关锁定。绝大多数争用是由于未能保证在访问相关内存之前,一定要进入正确锁定而引起的,所以在这个审核步骤上花时间是值得的。

每个锁定都具备对自己要保护的内存的准确说明之后,请检查是否有受不同锁定保护的任何区域发生重叠现象。虽然重叠并不一定是错误的,但也应避免,因为与两个不同锁定相关联的内存发生重叠是毫无用处的。考虑一下,两个锁定共用的内存需要更新时会发生什么?又该使用哪个锁定呢?可能的做法包括:

任意进入其中一个锁定  这种做法是不可取的,因为它不再提供互斥。如果采取这种做法,两个不同的更新站点可以选择不同的锁定,然后同时更新同一个内存位置。

始终进入两个锁定  这将提供互斥,但需要两倍开销,且与仅让该位置拥有一个锁定相比,没有提供任何优势。

始终进入其中一个锁定  这与仅有一个锁定保护该特定位置是等效的。

 

需要多少个锁定?

为了说明下一个要点,示例略微更加复杂。这次我们不是只有一个 totalRequests 计数器,而是有两个不同的计数器,分别用于高/低优先级的请求。totalRequests 不是直接存储,而是按以下方法计算:

totalRequests = highPriRequests + lowPriRequests; 
程序需要的不变量是:两个全局变量之和等于任何线程已处理请求的次数。与上一个示例不同,此不变量涉及两个内存位置。这立即带来是需要一个锁定还是两个锁定的问题。对这个问题的回答取决于设计目的。

拥有两个锁定(一个用于 highPriRequests,另一个用于 lowPriRequests)的主要优势在于它允许更多的并发。如果一个线程尝试更新 highPriRequests,另一个线程尝试更新 lowPriRequests,但只有一个锁定,那么一个线程必须等待另一个线程。如果有两个锁定,那么每个线程都可以继续运行,而不会发生争用。在示例中,这对并发的改善是微乎其微的,因为对单个锁定的采用相对较少,且占用的时间不会太长。但是想像一下,如果锁定正在保护处理请求期间频繁使用的表,情况又会怎样。在此情形中,最好只锁定表的某些部分(例如哈希存储桶条目),以便若干线程能够同时访问表。

具备两个锁定的主要劣势是因此带来的复杂性。很显然,程序关联的部分多了,程序员犯错的机会也就多了。这种复杂性随着系统中锁定数量的增多而快速提高,所以最好是使用较少的锁定来保护较大的内存区域,且仅当锁定争用即将成为性能瓶颈时才分割这些内存区域。

最极端的情况是,程序可以只有一个锁定,它保护可从多个线程中访问的所有内存。对于请求-处理示例,如果线程无需访问共享数据便可处理请求,这种设计会工作得很好。如果处理请求需要线程对共享内存进行多次更新,那么单个锁定将成为瓶颈。在此情形中,需要将由一个锁定保护的单一较大内存区域分割成若干不重叠的子集,每个子集都由自己的锁定来保护。

 

对读取采用锁定

到目前为止,我已经展示了写入内存位置之前,应始终进入锁定的做法,但尚未讨论读取内存时应采取的做法。读取的情况略微更加复杂,因为它取决于程序员的期望值。让我们回到上例,假设您决定读取 highPriRequests 和 lowPriRequests 来计算 totalRequests:

totalRequests = highPriRequests + lowPriRequests;
在此情形中,我们期望这两个内存位置中的值相加,得到准确的总请求数。这只有在进行计算时,两个值都没有发生变化的情况下才能实现。如果每个计数器都有自己的锁定,那么在能够求和之前,需要进入两个锁定。

相比之下,递增 highPriRequests 的代码仅需要采用一个锁定。这是因为,更新代码使用的唯一不变量是 highPriRequests 为一个准确的计数器;lowPriRequests 决不会被涉及。一般来说,代码需要程序不变量时,必须采用与涉及不变量的任何内存相关联的所有锁定。

图 5 显示了有助于解释这一点的一个类比示例。将计算机内存想像成具有数千个窗口的吃角子老虎机,每个窗口对应一个内存位置。启动程序时,就像拉动吃角子老虎机的手柄。随着其他线程对内存值的更改,内存位置开始旋转。一个线程进入锁定时,与锁定相关联的位置将停止旋转,因为代码始终遵循在尝试进行更新前必须获得锁定的约定。该线程可以重复此过程,获得更多的锁定,导致更多的内存冻结,直到该线程需要的所有内存位置都稳定下来。现在,该线程能够执行操作,不会受到其他线程的干扰。

图 5 交换受锁定保护的值的五个步骤
图 5 交换受锁定保护的值的五个步骤

这个类比示例可以帮助程序员改变观念,从过去认为在确实发生变化之前什么都没变,转变为相信一切都在变化,除非使用锁定进行保护。构造多线程应用程序时,采用这种新观念是最重要的一条忠告。

 

什么内存需要锁定保护?

我们已经探讨了如何使用锁定来保护程序不变量,但我还没有确切说明什么内存需要这种保护。一个简单且正确的回答是,所有内存都需要由锁定来保护,但这对绝大多数应用程序来说未免有些过头了。

以下多种途径中的任何一种都能保证内存在多线程使用中的安全。首先,仅由一个线程访问的内存是安全的,因为其他线程不会受其影响。这包括绝大多数局部变量和发布之前的所有堆分配内存(发布后其他线程可以访问)。但内存发布后,便不在此列,必须使用其他一些技术。

其次,发布后处于只读状态的内存不需要锁定,因为与之关联的任何不变量都必须为程序剩余部分保留(由于值不变)。

然后,主动从多个线程中更新的内存通常使用锁定来确保在打破程序不变量时只有一个线程具有访问权限。

最后,在某些程序不变量相对较弱的特殊情形中,可以执行无需锁定便能完成的更新。此时通常会运用专门的比较并交换指令。最好将这些技术视作锁定的轻型特殊实现。

程序中使用的所有内存都应属于上述四种情形之一。此外,最后一种情形显然更加脆弱,更易出错,因此仅当对性能的要求远超出相关风险时,才应格外小心地使用。我将有专文讨论这种情形。暂时排除这种情形之后,通用规则变为所有程序内存都应属于以下三种情形之一:线程独占、只读或受锁定保护。

 

系统化的锁定

在实践中,绝大多数重要的多线程程序都带有不少争用漏洞。问题的主要症结在于,程序员们完全不清楚何时需要锁定,这正是我下面要澄清的。但仅仅了解这一点还不够。只要漏掉一个锁定就会引入争用,所以仍然非常容易犯错。我们需要用强大的系统方法来帮助避免简单但易犯的错误。然而,即便是当前最好的技术,也需要分外小心才能很好地应用。

对于系统化的锁定而言,最简单、最有用的技术之一是监视程序的概念。这一概念的基本思想是附加在面向对象的设计中已存在的数据抽象之上。想像一个哈希表的示例。在精心设计的类中,已假设客户端仅通过调用类的实例方法来访问类的内部状态。如果对任何实例方法的入口采用锁定,在退出时释放,那么就可以使用系统的方法确保仅当已获得锁定时才会发生对内部数据(实例字段)的所有访问,如图 6 所示。遵循此协议的类称为监视程序。

图 6 使用 Monitor 类
图 6 使用 Monitor 类

在 .NET Framework 中,锁定的名称是 System.Threading.Monitor,无一例外。此类型是为了支持监视程序概念而专门设计的。.NET 锁定对 System.Object 进行操作的原因是为了使监视程序的创建更加轻松。为提高效率,每个对象都有一个内置锁定,可用于保护自己的实例数据。通过在 lock(this) 语句中嵌入每个实例方法的主体,就可以形成监视程序。此外,还有一个特殊属性 [MethodImpl(MethodImplAttributes.Synchronized)],它可以放置在实例方法上,以便自动插入 lock(this) 语句。另外,.NET 锁定是可重入的,这表示已进入锁定的线程无需阻止便可再次进入该线程。这允许方法调用同一个类上的其他方法,而不会导致通常会发生的死锁。

虽然监视程序非常有用,且使用 .NET 很容易编写,但它决不是解决锁定问题的万能方法。如果不加区别地使用,会得到要么过小要么过大的锁定。考虑一个应用程序,它使用图 6 中所示的哈希表来实现更高级别的运算(称为 Debit),将货币从一个帐户转入另一个帐户。Debit 方法对哈希表使用 Find 方法来检索两个帐户,使用 Update 方法来实际执行转帐操作。因为哈希表是一个监视程序,所以对 Find 和 Update 的调用可以保证是原子式进行的。不幸的是,Debit 方法需要的远不止此原子性保证。如果在 Debit 对 Update 进行的两次调用之间,有另一个线程更新了其中一个帐户,那么 Debit 将会出错。监视程序在单一调用中对哈希表保护得很好,但是若干调用过程中需要的不变量丢失了,因为进行的锁定过小。

用监视程序修复 Debit 方法中的争用问题会导致过大的锁定。我们需要的是能够保护 Debit 方法使用的所有内存的锁定,且在方法持续期间能够保持锁定。如果使用监视程序来实现此目的,那它应如图 7 中所示的 Accounts 类。每个高级别操作(例如 Debit 或 Credit)都将在执行自己的操作前,获得对 Accounts 的锁定,因而提供所需的互斥。创建 Accounts 监视程序可以修复争用问题,但接下来又出现了哈希表到底需要多少个锁定的问题。如果对 Accounts 的所有访问(继而对哈希表的所有访问)都获得 Accounts 锁定,那么访问哈希表(这是 Accounts 的一部分)的互斥已经得到了保证。如果确实如此,那么拥有哈希表锁定的开销便是没有必要的。进行的锁定过多。

图 7 只有顶层需要监视程序
图 7 只有顶层需要监视程序

监视程序概念的另一个重要弱点是,如果类将可更新指针分发给其数据,它就不提供保护。例如,类似哈希表上的 Find 这样的方法经常会返回一个调用方可以更新的对象。因为这些更新可以在对哈希表的任何调用之外发生,所以它们不受锁定的保护,这就破坏了希望监视程序提供的保护。最终,当需要采用多个锁定时,监视程序完全没有办法应对这种更加复杂的情况。

监视程序是个有用的概念,但它们只是一个工具,用于实现精心设计出的锁定。有时,锁定保护的内存与数据抽象自然一致。此时,监视程序是实现锁定设计的最佳机制。但在有些时候,一个锁定将保护多个数据结构,或是仅保护数据结构的一部分。在这些情形中,监视程序便不太适合。目前还不可避免地要进行精确定义系统需要哪些锁定以及每个锁定保护哪些内存之类的艰辛工作。现在,让我们试着总结出可以帮助进行此设计的若干指导原则。

总体而言,绝大多数可重复使用的代码(例如容器类)都不应内建锁定,因为这种代码只能保护自己,而且无论代码使用什么锁定,它似乎都会需要一个更强大的锁定。但是,如果必须使代码在出现高级别程序错误时也能正常工作,这条规则就不再适用。全局内存堆和对安全性非常敏感的代码都是例外情形的示例。

采用保护大量内存的少数锁定不易出错,且效率更高。如果允许所需并发数的话,保护多个数据结构的单个锁定是个不错的设计。如果每个线程的工作不要求对共享内存进行多次更新,那么我会将这个原则运用到极致,采用保护所有共享内存的一个锁定。这会使程序的简单性几乎可与顺序执行程序相媲美。对于工作线程之间没有太多交互的应用程序,它会工作得很好。

在线程频繁读取共享结构,但只是偶尔写入的情形中,可以使用类似 System.Threading.ReaderWriterLock 这样的读写锁将系统中的锁定数量保持在较少的水平。这类锁定有一个进入读取操作的方法和一个进入写入操作的方法。锁定将允许多个读取操作同时进入,但写入操作获得的访问是独占式的。这样,因为读取操作不会阻止其他操作,所以系统可以做得更简单(包含更少的锁定),且仍可获得所需的并发性。

不幸的是,即使有了这些指导原则的帮助,设计高并发的系统仍然基本上比编写顺序执行系统要困难得多。锁定的使用经常会与面向对象的普通程序抽象发生冲突,因为锁定确实是程序的另一个独立维度,有着自己的设计标准(其他类似维度包括生命周期管理、事务行为、实时限制和异常处理行为)。 有时对锁定的需要和对数据抽象的需要是一致的,例如当两者都用于控制对实例数据的访问时。但有时它们也会发生冲突(监视程序的嵌套没有太大用处,指针会使监视程序发生“泄漏”)。

对于这种冲突没有太好的解决方法。最终使得多线程程序更加复杂。我们有办法控制复杂性。您已经看到了一种策略:尝试在数据抽象层次结构的顶层使用少量锁定。然而此策略也会与模块性发成冲突,因为很多数据结构很可能是由一个锁定来保护。这意味着,没有一个明显的数据结构可供从它上面挂起锁定。通常来说,锁定需要的是全局变量(对于读取/写入数据而言,这永远不是一个真正的好想法),或是所涉及的最大全局数据结构的一部分。在后一种情形中,必须能够从需要锁定的任何其他结构中访问该结构。有时这是一项困难的工作,因为可能需要为某些方法添加额外的参数,设计可能变得有些混乱,但这比其他解决方法好得多。

设计中开始显示出类似这样的复杂性时,正确的反应是让它变得清晰明确,而不是忽略它。如果某些方法自身没有获得锁定,但期望自己的调用方提供该互斥,那么这种要求是调用该方法的前提条件,应包含在它的接口约定中。另一方面,如果数据抽象可以获得锁定或在保持锁定的同时调用客户端(虚拟)方法,那么这也需要是接口约定的一部分。只有让接口之间的这些细节变得清晰明确之后,才能为局部代码做出正确的决定。在好的设计中,这些约定中的绝大多数都是很简单的。被调用方期望调用方已提供对整个相关数据结构的独占,所以指定这一点并不是太困难。

在理想环境下,并发设计的复杂性可以隐藏在类库中。不幸的是,类库设计人员几乎没有办法可以让库对多线程更加友好。如哈希表示例所示,锁定只是一个用途极少的数据结构。仅当设计可以添加锁定的程序线程结构时,锁定的作用才能表现出来。通常这使得发挥锁定的作用成为应用程序开发人员的责任。只有类似 ASP.NET 这样,定义了可以插入最终用户代码的线程结构的整体框架才能帮助它们的客户端减轻认真设计和分析锁定的压力。

 

死锁

避免系统中具有多个锁定的另一个原因是死锁。一旦程序中有多个锁定,便有可能发生死锁。例如,如果一个线程尝试依次进入锁定 A 和锁定 B,而与此同时,另一个线程尝试依次进入锁定 B 和锁定 A,那么在每个线程进入另一个线程在尝试进入第二个锁定之前拥有的那个锁定时,它们之间有可能会发生死锁。

从编程角度来看,通常有两种方法可以防止死锁。防止死锁的第一种方法(也是最好的一种)是,对于永远不需要一次获得多个锁定的系统,不要让系统中有足够多的锁定。如果这可行的话,可以通过约定获得锁定的顺序来防止死锁。仅当存在符合以下条件的线程循环链时,才能形成死锁:链中的每个线程都在等待已被队列中下一个线程获得的锁定。为防止这样,系统中的每个锁定都分配有“级别”,且对程序进行了相关设计,使得线程始终都只严格地按照级别递减顺序获得锁定。此协议使周期包含锁定,因而不会形成死锁。如果此策略无效(找不到一组级别),那么很可能是程序的锁定获得行为取决于输入,此时最重要的是保证在每种情形中都不会发生死锁。通常情况下,这类代码会借助超时或某些死锁检测方案来解决问题。

死锁是将系统中的锁定数量保持在较少状态的另一个重要原因。如果无法做到这一点,那么必须进行分析,决定为什么必须同时获得多个锁定。请记住,仅当代码需要独占访问受不同锁定保护的内存时,才需要获得多个锁定。这种分析通常要么生成可避免死锁的简单锁定排序,要么表明彻底避免死锁是不可能的。

 

锁定的开销

避免系统中有多个锁定的另一个原因是进入和离开锁定的开销。最轻型的锁定使用特殊的比较/交换指令来检查是否已获得锁定,如果没有,它们将以单一原子操作进入锁定。不幸的是,这种特殊指令的开销相对较大,耗时通常是普通指令的十倍至数百倍。造成这种大开销的主要原因有两个,对于真实的多处理器系统发生的问题,它们都是必须的操作。

第一个原因是,比较/交换指令必须保证没有其他处理器也在尝试进行同样的操作。从根本上说,这要求一个处理器与系统中的所有其他处理器进行协调。这是一个缓慢的操作,形成了锁定开销的下限(数十个周期)。造成大开销的另一个原因是,内存系统的内部处理通讯效果。获得锁定后,程序很有可能要访问刚被另一个线程修改过的内存。如果此线程是在另一个处理器上运行的,那么有必要保证所有其他处理器上的所有挂起的写入都已刷新,以便当前线程看到更新。执行此操作的开销在很大程度上取决于内存系统的工作方式以及有多少写入需要刷新。在最糟糕的情形中,这一开销会很大,可能多达数百个周期或更多。

因此,锁定的开销有着重大意义。如果一个被频繁调用的方法需要获得锁定,且仅执行一百条左右的指令,内存开销很可能会成为一个问题。此时通常需要重新设计程序,以便能为更大的工作单元占用锁定。

除了进入和离开锁定的原始开销之外,随着系统中处理器数量的增加,开销会成为有效使用所有处理器的主要障碍。如果程序中的锁定过少,可能会使所有处理器都处于繁忙状态,因为它们要等待被另一个处理器锁定的内存。另一方面,如果程序中的锁定过多,那么很容易出现一个被多个处理器频繁进入和退出的“热”锁定。这会导致极高的内存刷新开销,且吞吐量也不与处理器的数量成正比。实现吞吐量良性增减的唯一设计是其中的工作线程无需和共享数据交互,便能完成大多数工作。

无疑,性能问题可能使我们希望彻底避免锁定。在特定的约束环境中,这是可以做到的,但正确实现这一点涉及到的细节远比使用锁定让互斥正确发挥作用多得多。只有绝对必要时,才可在充分了解相关问题之后使用这种方法。我将有另文专门讨论这个问题。

 

同步概述

虽然锁定提供了让线程各行其道的方法,但没有提供让线程合作(同步)的机制。我将简单介绍同步线程,但不引入争用的原则。您将看到,它们并不比适当锁定的原则困难多少。

在 .NET Framework 中,同步功能是在 ManualResetEvent 和 AutoResetEvent 类中实现的。这些类提供 Set、Reset 和 WaitOne 方法。WaitOne 方法会在事件处于重置状态期间使线程受阻。当另一个线程调用 Set 方法时,AutoResetEvents 将允许调用 WaitOne 的那个线程取消阻止,而 ManualResetEvents 将允许所有等待的线程取消阻止。

通常使用事件来表明存在一个更复杂的程序属性。例如,程序可能具有线程的工作队列,并使用事件向线程报告队列不为空。请注意,这引入了一个程序不变量,即当且仅当队列不为空时,才应设置事件。适当锁定的规则要求,如果代码需要不变量,那么必须存在为与该不变量相关联的所有内存提供独占访问的锁定。在队列中应用此原则,得到的建议是:所有对事件和队列的访问都仅应在进入通用锁定之后进行。

不幸的是,这种设计会导致死锁。让我们看看下面这个示例。线程 A 进入锁定,需要等待队列被填充(同时拥有队列的锁定)。线程 B 要将线程 A 需要的条目添加到队列,它在修改队列之前,将尝试进入队列的锁定,因而受阻于线程 A。形成死锁!

一般来说,在等待事件的同时占用锁定不是什么好事。毕竟,为什么在一个线程等待其他事情的时候,要将所有其他线程都锁于数据结构之外呢?您也许会提到死锁。常见的做法是释放锁定,然后等待事件。但是现在,事件和队列可能没有同步。我们已经打破了“事件是队列何时不为空的精确指示器”这个不变量。典型的解决方案是,将此情形中的不变量弱化为“如果事件被重置,则队列为空”。此新不变量足够强大,等待事件仍然是安全的,不会有永远等下去的风险。这个宽松的不变量意味着,当线程从 WaitOne 中返回时,它不能假设队列中已存在条目。苏醒的线程必须进入队列的锁定,验证该队列中存在条目。如果没有(例如一些其他线程移除了条目),它必须再次等待。如果线程之间的公平性非常重要,那么此解决方案有问题,但对于绝大多数用途,它工作得很好。

 

总结

编写优秀的顺序执行程序和多线程程序的基本原则并没有太大不同。在这两种情形中,整个基本代码都必须保护程序中其他地方需要的不变量。如图 2 所示,不同之处在于,在多线程情形中保护程序不变量更加复杂。结果是,构建正确的多线程程序需要等级高得多的准则。此准则的一部分是确保通过诸如监视程序这样的工具,所有线程共享的读-写数据都得到锁定的保护。另一部分是精心设计哪些内存由哪个锁定来保护,并控制锁定必然为程序带来的额外复杂性。在顺序执行程序中,好的设计通常就是最简单的设计。而对于多线程程序,这意味着拥有能实现所需并发性的最少数量的锁定。如果保持锁定设计的简洁,并系统地跟踪锁定设计,您就一定能编写出没有争用的多线程程序。


Vance Morrison是 Microsoft 的 .NET 运行时编译器架构师,他从 .NET 运行时开始设计之日起便介入相关工作。他推动了 .NET 中间语言 (IL) 的设计,并领导实时 (JIT) 编译器团队。

 


 

其他后续文章:

posted on 2008-04-13 21:11  Mainz  阅读(5279)  评论(0编辑  收藏  举报

导航