最近重读《the art of multiprocessor programming》,从初读时的不知所云,到二读时的不以为然,再到再读时字字珠玑深以为然。由此深知遇到一本书,不是你看见它的时候,也不是你买了它的时候,甚至都不是你读过它的时候,而是你深刻的与它产生了共鸣的时候。于是决定有精力的话会将其逐篇的翻译出来,以供自己进一步的加深理解。另外,个人感觉英语作为一门层次化的结构立体性的语言,其突出重点,并随时可以自然的为其中的任何概念增加注释(从句)的特性使其更加适合技术性文章。这也是为什么很多时候看英文的注释或文献会感觉讲的很清晰,而看中文的介绍和说明反而会觉得讲不清楚。中文线性化及藏头露尾的语言特性使其表达非常具体的技术细节时总觉得有些捉襟见肘。在此先尝试着将说明我们为什么需要并发编程相关知识的背景--多核系统硬件基础(附录B)翻译如下
------------------------------------------------------------------------------------------------------
一个菜鸟学生正在试着关掉电源再打开来修复一个死机的电脑。Knight发现了这个学生正在干的事并厉声说道:“你不能不了解哪里出错就仅靠重启来修复一个计算机。”然后Knight将机器关掉再启动,于是它就工作正常了。(摘自"AI Koans",一个在80年代广泛流传在麻省理工学院的笑话集)
B.1 简介(以及一个疑惑)
你无法在不了解多处理器是什么的情况下编写一个有效的多线程程序。在不了解计算机体系结构的前提下可以很好的编写单线程程序,但是对于多线程程序而言情况就有所不同了。我们将会通过一个令人疑惑的场景来展示这一点。我们将会考虑两个在逻辑功能上相同的程序,只不过一个没有另一个高效。令人不爽的是,更简单的那个程序却是更低效的。如果不了解现代计算机的多处理器架构,则既无法解释上述不合常理,也不能避免这样的危险。
如下是该疑惑的背景说明。假设两个线程共享一个资源,该资源每次只能被一个线程使用。为了防止同时使用,每一个线程必须在使用该资源之前锁定它,并在使用之后为其解锁。我们将在第7章学习很多实现锁的方式。对于本次的场景来说,我们考虑两个简单的实现,在这两个实现中都会使用一个单独的Boolean属性来代表锁。如果该属性值为false,则说明未上锁,否则说明已上锁。我们通过getAndSet(v)方法来操作这把锁,getAndSet(v)方法会原子性的将参数中的值设置到属性中并返回属性中原来的值。为了得到锁,线程会调用getAndSet(true)。如果返回的值为false,则说明原来锁是空闲的,调用者就成功的获取了该锁。否则,若返回值为true说明该对象早已经被上锁了,本线程必须稍后再次尝试。一个线程简单的通过将对应属性设置为false就可以释放锁了(无需getAndSet操作)。
在代码B.1中,test-and-set锁(TASLock)会重复地调用getAndSet(true),直到该方法返回false为止。相比之下,在代码B.2中test-and-test-and-set锁(TTASLock)重复地读取表示锁的属性值(通过调用state.get(),在第5行)直到该值变为false,只有到这个时候才会调用getAndSet()(第6行)。了解下面的情况是很重要的--读取锁属性的值是原子性的,应用getAndSet()也是原子性的,但是两者组合起来不是原子性的:在一个线程读取锁属性值与调用getAndSet()方法中间,锁属性可能会被其他线程修改。
public class TASLock implements Lock {
...
public void lock() {
while(state.getAndSet(true)) {} //spin
}
...
}
Figure B.1 TASLock类
public class TTASLock implements Lock {
...
public void lock () {
while (true) {
while (state.get()) {} //spin
if (!state.getAndSet(true))
return;
}
}
...
}
Fiture B.2 TTASLock类
在继续前进之前,你应该说服自己TASLock和TTASLock算法在逻辑功能上是相同的。原因很简单:在TTASLock算法中,读取到锁处于可用状态并不能保证紧接着的getAndSet()调用一定能成功,因为可能有其他线程在这个间隙捷足先登得到了锁。所以为什么要搞这么麻烦先读取锁的状态再尝试获取它呢?
这里就是令人疑惑的现象。虽然两个锁实现在逻辑功能上是等价的,但是他们的性能表现非常不同。在1989年的一个经典的实验中,Anderson在几个当时的多核处理器上测量了一个简单测试程序的执行时间。他测量了n个线程并发执行一个短暂的临界区各一百万次所消耗的时间。图表B.3展示了每个锁实现消耗的时间,在图上被描绘成一个使用线程数的函数。在一个完美的世界里,TASLock和TTASLock的曲线都应该和在底部的理想化曲线一样平坦,因为每一个线程的run方法都会执行相同数目的一百万次工作量。然而,我们看到两者的曲线都向上倾斜了,说明当线程数增加时,由锁引入的延迟也在增加。然而奇怪的是,TASLock比TTASLock的性能要慢很多,特别是当线程数增加的时候。这是为什么呢?
本章节覆盖了很多你想要编写高效的并发算法及数据结构必须知道的多处理器体系结构知识。(顺带着,我们将会解释在图表B.3中性能曲线所表现出的差异)
我们将会涉及到如下的组件:
- 处理器是执行线程的硬件设备。通常会有比处理器个数更多的线程,每个处理器会执行一个线程一小会儿,然后将其放在一边,转头去执行其他线程。
- 互联设备是一个通信中介,该通信中介连接不同的处理器,以及处理器与存储器。
- 存储器实际上是一个保存数据的层次结构的组件,涉及到一层或多层很小但是很快的缓存,以及一个很大但是相对较慢的主内存。了解这些不同层次的存储设备是如何交互的,对于理解很多并发算法的实际性能表现是至关重要的。
从我们的角度来看,一个架构上的原则驱动了所有的事情:处理器和主内存离得很远。处理器需要花费很长时间从主内存中读取一个值。处理器同样要花费很长时间来将一个值写到内存中,还要花更长的时间来确保该值确实被装载到内存中了。访问内存更像是邮寄一封信而不是打一个电话。我们本章考察的几乎所有东西都是为了尝试减轻访问内存的高延迟而产生的。
处理器和内存的速度都随着时间的推移而变化,但是它们之间的相对性能变化比较缓慢。让我们考虑一下如下的类比。想象一下现在是1980年,你负责曼哈顿市中心的送信业务。虽然在开阔的路上汽车的性能好过自行车,但是在交通拥堵的地方自行车的表现更好,所以你选择使用自行车。尽管自行车和汽车背后的技术都在不断改进,但是它们在架构上的对比仍然是相似的。所以直到现在,如果你设计一个处于都市中的送信业务,你仍然应该选择使用自行车,而不是汽车。
B.2 处理器和线程
一个多处理器系统包含了多个硬件上的处理器,每一个都可以执行一个顺序的程序。当讨论多处理器架构时候,基本的时间单元是指令周期:一个处理器取出并执行一条指令所花费的时间。按绝对值计算,随着技术的进步指令周期也在变化(从1980年的每秒1千万次到2005年的大约每秒30亿次),并在在不同的平台上也会有所差异(控制烤箱的处理器与控制网站服务器的处理器相比,指令周期要长的多)。然而,访问内存所需耗费的指令周期数的变化非常缓慢。
一个线程是一个顺序的程序。相较于处理器是一个硬件设备,线程则是一个软件结构。一个处理器可以运行一个线程一小会,然后将其放在一边并去运行另外一个线程,该事件被称为上下文切换。处理器有很多原因会将线程放在一边,或者取消调度它。或许这个线程下达了一个内存请求,该请求需要等待一些时间才能满足;又或者仅仅是因为该线程已经运行了足够久,是时候让另一个线程执行一会儿了。当一个线程被取消调度,它可能会被另一个处理器调度继续执行。
B.3 互联设备
互联设备是处理器用来和存储器及其他处理器通信的中介。实质上当前在使用的有两种类型的互联架构:SMP(对称多核处理器架构)以及NUMA(非统一内存访问架构)。
在一个SMP架构中,处理器和内存会通过一个总线连接起来,该总线是一个看起来类似于以太网的广播媒介。处理器和主内存都有总线控制单元,负责发送和监听来自总线广播的消息(有时也称为嗅探)。在今天,SMP架构是最常见的架构,因为它们最容易构建,但是受限于总线过载而无法扩展到较大的处理器个数。
在一个NUMA架构中,一群节点通过点对点的网络连接在一起,像一个小的局域网。每个节点包括一个或多个处理器,以及一个本地的内存。一个节点的本地内存也可以被其他节点访问,所有节点的内存共同构成一个所有处理器共享的全局内存。NUMA的命名就说明了一个处理器访问自己所在节点内存的速度快过其他节点中的内存。网络比总线更复杂一些,要求更加复杂的协议,但是它们在扩展性上比总线要好,可以扩展到很大数目的多处理器。
SMP和NUMA架构的划分有一点简单化:当然也可以设计混合架构,在一个集群内的处理器通过总线通信,但是不同集群之间的处理器通过网络通信。
从程序员的视角来看,底层平台是基于总线,网络,还是一个混合的互联结构看起来似乎没有那么重要。然而,意识到互联设备是一个所有处理器共享的有限资源,这点是非常重要的。如果一个处理器占用了过多的互联设备的带宽,那么其他处理器就会被延误了。
B.4 存储器
多个处理器共享一个主内存,该主内存是一个使用内存地址进行索引的巨大的字数组。与平台有关,一个字一般是32或64位,内存地址也一样。稍微简化一下,处理器通过发送一个包含了想要地址的消息给内存,以从内存中读取一个值。响应消息包含了对应的数据,也就是内存中对应地址中的内容。处理器通过向内存发送地址和新的数据来将数据写入内存,当新数据被装载之后,内存会发送回一个通知。
B.5 缓存
不幸的是,在现代的架构中,一个主内存的访问可能会消耗掉数百个指令周期,因此会存在这样的危险,处理器可能浪费掉大部分时间仅仅是在等待内存的响应。我们可以通过引入一层或多层缓存来缓解这个问题。缓存是小的存储设备,其位置更加靠近处理器,因此处理器访问其速度会远远快过主内存。
这些缓存在逻辑上的位置处于处理器与主内存之间:当一个处理器想要读取给定地址的数据时,它首先查看缓存中是否已经有该数据了,若有,它就不需要执行缓慢的内存访问了。如果在缓存中找到了想要地址的值,我们称为缓存匹配(cache hit),否则称为缓存缺失(cache miss)。同样的方式,如果一个处理器想要写一个已在缓存中的地址,它也不需要执行缓慢的内存访问。请求被缓存满足的比例称为缓存匹配率。
由于绝大部分程序都表现出一种高度的局部性,因此缓存是很有效的。局部性是指如果一个处理器读或写了某一个内存地址(也称为一个内存位置),那么它很可能很快会再次读或写相同的位置。而且,如果一个处理器读或写某一个内存位置,那么它很可能也会很快读或写相邻的内存位置。为了利用后一个观测结果,缓存往往会操作比单个字宽更大的粒度:缓存会持有被称为缓存线(cache lines,有时候也被称为缓存块cache blocks)的一组位置相邻的字宽数据。
实践中,大多数处理器都有两层缓存,称为L1和L2缓存。L1缓存通常与处理器集成于同一块芯片上,其访问会消耗1到2个指令周期。L2缓存可能会被集成到芯片中,也可能不会,访问L2缓存可能会消耗数十个指令周期。两者都远远快过访问主内存的数百个指令周期。当然,这些时间在不同的平台上会有不同,并且一些多处理器有更加复杂的缓存架构。
NUMA架构最初的白皮书中并没有包含缓存,因为觉得本地内存就已经够用了。然后,后来商用的NUMA架构中确实引入了缓存。有时候术语cc-NUMA(缓存一致性NUMA)用来表示带缓存的NUMA架构。这里,为了避免模棱两可,我们讲的NUMA包含了缓存,除非我们另有说明。
缓存的构建非常昂贵,因此其会远远小于内存:只有内存地址的很小一部分可以同时存在于缓存中。因此我们希望在缓存中维护最常用到的地址中的值。这意味着当我们往一个已满的缓存中再次加载数据时,就需要驱逐一个缓存线了,如果它没有被修改过就直接丢弃,否则需要将其写回主内存中。替换策略决定了由哪个缓存线来给新地址中的数据腾地方。如果替换策略可以自由的替换掉任何一个缓存线,则我们称该缓存为完全相连的(fully associative)。另一方面,如果只有一个缓存线可以被替换,那么我们称该缓存为直接映射的(direct mapped)。如果我们做个折中,对于一个给定的缓存线,允许一个大小为z的集合中的任意一条被替换,那么我们称该缓存为k路组相连的(k-way set associative)。
B.5.1 一致性
共享(或者不太礼貌的说,内存竞争),发生在当一个处理器读或者写一个已经被其他处理器缓存过的内存地址时。如果每个处理器不修改只是读取数据,那么数据可以同时被各处理器分别缓存。然而,如果一个处理器尝试更新共享状态的缓存线,那么其他处理器的副本必须被无效化以确保它不会读取到一个过时的值。
一般来讲,这个问题被称为缓存一致性。参考文献中包含了各种各样复杂而聪明的缓存一致性协议。这里我们回顾一个最常用的协议,基于缓存线可能处于的状态而被叫做MESI协议。该协议已经被用在奔腾和PowerPC处理器中。下列是缓存线的状态。
- Modified: 该缓存线已经被修改了,它最终必须被写回到主内存中。没有其他处理器持有该数据的缓存。
- Exclusive: 该缓存线没有被修改,也没有其他处理器持有该数据的缓存。
- Shared: 该缓存线没有被修改,其他处理器可能也持有了该数据的缓存。
- Invalid: 该缓存线无效,没有包含有意义的数据。
我们通过图表B.5描述的简短例子来展示该协议。为了简单起见,我们假设处理器和内存是通过总线连接的。
处理器A从内存地址a中读取数据,并将该数据以exclusive状态加载到它的缓存中。当处理器B尝试从相同地址读取数据时,A检测到了地址冲突,并使用该地址相关联的数据响应B。现在地址a被处理器A和B以shared状态同时缓存了。如果B往shared状态的地址a(缓存线)中写入数据,它会将该缓存线状态转换为modified,然后广播一个消息给A(以及任何可能缓存了地址a中数据的处理器),让这些处理器将自己对应的缓存线状态置为invalid。如果A随后又要从地址a中读取数据,它就会通过总线广播该请求,此时B就会将修改后的数据同时发送给A和主内存以响应该请求,此时A和B缓存中的该数据所在的缓存线都会处于shared状态了。
伪共享就是当处理器访问逻辑上独立的数据时却发生了冲突,因为它们访问的内存位置处于相同的缓存线中。这种现象展示了一种艰难的权衡:大的缓存线对于局部性原理来说是好的,但是它们增加了伪共享的可能性。可以通过确保那些可能被独立线程并发访问的数据结构在内存中彼此远离来减少发生伪共享的可能性。例如,多个线程共享一个byte数组很可能会引入伪共享问题,但是让它们共享一个双精度的整数数组就没有那么危险了。
B.5.2 自旋
如果一个处理器重复的测试内存中的某些内容,那么它处于自旋中,等待其他处理器改变该内容。基于硬件架构,自旋可以对整个系统的性能造成巨大的影响。
对于一个无缓存的SMP架构而言,自旋是一个非常糟糕的主意。每次处理器读取内存,它都会消耗总线带宽而没有完成任何有用的工作。由于总线是一个广播媒介,这些发送往内存的请求可能会妨碍其他处理器的进展。
对于一个无缓存的NUMA架构而言,假如持续测试的地址处于处理器的本地内存中,那么自旋或许是可以接受的。虽然无缓存的多处理器架构是很稀少的,但我们在考虑一个涉及到自旋的同步协议时仍然需要问一下,该协议是否允许每一个处理器自旋在它们自己的本地内存中。
在一个带缓存的SMP或NUMA架构中,自旋会消耗极少的资源。处理器第一次读取内存地址时,它遇到一个缓存缺失(cache miss),并将该地址的内容加载到缓存线中。从此以后,只要该数据没有被修改,处理器就只是简单的从自己的缓存中重读该数据,不消耗任何互联设备带宽,该过程被称为本地自旋。当缓存状态变化了,处理器会遇到一个缓存缺失,然后观察到数据改变了,于是就停止自旋。
B.6 缓存敏感的编程,或疑惑的破解
我们现在知道的多到足以解释为什么在章节B.1中验证的TTASLock性能胜过TASLock了。每一次TASLock对锁属性应用getAndSet(true),它都会通过互联设备广播一个消息,而这会导致大量的通信。在一个SMP架构中,其引发的通信量有可能足够使得互联设备饱和,因此而延误所有的线程,包括正在尝试释放该锁的线程,甚至是完全没有在竞争该锁的线程。与此相对的,当锁被使用的时候,TTASLock自旋会一直读取一个锁属性的本地缓存副本,没有制造任何的互联设备上的通信,这就解释了它的更好的性能表现。
然而,TTASLock自己也远远没有到达理想的状态。当锁被释放的时候,所有处理器中的锁属性的缓存副本都会被无效化,然后所有等待的线程都会调用getAndSet(true)方法争抢锁,这会导致一次通信量的爆发,这个通信量虽然比TASLock造成的要小,但是仍然很大。
我们会在第7章深入讨论缓存与锁的相互作用。与此同时,这里有一些简单的方法来构建数据以避免伪共享问题。相较于Java而言,下面列出的这些技术中的一部分在提供了对内存更加细粒度控制的编程语言如C或者C++上面更容易实施。
- 会被单独访问的对象或属性应该被对齐和填充,这样一来它们就能处于不同的缓存线中。
- 将只读数据与经常修改的数据分开存放。例如,考虑一个其结构不会变化的链表,但是其对象的value属性经常变化。为了确保修改value属性值不会减慢链表的遍历行为,我们可以对齐并填充value属性,这样的话每一个都可以填满一个缓存线。
- 如果可能的话,将一个对象分裂成线程本地化的片段(也就是由每个线程维护自己的那个片段)。例如,一个用于统计的计数器可以被分裂成一个计数器数组,其中每一个都专属于一个线程,每一个都存在于一个不同的缓存线中。一个共享的计数器可能会导致缓存失效时巨大的通信量,而分裂的计数器允许每个线程在不引起一致性通信的情况下更新自己的那部分片段。
- 如果一个锁被用于保护经常被修改的数据,那么将该锁和该数据保持在不同的缓存线中,如此一来尝试获取锁的线程就不会干扰到持有锁的线程对数据的访问。
- 如果一个锁被用于保护经常处于无竞争状态的数据,那么尝试将该锁与该数据保持在相同的缓存线中,如此一来获取锁时也会加载一些被该锁保护的数据到缓存中。
B.7 多核和多线程架构
在一个多核架构中,如在图表B.6中所示,多个处理器被集成在同一块芯片上。该芯片上的每一个处理器通常都有自己的L1缓存,但是它们共享一个通用的L2缓存。同一块芯片上的存储器可以通过共享的L2缓存高效的通信,避免了需要通过内存交换数据,以及引发笨重的缓存一致性协议。
在一个多线程的架构中,一个单独的处理器可能一次执行两个或更多的线程。很多现代的处理器实际上都具备内在的并行性。它们可以以乱序的方式执行指令,或者以并行的方式(例如,同时使固定的及浮点的计算单元保持繁忙),或者甚至会在分支之前或数据被计算出之前推测性的提前执行指令(分支预测)。为了保持硬件单元繁忙,多线程处理器可以将多个程序流上的指令混合执行。
现代的处理器架构结合了多核与多线程,多个支持多线程的内核可能会集成于同一个芯片上。在某些多核芯片上执行上下文切换的代价非常小,并以一种非常小的粒度进行,实际上达到了单条指令的粒度。这样的话,对多线程的支持就可以掩盖内存访问的高延迟:当一个线程访问内存时,处理器就立刻允许另一个线程执行,而无上下文切换的开销。
B.7.1 宽松的内存一致性
当一个处理器向内存中写一个值时,该值被被保存在缓存中并标记为“脏的”,意味着它必须最终被写回到主内存。在绝大部分现代处理器中,当下达写请求时,它们不会马上被应用到内存。实际上,这些写请求会被收集在一个硬件的队列中,叫做写缓冲区(或者存储缓冲区),并在稍后被一起应用到内存中。写缓冲区可以提供两个好处。首先,一次性应用多个请求总是会更有效率,这被称为批处理。第二,如果一个线程多次往同一个地址写数据,那么前面的写请求就可以被丢弃,省下了一次内存操作,这被称为写吸收。
使用写缓冲区会导致一个非常重要的结果:读和写被送达内存的顺序并不一定是按照它们在程序中发生的顺序。例如,回顾一下第一章介绍的flag规则,其对于实现互斥的正确性是至关重要的:如果两个处理器各自首先写它们自己的flag,然后读取对方的flag,那么它们中间的一个必然会看到对方新写入的flag值。在使用写缓冲区时这一点就不再为真了,两个都有可能将写请求放入自己的写缓冲区中,但是两个写缓冲区可能都在各自处理器读取对方内存中的flag之后才被写入。这样一来,没有一个能读取到对方新写入的flag。
编译器可能会使问题更严重。它们非常擅长于优化程序在单处理器架构中的性能。通常,这种优化需要将一个线程对内存的读和写的顺序重排。对于单线程程序这样的重排是透明的,但是在线程可以观察到写操作发生顺序的多线程程序中,重排可能会造成不可预期的后果。例如,如果一个线程往一个缓冲区中写满了数据,然后设置一个缓冲区已满的标志,而并发执行的线程可能会在看见缓存中的新数据之前先看见这个标志,导致它们读取到脏数据。第三章中描述的不正确的双重检查的锁模式(double-check locking)就是一个由Java内存模型的某些非直觉特性导致陷阱的例子。
不同的架构提供了对于在多大程度上允许读写指令重排的不同保证。通常来讲,最好不要依赖于这些保证,并使用在接下来章节中描述的更加昂贵的技术,来阻止这种重排。所有架构都允许你强制要求你的写操作按照它们被下达的顺序执行。一个内存屏障指令(有时候也叫内存栅栏)冲刷写缓冲区,确保在本屏障之前下达的写操作对于下达该屏障指令的处理器统统可见。内存屏障经常会由原子性的如getAndSet()这样的读-改-写操作,或者由标准的并发库来透明的插入。如此一来,处理器只需要在临界区以外对共享变量执行读写指令时才需要明确的使用内存屏障。
一方面,内存屏障的代价是昂贵的(消耗数百或更多的指令周期),因此应该只在必要的时候使用。另一方面,同步导致的错误可能非常难以追踪,因此应该更慷慨的使用内存屏障,总好过依赖复杂的且与平台有关的对指令重排限制的保证。
Java语言自身是允许将发生在同步方法或代码块之外的对象属性的读写操作重排的。另外,Java又提供了一个关键字volatile,其可以确保发生在同步代码块或方法之外的volatile对象的读写操作不会被重排。使用这个关键字代价可能很昂贵,因此只有必要的时候才应该使用。我们注意到,原则上说,使用volatile属性可以使得前面所说的双重检查的锁算法变得没问题,但这并没有多大意义,因为访问volatile变量总是会要求同步(这就抵消了我们使用双重检查锁算法的初衷:一旦对象创建完毕,后续的调用就不再需要同步了)。
我们对于多处理器硬件的初级读本已接近尾声了。我们会在介绍具体的数据结构及算法时继续讨论这些架构思想。此时一个模式浮现出来:多处理器程序的性能高度依赖于与底层硬件的协同作用。
B.8 硬件同步指令
如同在第5章中讨论的,一个现代的多处理器架构必须支持通用的功能强大的同步基元(synchronization primitives),换言之,提供一个通用图灵机的并发计算等价物。因此Java语言在实现同步时依赖于这样专门的硬件指令(也称硬件基元)就不足为奇了,这些被Java实现的同步涵盖了自旋锁和监控器(monitors)一直到最复杂的无锁化的lock-free结构。
现代架构通常提供一到两种通用的同步基元。AMD,Intel和Sun的架构都支持compare-and-swap(CAS)指令。该指令接收3个参数:一个内存中的地址a,一个预期的值e,以及一个要更新的值v。它返回一个Boolean。它会原子性的执行如下的步骤:
- 如果内存中的地址a包含预期的值e,
- 那么将要更新的值v写入地址a中并返回true,
- 否则不修改内存a,并返回false。
在Intel和AMD的架构中,CAS被称为CMPXCHG,而在SPARC tm中其被称为CAS。Java的java.util.concurrent.atomic库提供了原子性的Boolean, integer以及reference类,这些类都通过一个compareAndSet()方法来实现CAS(由于我们的例子绝大部分使用Java所写,因此在本书其他地方都是使用compareAndSet()来指代CAS)。C#通过方法Interlocked.CompareExchange来提供相同的功能。
CAS指令会导致一个陷阱。或许最常见的对CAS的使用场景如下。一个应用从一个指定的内存地址中读取数据a,并为该内存地址计算出一个新的值c。它想要保存c,但是只有在该地址中的数据被读取之后没有变更的前提下才可以。有人可能认为使用预期值a和更新值c来调用一个CAS操作就能完成这个目标。但有一个问题:一个线程可能已经使用另一个值b覆盖了该值,然后又一次将值a写入了该地址中。如此一来,上述compare-and-swap操作就可以成功的将值a替换为值c,但是应用程序可能并不想这么做(例如,如果该地址存储了一个指针,那么再次写入的值a有可能就是一个被回收又再次利用的对象的地址)。CAS操作成功的校验值并做了替换,但是应用程序并非做了它想做的事。这个问题被称为ABA问题,在第10章中会详细讨论。
另一个硬件同步基元是一对指令:load-linked和store-conditional(LL/SC)。LL指令负责从一个地址a中读取数据。随后的SC指令尝试将一个新值存储到该地址中。如果自从该线程对地址a下达了先前的LL指令以来,地址a的内容没有被更改过,那么随后的SC指令就可以执行成功。如果在此间隙内地址a中的内容有变化,那么SC指令就会执行失败。
有几个架构是支持LL和SC指令的:Alpha AXP (ldl l/stl c),IBM PowerPC (lwarx/stwcx) MIPS ll/sc,以及ARM (ldrex/strex)。LL/SC不会遭遇ABA问题,但是实践中一个线程在LL和对应的SC指令之间可以做什么是有严格限制的。上下文切换,另一个LL,或者另一个load或store指令都有可能导致随后的SC执行失败。
节俭的使用原子性的字段及其相关方法是一个好主意,因为它们往往都基于CAS或LL/SC指令。完成一个CAS或LL/SC指令比完成一个load或store指令要耗费多的多的指令周期:它包含一个内存屏障并阻止乱序执行及各种各样的编译器优化。确切的开销依赖于很多因素,不仅在不同的平台上会有所不同,相同平台上的不同应用之间也会有所不同。只能说CAS或LL/SC可以比简单的load或store慢的多的多。
B.9 本章说明
John Hennessy和David Patterson给出了一个计算机体系结构的综述。英特尔的奔腾处理器使用了MESI缓存一致性协议。对于缓存敏感的编程技巧改编自Benjamin Gamsa,Orran Krieger,Eric Parsons,和Michael Stumm。Sarita Adve和Karosh Gharachorloo提供了对内存一致性模型的完美调研。
------------------------------------------------------------------------------------------------------
翻译自《the art of multiprocessor programming》