转载 | Memory Models, Part 1-Hardware Memory Models
Introduction: A Fairy Tale, Ending(介绍:童话故事的结局)
很久以前,当所有人都编写单线程程序时,使程序运行更快的最有效方法之一就是坐视不动。下一代硬件和下一代编译器的优化会让程序运行得一模一样,只是更快。在这个童话故事时期,有一个简单的测试来判断优化是否有效:如果程序员无法分辨未经优化和经过优化的有效程序的区别(除了加速的差异),那么优化就是有效的。也就是说,有效的优化不会改变有效程序的行为。
多年以前,有一天令人难过的事情发生了,硬件工程师使单个处理器变得越来越快的魔法咒语失效了。作为回应,他们找到了一个新的魔法咒语,让他们能够制造出拥有越来越多处理器的计算机,操作系统通过线程这一抽象向程序员暴露了这种硬件并行性。这种新的魔法咒语——以操作系统线程形式提供的多处理器——对硬件工程师来说效果更好,但却为语言设计者、编译器编写者和程序员带来了重大问题。
许多在单线程程序中是不可见(因此有效)的硬件和编译器优化,在多线程程序中会产生可见的变化。如果有效的优化不应改变有效程序的行为,那么这些优化或者现有程序必须被判定为无效。究竟是哪一种情况,我们又该如何做出判断呢?
这里有一个用类似C语言写的简单示例程序。在这个程序以及我们将考虑的所有程序中,所有变量初始值均为零。
如果线程1和线程2各自运行在独立的处理器上,并且都能运行至完成,这个程序能打印出0吗?
这要看情况。它依赖于硬件,也依赖于编译器。在x86多处理器上,逐行翻译成汇编语言并运行的程序总是会打印1。但在ARM或POWER多处理器上,逐行翻译成汇编语言并运行的程序可能打印0。此外,无论底层硬件如何,标准的编译器优化可能使该程序打印0或进入无限循环。
“这要看情况”并不是一个令人满意的结局。程序员需要一个明确的答案,来确定程序是否能在新硬件和新编译器下继续正常工作。硬件设计者和编译器开发者也需要一个明确的答案,来精确说明在执行某个程序时硬件和编译代码允许以何种方式表现。由于这里的主要问题是存储在内存中数据的更改的可见性和一致性,这种约定被称为内存一致性模型,或简称内存模型(memory model)。
最初,内存模型的目标是定义硬件向编写汇编代码的程序员所保证的内容。在那个背景下,编译器并不参与其中。二十五年前,人们开始尝试编写内存模型,定义诸如Java或C++这样的高级编程语言,向使用这些语言编写代码的程序员所保证的内容。将编译器纳入模型,使得定义一个合理的模型变得更加复杂。
这是关于硬件内存模型和编程语言内存模型的两篇文章中的第一篇。我的写作目的,是为讨论我们可能想在 Go 语言内存模型中做出的潜在变更打好基础。但为了理解 Go 语言的现状和未来走向,首先我们必须了解其他硬件内存模型和语言内存模型目前的发展状况,以及它们为达到如今状态所经历的风险路径。
再次说明,这篇文章是关于硬件的。假设我们正在为一台多处理器计算机编写汇编语言。程序员需要计算机硬件提供什么保证,才能编写出正确的程序?计算机科学家们对这个问题的研究已经超过四十年。
Sequential Consistency(顺序一致性)
Leslie Lamport 在1979年的论文《如何制造一台能正确执行多进程程序的多处理器计算机》中提出了顺序一致性的概念:
设计和证明多处理器算法正确性的惯常方法假设满足以下条件:任何执行的结果都与所有处理器的操作按某种顺序依次执行的结果相同,且每个处理器的各个操作按其程序指定的顺序出现在该序列中。满足这一条件的多处理器称为顺序一致的。
如今我们不仅谈论计算机硬件,还谈论保证顺序一致性的编程语言,其中程序的所有可能执行对应于线程操作某种交织后的顺序执行。顺序一致性通常被视为理想模型,是程序员最自然的工作模型。它允许你假设程序按代码中出现的顺序执行,而各线程的执行只是以某种顺序交织在一起,但不会被其他方式重排。
有人可能会合理地质疑顺序一致性是否应该是理想模型,但这超出了本文的讨论范围。我只想指出,考虑所有可能的线程交织,今天和1979年一样,仍然是“设计和证明多处理器算法正确性的惯常方法。”在过去的四十年中,尚无其他方法取而代之。
之前我问过这个程序是否能打印0:
为了让程序更容易分析,我们去掉循环和打印操作,询问读取共享变量时可能的结果:
试金石测试:消息传递
该程序能否出现 r1 = 1,r2 = 0 这样的情况?
我们假设每个示例都以所有共享变量初始化为零开始。由于我们试图确定硬件允许做什么,我们假设每个线程都在其独立的处理器上执行,且没有编译器对线程内部的操作进行重排序:代码清单中的指令即处理器执行的指令。rN 表示线程本地寄存器,而非共享变量,我们关心在程序执行结束时,某种线程本地寄存器的设置是否可能出现。
这类关于程序执行结果的问题称为“试金石测试”(litmus test)。因为它的答案是二元的——这个结果是否可能?——试金石测试为我们区分内存模型提供了明确的方法:如果某个模型允许某执行结果,而另一个模型不允许,则两者明显不同。不幸的是,正如我们稍后会看到的,某个模型对特定试金石测试的回答经常令人惊讶。
如果这个试金石测试的执行是顺序一致的,那么只有六种可能的交织方式:
由于没有任何交织会导致 r1 = 1,r2 = 0 的情况,所以这个结果是不允许的。也就是说,在顺序一致的硬件上,试金石测试——“这个程序是否会出现 r1 = 1,r2 = 0?”的答案是否定的。
顺序一致性的一个良好心理模型是,想象所有处理器都直接连接到同一个共享内存,该内存一次只能服务于来自一个线程的读或写请求。这里没有任何缓存,因此每当处理器需要从内存读取或写入数据时,请求都会直接发送到共享内存。这个一次只允许一个访问者使用的共享内存,对所有内存访问的执行顺序施加了顺序约束:这就是顺序一致性。
本文中三张内存模型硬件图改编自 Maranget 等人的《ARM 和 POWER 弛豫内存模型教程导读》。)
“弛豫”在计算机科学,尤其是内存模型领域,指的是对严格顺序执行或一致性要求的放宽。也就是说,内存访问操作不必严格按程序中写的顺序执行,而是允许一定程度的重排序或延迟,从而提高硬件和软件的性能。比如“弛豫内存模型”(relaxed memory model)就是指允许内存操作顺序部分放宽的内存一致性模型。
这个图示是一个顺序一致机器的模型,而不是构建这种机器的唯一方式。实际上,可以使用多个共享内存模块和缓存来构建一个顺序一致的机器,从而帮助预测内存访问的结果,但顺序一致意味着机器的行为必须与该模型无异。如果我们只是想理解什么是顺序一致的执行,那么可以忽略所有可能的实现细节,只专注于这个模型。
对于我们程序员来说,不幸的是,放弃严格的顺序一致性可以让硬件更快地执行程序,因此所有现代硬件在各种方式上都会偏离顺序一致的行为。准确定义具体硬件的偏离方式其实是很困难的。本文以两个例子来说明当今广泛使用的硬件中存在的两种内存模型:x86架构的,以及ARM和POWER处理器家族的。
x86 Total Store Order (x86-TSO)(x86 全部写入顺序)
现代 x86 系统的内存模型对应于这个硬件图示:
所有处理器仍然连接到单个共享内存,但每个处理器将写入该内存的操作排队到本地写入队列中。处理器在写入操作传递到共享内存的过程中,继续执行新的指令。在一个处理器上进行的内存读取会先查看本地写入队列,然后才访问主内存,但它无法看到其他处理器的写入队列。其效果是,处理器能比其他处理器更早看到自己发出的写入操作。但——这点非常重要——所有处理器都同意写入(存储)达到共享内存的(总)顺序,这就是该模型名称的由来:total store order,简称 TSO。当前,写入一旦达到共享内存,任何处理器对该内存位置的后续读取都会看到并使用该值(直到被后续写入覆盖,或者被来自其他处理器的缓存写入缓冲覆盖)。
写入队列是一个标准的先进先出(FIFO)队列:内存写入操作以处理器执行的相同顺序应用到共享内存中。由于写入顺序由写入队列保持,而且其他处理器也能立即看到写入共享内存的操作,我们之前考虑的消息传递灯塔测试仍然会得到相同的结果:r1 = 1,r2 = 0 依然不可能出现。
试金石测试:消息传递
这个程序能看到 r1 = 1,r2 = 0 吗?
在顺序一致的硬件上:不能。
在 x86(或其他 TSO)上:也不能。
写入队列保证线程1先将x写入内存,再写入y,并且系统范围内对内存写入顺序的共识(即总写入顺序)保证线程2在得知y的新值之前先得知x的新值。因此,不可能出现r1 = y看到新的y值而r2 = x未看到新的x值的情况。这里写入顺序非常关键:线程1先写x再写y,所以线程2不可能先看到写入y的操作而没看到写入x的操作。
顺序一致性模型和TSO模型在这种情况下是相同的,但它们对其他试金石测试的结果存在分歧。例如,下面是区分这两种模型的常用示例:
试金石测试:写入队列(也称为存储缓冲区)
这个程序能看到 r1 = 0,r2 = 0 吗?
在顺序一致性硬件上:不能。
在 x86(或其他 TSO)上:可以!
在任何顺序一致性执行中,要么先发生 x = 1,要么先发生 y = 1,然后另一线程的读取必须观察到这个写入,因此 r1 = 0,r2 = 0 是不可能的。但在 TSO 系统中,线程 1 和线程 2 都可以先将它们的写入排入队列,然后在这两个写入任一被写入内存之前,从内存中读取,从而两个读取都看到零。
这个例子看起来可能有些牵强,但在众所周知的同步算法中,使用两个同步变量确实会出现这种情况,比如 Dekker 算法 或 Peterson 算法,以及一些临时方案。如果一个线程没有看到另一个线程的所有写入,这些算法就会失效。
在TSO(Total Store Order)模型中,允许每个线程维护自己的“写入队列”(store buffer),写操作首先进入这个队列,而不是立即刷新到主内存。当线程执行读取操作时,读取操作从主内存中读取数据(除非读取的是自己写入队列中尚未提交的地址),而其他线程看不到这些写入队列中的写入,直到它们被刷新到主内存。
线程1写入x=1,然后读取y到r1
线程2写入y=1,然后读取x到r2
在顺序一致性很严格的模型下,写操作会严格按程序顺序,被全局看到,因此不可能同时看到r1=0且r2=0,因为写入x或y的一方必定先被看到。
但是在TSO模型下,情况不同:
线程1写x=1,这个写入被放到线程1自己的写入缓冲区(store buffer)里,还没刷入主内存。
线程2写y=1,同样放到线程2自己的写入缓冲区里,尚未刷入主内存。
线程1读取y,此时y还未被线程2刷入主内存,线程1从主内存读取到的是初始值0(旧值),因为看不到对方的写缓冲区。
线程2读取x,情况同理,也是读到主内存中的旧值0。
由于两个线程都在读取中看不到对方的写入,产生了r1=0且r2=0的结果。
总结来说,写入缓冲区的存在和刷新延迟,是允许读操作“看到旧值”的关键原因。这导致了程序表现出在顺序一致模型下不可能出现的结果。
写入操作:先进入每个线程私有的写入缓冲区(store buffer),暂时不直接写入主内存。写入缓冲区中的内容会按照顺序缓慢且依次地刷新到主内存。
读取操作:通常直接从主内存读取数据,但如果读取的地址在该线程的写入缓冲区中存在未刷新到主内存的写入,读取操作会先从自己的写入缓冲区取最新值(即“写后读”保障),否则就从主内存读取。
换句话说:
写入操作先排队(进入写缓冲区),可能“延迟”对其他线程可见。
读取操作不排队,直接读取内存的最新状态或者自己的写缓冲区(当有对应未写入主内存的写入时)。
为了修复依赖更强内存顺序的算法,非顺序一致性硬件提供了称为内存屏障(或栅栏,memory barriers/fences)的显式指令,用于控制顺序。我们可以添加内存屏障,确保每个线程在开始读取之前先将之前的写入刷新到内存:
加入内存屏障后,r1 = 0,r2 = 0 再次变得不可能,Dekker 算法或 Peterson 算法也将能够正确工作。内存屏障有很多种类,它们的具体细节因系统而异,超出了本文的讨论范围。重点是,内存屏障的存在为程序员或语言实现者提供了一种方式,使得在程序的关键时刻能够强制执行顺序一致的行为。
最后举一个例子,说明为什么该模型被称为总存储顺序(total store order)。在该模型中,有本地写入队列,但读取路径上没有缓存。一旦写入到达主内存,所有处理器不仅会同意该值已经存在,还会就它相对于其他处理器写入何时到达达成一致。考虑如下试金石测试:
试金石测试:独立写入的独立读取(IRIW)
这个程序是否能观察到 r1 = 1, r2 = 0, r3 = 1, r4 = 0?
(线程3和线程4能否看到 x 和 y 按不同顺序变化?)
在顺序一致性硬件上:不可能。
在 x86(或其他 TSO)上:不可能。
如果线程3先看到 x 的变化再看到 y 的变化,线程4能否先看到 y 的变化再看到 x 的变化?对于 x86 和其他 TSO 机器,答案是否定的:存在一个覆盖所有写入到主内存的总顺序,并且所有处理器同意这个顺序,不过每个处理器对于自己写入达到主内存之前的情况有所不同。
The Path to x86-TSO(通向 x86-TSO 的路径)
x86-TSO 模型看起来相当简洁,但通向它的道路充满了障碍和错误的转折。在1990年代,第一批 x86 多处理器的手册几乎没有提及硬件所提供的内存模型。
举一个问题的例子,Plan 9 是最早在 x86 上运行的真正多处理器操作系统之一(没有全局内核锁)。在1997年移植到多处理器 Pentium Pro 时,开发者遇到了意外行为,归结到写队列的试金石测试。一段细微的同步代码假设 r1 = 0,r2 = 0 不可能发生,但事实却发生了。更糟糕的是,Intel 的手册对内存模型的细节描述含糊不清。
针对邮件列表中提出的“与其信任硬件设计者按照我们的预期行事,不如对锁保持保守态度”的建议,Plan 9 的一位开发者很好地解释了这个问题:
我完全同意。我们在多处理器中将遇到更加宽松的排序问题。问题是,硬件设计者认为什么是保守?在锁定区段的开始和结束处强制执行互斥锁对我来说似乎相当保守,但显然我想象力不够。专业手册对描述缓存及其保持一致性的机制非常详尽,但似乎对执行顺序或读取顺序的细节不怎么关心。事实是,我们无法判断自己是否足够保守。
在讨论过程中,Intel的一位架构师做了一个非正式的内存模型解释,指出理论上即使是多处理器486和Pentium系统也可能产生 r1 = 0,r2 = 0 的结果,而Pentium Pro只是因为具有更大的流水线和写入队列,更频繁地暴露了这种行为。
Intel 的架构师还写道:
粗略来说,这意味着系统中任何一个处理器发起的事件顺序,在其他处理器观察时,总是相同的。然而,不同的观察者允许对来自两个或多个处理器事件交错的顺序存在分歧。 未来的 Intel 处理器将实现相同的内存排序模型。
“不同观察者允许对两个或更多处理器事件交错顺序存在分歧”的说法,是指对 IRIW 试金石测试的答案在 x86 上可以是“是”,尽管在前面章节中我们看到 x86 的答案是否定的。这是怎么回事呢?
答案似乎是,Intel 处理器实际上从未对该试金石测试的结果给出过“是”的回答,但当时 Intel 的架构师们不愿对未来的处理器做出任何保证。架构手册中极少的说明几乎没有任何保障,使得针对这些不确定性进行编程非常困难。
Plan 9 的讨论并非孤立事件。Linux 内核开发者们在他们的邮件列表上花费了数百条消息,起始于1999年11月底,围绕 Intel 处理器提供的保证陷入了类似的困惑。
针对随后的十年里越来越多的人遇到这些困难,Intel 的一组架构师承担了撰写关于处理器行为的有用保障的任务,既包括当前处理器也包括未来处理器。第一个成果是2007年8月发布的《Intel 64 Architecture Memory Ordering White Paper》,旨在“为软件编写者提供对不同内存访问指令序列可能产生的结果的清晰理解”。AMD 同年晚些时候发布了类似的描述,见 AMD64 Architecture Programmer’s Manual revision 3.14。这些描述基于一个称为“总锁顺序 + 因果一致性”(TLO+CC)的模型,该模型故意比 TSO 弱一些。在公开演讲中,Intel 架构师表示 TLO+CC 是“满足要求但不更强”。特别是,该模型保留了 x86 处理器在 IRIW 试金石测试中回答“是”的权利。不幸的是,内存屏障的定义不够严格,无法重新建立顺序一致的内存语义,即使在每条指令后都设置了屏障。更糟糕的是,研究人员还观察到实际的 Intel x86 硬件违反了 TLO+CC 模型。以下是一个例子:
Litmus 测试: n5
这个程序会出现 r1 = 2, r2 = 1
?
// Thread 1 // Thread 2
x = 1 x = 2
r1 = x r2 = x
On sequentially consistent hardware: no.
On x86 specification (2008): yes!
On actual x86 hardware: no.
On x86 TSO model: no. (Example from x86-TSO paper.)
为了解决这些问题,Owens 等人基于早期的 SPARCv8 TSO 模型 提出了 x86-TSO 模型。当时他们宣称,“据我们所知,x86-TSO 是健全的,足够强大以支持上述编程,并且与厂商的意图大体一致。”几个月后,Intel 和 AMD 发布了新的手册,广泛采用了该模型。
看起来所有 Intel 处理器从一开始确实都实现了 x86-TSO,尽管 Intel 直到十年后才决定正式采纳它。回顾来看,很明显 Intel 和 AMD 的架构师当时正努力寻找一种内存模型,既能为未来处理器优化留下空间,又能为编译器开发者和汇编语言程序员提供有用的保障。“满足需求但不超强”是一种难以平衡的操作。
ARM/POWER Relaxed Memory Model(ARM/POWER 松散内存模型)
现在让我们看看一种更加宽松的内存模型,即 ARM 和 POWER 处理器所采用的模型。在实现层面上,这两个系统有许多不同之处,但保证的内存一致性模型大致相似,并且比 x86-TSO 甚至 x86-TLO+CC 要弱得多。
ARM 和 POWER 系统的概念模型是,每个处理器从自己的完整内存副本中读取和写入数据,每次写操作独立传播到其他处理器,并且允许在写操作传播过程中重新排序。
这里没有总的存储顺序。虽然未在图中显示,每个处理器也被允许推迟读取操作直到它需要结果:读取可以被推迟,直到后续的写操作完成。在这个松散模型中,到目前为止我们看到的所有 litmus 测试的答案都是“是的,这确实可能发生。”
对于原始的消息传递 litmus 测试,单个处理器对写操作的重新排序意味着线程1的写操作可能不会被其他线程以相同的顺序观察到:
Litmus 测试: 消息传递
这个程序会出现 r1 = 1, r2 = 0
的情况吗?
// Thread 1 // Thread 2
x = 1 r1 = y
y = 1 r2 = x
在顺序一致的硬件上:不可能。
在 x86(或其他 TSO)上:不可能。
在 ARM/POWER 上:可能!
在 ARM/POWER 模型中,我们可以认为线程1和线程2各自拥有自己独立的内存副本,写操作以任意顺序在这些内存之间传播。如果线程1的内存先将 y 的更新发送给线程2,再发送 x 的更新,而线程2在这两次更新之间执行,那么它确实会看到结果 r1 = 1,r2 = 0。
这一结果表明,ARM/POWER 内存模型比 TSO 更宽松:它对硬件的要求更少。ARM/POWER 模型仍然允许 TSO 所允许的那种类型的重新排序。
Litmus 测试: 写缓冲
这个程序会出现 r1 = 0, r2 = 0
的情况吗?
// Thread 1 // Thread 2
x = 1 y = 1
r1 = y r2 = x
在顺序一致的硬件上:不可能。
在 x86(或其他 TSO)上:可能!
在 ARM/POWER 上:可能!
在 ARM/POWER 上,对 x 和 y 的写操作可能已写入到本地内存,但在对方线程进行读取时还未传播出去。
下面是展示 x86 拥有全序存储顺序含义的一个 Litmus 测试:
Litmus 测试:独立写的独立读 (IRIW)
这个程序能否看到 r1 = 1, r2 = 0, r3 = 1, r4 = 0?
(线程 3 和 线程 4 会看到 x 和 y 的变化顺序不同吗?)
// Thread 1 // Thread 2 // Thread 3 // Thread 4
x = 1 y = 1 r1 = x r3 = y
r2 = y r4 = x
在顺序一致的硬件上:不可能。
在 x86(或其他 TSO)上:不可能。
在 ARM/POWER 上:可能!
在 ARM/POWER 上,不同的线程可能以不同的顺序了解到不同的写操作。它们不保证对到达主内存的写操作有一个总的顺序一致性,因此线程 3 可能会先看到 x 的变化,再看到 y 的变化,而线程 4 则可能先看到 y 的变化,再看到 x 的变化。
作为另一个例子,ARM/POWER 系统具有可见的缓冲或内存读取(load)的重排序,如下述 litmus 测试所示:
Litmus 测试:加载缓冲
这个程序能否出现 r1 = 1, r2 = 1
?
(每个线程的读取是否可能发生在另一个线程的写入之后?)
// 线程 1 // 线程 2
r1 = x r2 = y
y = 1 x = 1
在顺序一致的硬件上:不可能。
在 x86(或其他 TSO)上:不可能。
在 ARM/POWER 上:可能!
任何顺序一致的交织执行必须以线程1的 r1 = x 或线程2的 r2 = y 开始。那个读取操作必须看到一个零,从而使得结果 r1 = 1,r2 = 1 不可能出现。然而,在 ARM/POWER 的内存模型中,处理器允许延迟读取,直到指令流中后面的写操作执行之后,从而使得 y = 1 和 x = 1 在两个读取之前先执行。
尽管 ARM 和 POWER 内存模型都允许这种结果,但 Maranget 等人于 2012 年的报告显示,这种现象仅能在 ARM 系统上通过实验证明重现,而在 POWER 上则从未出现过。在这里,模型与现实之间的差异再次显现出来,就如我们之前研究 Intel x86 时发现的那样:实现比技术规范更强的硬件模型会促使软件依赖这种更强的行为,这意味着未来较弱的硬件无论是否符合规范,都可能导致程序出错。
像 TSO 系统一样,ARM 和 POWER 也有屏障(barriers),我们可以将其插入到上述示例中以强制顺序一致的行为。但显而易见的问题是,没有屏障的 ARM/POWER 是否会排除任何行为?对任何 litmus 测试的回答都可能是“不会,绝不可能”,但当我们关注单个内存位置时,答案是肯定的。
以下是一个即使在 ARM 和 POWER 上也不可能发生的 litmus 测试:
Litmus 测试:一致性
这个程序能否出现 r1 = 1, r2 = 2, r3 = 2, r4 = 1
?
(线程 3 是否可能在看到 x = 2 之前先看到 x = 1,而线程 4 则相反?)
// 线程 1 // 线程 2 // 线程 3 // 线程 4
x = 1 x = 2 r1 = x r3 = x
r2 = x r4 = x
在顺序一致的硬件上:不可能。
在 x86(或其他 TSO)上:不可能。
在 ARM/POWER 上:不可能。
这个 litmus 测试类似于之前的例子,但现在两个线程都写入同一个变量 x,而不是两个不同的变量 x 和 y。线程 1 和 线程 2 向 x 写入冲突的值 1 和 2,同时线程 3 和线程 4 都读取两次 x。如果线程 3 看到 x = 1 被 x = 2 覆盖,线程 4 会看到相反的情况吗?
答案是否定的,即使在 ARM/POWER 上也是如此:系统中的线程必须就对单一内存位置的写操作达成一个总的顺序一致性。也就是说,线程必须就哪些写操作覆盖了其他写操作达成一致。这一属性称为一致性(coherence)。如果没有一致性属性,处理器要么会对内存的最终结果产生分歧,要么会报告某个内存位置的值在多个值之间来回切换,程序设计这样的系统将非常困难。
我故意省略了许多关于 ARM 和 POWER 弱内存模型的细节。想了解更多细节,可以参考 Peter Sewell 关于该主题的相关文章。此外,ARMv8 通过使其成为“多复制原子性(multicopy atomic)”来加强了内存模型,但我这里不会详细解释这个具体含义。
有两个重要的点需要注意。首先,这个话题涉及大量非常微妙的问题,是学术界经过十多年非常执着和聪明的研究人员深入探讨的结果。我自己也不能声称完全理解这些内容。这并不是我们期望向普通程序员解释的,也不是我们能在调试普通程序时轻易掌握的知识。其次,允许的行为和实际观察到的行为之间的差距会带来未来的不幸惊喜。如果当前硬件没有展示出允许行为的全部范围——尤其当我们很难推断什么行为是被允许时——那么不可避免地会出现一些程序,它们意外地依赖于硬件实际上更严格的行为。如果新芯片在行为上的限制更少,而新行为又在技术上被硬件内存模型允许,这导致了程序被破坏——这从技术上讲是硬件模型的问题,但对程序员来说基本上是错误引起的,这几乎没有什么安慰。这绝不是一种理想的程序设计方式。
Weak Ordering and Data-Race-Free Sequential Consistency(弱顺序和无数据竞争的顺序一致性)
到现在,我希望你已经相信硬件细节是复杂且微妙的,不是每次写程序时都想去详细研究的东西。相反,更有帮助的是找到一些简便方法,比如“如果你遵循这些简单规则,你的程序只会产生像顺序一致交错执行那样的结果。”(我们讨论的仍然是硬件,所以我们还是在讨论单条汇编指令的交错执行。)
Sarita Adve 和 Mark Hill 在他们1990年的论文《Weak Ordering – A New Definition》中提出了这种方法。他们将“弱有序”定义如下。
假设一个同步模型是一组关于内存访问的约束,规定了同步操作如何以及何时进行。 当且仅当对遵守该同步模型的所有软件来说,硬件表现为顺序一致时,该硬件相对于该同步模型就是弱有序的。
虽然他们的论文是关于捕捉当时硬件设计(而非 x86、ARM 和 POWER)的,但将讨论提升到具体设计之上的思路,使得这篇论文至今仍具参考价值。
我之前说过,“有效的优化不会改变有效程序的行为。”规则定义了“有效”的含义,然后任何硬件优化必须保持这些程序在顺序一致机器上的行为不变。当然,有趣的细节是规则本身,即定义程序何为有效的约束。
Adve 和 Hill 提出了一个同步模型,称为无数据竞争(data-race-free,DRF)。该模型假设硬件将内存同步操作与普通内存的读写操作分开。普通内存的读写操作可能在同步操作之间重新排序,但不能跨越同步操作移动。(也就是说,同步操作同时充当了重排序的屏障。)如果在所有理想化的顺序一致执行中,任意两个线程对同一内存位置的普通内存访问要么都是读操作,要么被同步操作隔开,保证一个操作一定先发生,那么该程序被称为无数据竞争。
让我们来看一些例子,取自 Adve 和 Hill 的论文(为展示重新绘制)。这里有一个单线程,先执行对变量 x 的写操作,接着读取同一个变量。
垂直箭头标示了单个线程内的执行顺序:先进行写操作,然后是读操作。这个程序中不存在竞态条件,因为所有操作都在同一个线程中。
相比之下,下面这个两线程程序中存在竞态条件:
这里,线程 2 对变量 x 进行写操作,而没有与线程 1 进行协调。线程 2 的写操作与线程 1 的写操作和读操作都存在竞态条件。如果线程 2 是读取 x 而非写入,那么程序中只有一次竞态,即线程 1 的写操作与线程 2 的读操作之间的竞态。每一次竞态至少涉及一个写操作:两个不协调的读操作之间不存在竞态。
为了避免竞态,我们必须添加同步操作,迫使共享同步变量的不同线程间的操作有序进行。如果同步操作 S(a)(对变量 a 的同步操作,用虚线箭头标记)强制线程 2 的写操作在线程 1 操作完成后发生,那么竞态就被消除了:
现在,线程 2 的写操作不能与线程 1 的操作同时发生。
如果线程 2 只是进行读取操作,那么我们只需与线程 1 的写操作进行同步。两个读取操作仍然可以并发进行:
线程可以通过一系列同步操作来排序,甚至可以通过中间线程实现排序。这个程序没有竞态条件:
另一方面,仅仅使用同步变量并不能消除竞态条件:错误地使用同步变量也是可能的。这个程序确实存在竞态条件:
线程 2 的读取操作与其他线程的写入操作进行了正确的同步——它肯定发生在两者之后——但这两个写入操作本身并未同步。这个程序不是无数据竞态的。
Adve 和 Hill 提出了弱序(一种软件与硬件之间的“契约”),具体来说,就是如果软件避免了数据竞态,那么硬件表现得就好像它是顺序一致的,这比我们在前面章节中研究的模型更容易理解。但硬件如何满足这种契约呢?
Adve 和 Hill 证明了硬件是“由 DRF 弱序的”,意即硬件在执行无数据竞态程序时,好像按照顺序一致的顺序执行,只要满足某些最低要求。这里不打算详细展开细节,重点是 Adve 和 Hill 的论文之后,硬件设计者拥有一套由证明支持的“配方”:做这些事情,你即可断言你的硬件对于无数据竞态程序表现为顺序一致。实际上,大多数弱序硬件都是这样运行的,并且在实现同步操作时也假设如此。Adve 和 Hill 最初关注的是 VAX 架构,但显然 x86、ARM 和 POWER 也能满足这些约束。这个想法是系统保证无数据竞态程序的顺序一致性,这经常被缩写为 DRF-SC。
DRF-SC 标志着硬件内存模型的一个转折点,为硬件设计者和软件开发者(至少那些编写汇编语言的软件开发者)提供了清晰的策略。正如我们将在下一篇文章中看到的,更高级编程语言的内存模型问题并没有同样简明的答案。
本系列的下一篇文章将介绍编程语言内存模型(programming language memory models)。
致谢
这一系列文章得益于与我在谷歌有幸共事的一长串工程师们的讨论和反馈。在此向他们表示感谢。对于任何错误或不受欢迎的观点,我承担全部责任。