原文作者:swtch.com/~rsc/
发布时间:2021年06月29日
(内存模型,第一部分)
介绍。一个童话故事,结尾
很久以前,当每个人都在编写单线程程序时,让程序运行得更快的最有效方法之一就是坐视不管。下一代硬件和下一代编译器的优化将使程序的运行与以前完全一样,只是速度更快。在这个童话般的时期,有一个测试优化是否有效的简单方法:如果程序员无法分辨一个有效程序的未优化和优化执行之间的区别(除了速度的提高),那么优化就是有效的。也就是说,有效的优化不会改变有效程序的行为。
几年前的一个悲伤的日子,硬件工程师们使单个处理器越来越快的魔咒停止了作用。作为回应,他们找到了一个新的魔咒,让他们创造出具有越来越多的处理器的计算机,而操作系统以线程的抽象形式向程序员暴露了这种硬件并行性。这个新的魔咒–多个处理器以操作系统线程的形式出现–对硬件工程师来说效果更好,但它给语言设计者、编译器编写者和程序员带来了重大问题。
许多硬件和编译器的优化在单线程程序中是不可见的(因此也是有效的),但在多线程程序中却产生了明显的变化。如果有效的优化没有改变有效程序的行为,那么要么这些优化,要么现有的程序必须被宣布无效。会是哪一个呢,我们如何决定呢?
这里有一个类似C语言的简单例子程序。在这个程序以及我们将要考虑的所有程序中,所有变量最初都被设置为零。
// Thread 1 // Thread 2
x = 1; while(done == 0) { /* loop */ }
done = 1; print(x);
复制代码
如果线程1和线程2,各自运行在自己的专用处理器上,都运行到完成,这个程序可以打印0吗?
这取决于。这取决于硬件,也取决于编译器。在X86多处理器上运行的直接逐行翻译成汇编的程序总是会打印1。但是,在ARM或POWER多处理器上运行的直接逐行翻译为汇编的程序可以打印0。
“这取决于 “并不是一个圆满的结局。程序员需要一个明确的答案,即一个程序是否能在新的硬件和新的编译器下继续工作。而硬件设计者和编译器开发者需要一个明确的答案,即在执行一个给定的程序时,允许硬件和编译后的代码有怎样精确的表现。因为这里的主要问题是存储在内存中的数据变化的可见性和一致性,该契约被称为内存一致性模型或只是内存模型。
最初,内存模型的目标是定义硬件对编写汇编代码的程序员的保证。在这种情况下,编译器并不参与。25年前,人们开始尝试编写内存模型,定义像Java或C++这样的高级编程语言对用该语言编写代码的程序员的保证。将编译器包括在模型中使得定义一个合理模型的工作变得更加复杂。
这是关于硬件内存模型和编程语言内存模型的一对帖子的第一篇。我写这些文章的目的是为讨论我们可能要在Go的内存模型中做出的潜在变化建立背景。但要了解Go的现状和我们可能想要的方向,首先我们必须了解其他硬件内存模型和语言内存模型的现状,以及它们走过的不稳定的道路。
同样,这篇文章是关于硬件的。让我们假设我们正在为多处理器计算机编写汇编语言。为了写出正确的程序,程序员需要从计算机硬件中得到什么保证?四十多年来,计算机科学家一直在寻找这个问题的良好答案。
顺序一致性
Leslie Lamport在1979年发表的论文《如何制造一台能正确执行多进程程序的多处理器计算机》中提出了顺序一致性的概念。
设计和证明这种计算机的多进程算法正确性的惯常方法是假定满足以下条件:任何执行的结果都与所有处理器的操作按某种顺序执行相同,而且每个单独的处理器的操作都按其程序指定的顺序出现在这个顺序中。满足这一条件的多处理器将被称为顺序一致。
今天我们谈论的不仅仅是计算机硬件,还有编程语言对顺序一致性的保证,当一个程序的唯一可能的执行对应于某种线程操作的交错执行。顺序一致性通常被认为是理想的模型,是程序员最自然的工作方式。它让你假设程序按照它们在页面上出现的顺序执行,各个线程的执行只是按照某种顺序交错进行,但没有其他的重新安排。
人们可能会合理地质疑顺序一致性是否应该是理想的模型,但这已经超出了本篇文章的范围。我只想指出,考虑所有可能的线程交错,在今天和1979年一样,仍然是 “设计和证明多进程算法正确性的惯用方法”。在这四十年间,没有任何东西可以取代它。
之前我问过这个程序是否可以打印0。
// Thread 1 // Thread 2
x = 1; while(done == 0) { /* loop */ }
done = 1; print(x);
复制代码
为了使程序更容易分析,让我们去掉循环和打印,问一下读取共享变量的可能结果。
Litmus Test: Message Passing
Can this program see r1 = 1, r2 = 0?
// Thread 1 // Thread 2
x = 1 r1 = y
y = 1 r2 = x
复制代码
我们假设每个例子开始时,所有共享变量都设置为零。因为我们要确定硬件允许做什么,所以我们假设每个线程都在自己的专用处理器上执行,没有编译器来重新安排线程中发生的事情:列表中的指令就是处理器执行的指令。rN这个名字表示线程本地寄存器,而不是共享变量,我们问的是线程本地寄存器的特定设置在执行结束时是否可能。
这种关于样本程序执行结果的问题被称为石蕊测试。因为它有一个二进制的答案–这个结果是否可能?–试金石测试给了我们一个明确的方法来区分内存模型:如果一个模型允许一个特定的执行,而另一个不允许,这两个模型显然是不同的。不幸的是,正如我们后面所看到的,一个特定的模型对一个特定的试金石测试所给出的答案往往是令人惊讶的。
如果这个试金石测试的执行在顺序上是一致的,那么只有六种可能的交织。
由于没有任何交织方式以r1=1,r2=0结束,所以这个结果是不允许的。也就是说,在顺序一致的硬件上,试金石测试的答案–这个程序能看到r1=1,r2=0吗–是不能。
顺序一致的一个很好的心理模型是想象所有的处理器直接连接到相同的共享内存,它可以一次为一个线程的读或写请求服务。没有涉及缓存,所以每次处理器需要从内存中读取或写入内存时,该请求就会进入共享内存。单次使用的共享内存对所有内存访问的执行施加了一个顺序:顺序一致性。
本帖中的三个内存模型硬件图改编自Maranget等人的《ARM和POWER宽松内存模型的教程介绍》)。
这张图是一个顺序一致的机器模型,而不是构建一个机器的唯一方法。事实上,可以使用多个共享内存模块和缓存来建立一个顺序一致的机器,以帮助预测内存获取的结果,但是顺序一致意味着该机器的行为必须与这个模型没有区别。如果我们只是想了解顺序一致的执行意味着什么,我们可以忽略所有这些可能的实现复杂性,而考虑这一个模型。
不幸的是,对于我们这些程序员来说,放弃严格的顺序一致可以让硬件更快地执行程序,所以所有的现代硬件都以各种方式偏离顺序一致。准确地定义特定硬件的偏离方式原来是相当困难的。这篇文章以当今广泛使用的硬件中存在的两种内存模型为例:x86的内存模型,以及ARM和POWER处理器系列的内存模型。
x86总存储顺序(x86-TSO)
现代x86系统的内存模型与这个硬件图相对应。
所有的处理器仍然连接到一个共享内存,但每个处理器在本地写队列中排队写到该内存。处理器继续执行新的指令,同时写到共享内存中去。一个处理器上的内存读取在查阅主内存之前会查阅本地写队列,但它不能看到其他处理器的写队列。其结果是,一个处理器在其他处理器之前看到自己的写。但是–这一点非常重要–所有的处理器都同意写(存储)到达共享内存的(总)顺序,因此该模型被称为:总存储顺序,或TSO。在写到达共享内存的那一刻,任何处理器上的任何未来的读都会看到它并使用这个值(直到它被后来的写覆盖,或者可能被另一个处理器的缓冲写覆盖)。
写入队列是一个标准的先入先出队列:内存的写入是按照处理器执行的顺序应用到共享内存的。因为写队列保留了写的顺序,而且其他处理器会立即看到对共享内存的写,所以我们之前考虑的消息传递试金石测试的结果与之前一样:r1=1,r2=0仍然是不可能的。
Litmus Test: Message Passing
Can this program see r1 = 1, r2 = 0?
// Thread 1 // Thread 2
x = 1 r1 = y
y = 1 r2 = x
On sequentially consistent hardware: no.
On x86 (or other TSO): no.
复制代码
写入队列保证线程1在y之前将x写入内存,而全系统关于内存写入顺序的协议(总存储顺序)保证线程2在知道y的新值之前就知道了x的新值。因此,r1=y不可能在没有r2=x也看到新的x的情况下看到新的y。存储顺序在这里很关键:线程1在y之前写x,所以线程2不能在写到x之前看到对y的写。
在这种情况下,顺序一致性和TSO模型是一致的,但它们对其他试金石测试的结果却有分歧。例如,这是区分这两种模型的通常例子。
Litmus Test: Write Queue (also called Store Buffer)
Can this program see r1 = 0, r2 = 0?
// Thread 1 // Thread 2
x = 1 y = 1
r1 = y r2 = x
On sequentially consistent hardware: no.
On x86 (or other TSO): yes!
复制代码
在任何顺序一致的执行中,x=1或y=1必须先发生,然后另一个线程的读必须观察到它,所以r1=0,r2=0是不可能的。但在一个TSO系统中,可能会发生线程1和线程2都在排队写,然后在任何一个写进入内存之前从内存中读出,所以两个读都看到了0。
这个例子似乎是人为的,但使用两个同步变量确实发生在著名的同步算法中,如Dekker的算法或Peterson的算法,以及特设的方案。如果一个线程没有看到另一个线程的所有写操作,它们就会中断。
为了修复那些依赖于更强的内存排序的算法,非顺序一致的硬件提供了明确的指令,称为内存屏障(或栅栏),可以用来控制排序。我们可以添加一个内存屏障,以确保每个线程在开始读之前都会把之前的写内容冲到内存中。
// Thread 1 // Thread 2
x = 1 y = 1
barrier barrier
r1 = y r2 = x
复制代码
加上正确的障碍,r1=0,r2=0又是不可能的,德克尔或彼得森的算法就会正确地工作。障碍物有很多种;细节因系统而异,超出了本帖的范围。重点是障碍的存在,给程序员或语言实现者提供了一种方法,在程序的关键时刻强制执行顺序一致的行为。
最后一个例子,来说明为什么这个模型被称为总存储顺序。在这个模型中,有本地的写队列,但在读取路径上没有缓存。一旦写入到达主内存,所有的处理器不仅同意该值在那里,而且同意它相对于其他处理器的写入何时到达。考虑一下这个试金石测试。
Litmus Test: Independent Reads of Independent Writes (IRIW)
Can this program see r1 = 1, r2 = 0, r3 = 1, r4 = 0?
(Can Threads 3 and 4 see x and y change in different orders?)
// Thread 1 // Thread 2 // Thread 3 // Thread 4
x = 1 y = 1 r1 = x r3 = y
r2 = y r4 = x
On sequentially consistent hardware: no.
On x86 (or other TSO): no.
复制代码
如果线程3在y之前看到x的变化,线程4能否在x之前看到y的变化?对于x86和其他TSO机器,答案是否定的:对主内存的所有存储(写)有一个总的顺序,所有处理器都同意这个顺序,但有一个问题,即每个处理器在到达主内存之前都知道自己的写。
通往x86-TSO的道路
x86-TSO模型看起来相当干净,但其间充满了路障和误区。在 20 世纪 90 年代,第一批 x86 多核处理器的手册对硬件提供的内存模型几乎只字未提。
作为问题的一个例子,Plan 9 是最早在 x86 上运行的真正的多处理器操作系统之一(没有全局内核锁)。在1997年移植到多处理器Pentium Pro的过程中,开发人员偶然发现了一些意想不到的行为,归结为写队列的试金石。一段微妙的同步代码假设r1=0,r2=0是不可能的,但它却发生了。更糟的是,英特尔的手册对内存模型的细节含糊不清。
在回应邮件列表中的一个建议时,”与其相信硬件设计师会做我们期望的事情,不如对锁采取保守的态度”,Plan 9的一个开发者很好地解释了这个问题。
我当然同意。我们将在多处理器中遇到更宽松的排序。问题是,硬件设计者认为什么是保守的?对我来说,在锁定部分的开头和结尾强制进行联锁似乎是相当保守的,但我显然不够有想象力。专业手册在描述缓存和保持缓存连贯性的细节方面非常详细,但似乎并不关心执行或读取顺序方面的细节。事实是,我们没有办法知道我们是否足够保守。
在讨论过程中,英特尔的一位架构师对内存模型进行了非正式的解释,指出在理论上,即使是多处理器的486和奔腾系统也可以产生r1=0,r2=0的结果,而奔腾Pro只是拥有更大的流水线和写队列,更经常地暴露出这种行为。
英特尔的架构师还写道。
宽泛地说,这意味着由系统中任何一个处理器发出的事件的顺序,正如其他处理器所观察到的,总是相同的。然而,不同的观察者被允许对来自两个或更多处理器的事件的交错排列有不同意见。
未来的英特尔处理器将实现相同的内存排序模型。
声称 “允许不同的观察者对来自两个或多个处理器的事件的交错有不同意见 “是说IRIW试金石测试的答案在x86上可以回答 “是”,尽管在上一节我们看到x86回答 “不是”。这怎么可能呢?
答案似乎是,英特尔处理器实际上从未对该试金石测试作出 “是 “的回答,但当时英特尔架构师不愿意对未来的处理器作出任何保证。架构手册中存在的少量文字几乎没有做出任何保证,这使得针对它进行编程非常困难。
Plan 9的讨论并不是一个孤立的事件。从1999年11月下旬开始,Linux内核开发者在他们的邮件列表中花了一百多条信息,对英特尔处理器提供的保证产生了类似的困惑。
在随后的十年里,越来越多的人遇到了这些困难,英特尔的一组架构师承担了写下关于处理器行为的有用保证的任务,包括当前和未来的处理器。第一个成果是2007年8月发表的《英特尔64架构内存排序白皮书》,其目的是 “为软件编写者提供对不同内存访问指令序列可能产生的结果的清晰理解”。AMD在当年晚些时候在《AMD64架构程序员手册》修订版3.14中发表了类似的描述。这些描述是基于一个叫做 “总锁顺序+因果一致性”(TLO+CC)的模型,故意比TSO弱。在公开谈话中,英特尔的架构师说,TLO+CC是 “按要求的强度,但不会更强”。特别是,该模型为x86处理器保留了对IRIW试金石测试回答 “是 “的权利。不幸的是,内存屏障的定义不够强大,无法重新建立顺序一致的内存语义,即使在每条指令后都有一个屏障。更糟糕的是,研究人员观察到实际的英特尔x86硬件违反了TLO+CC模型。比如说。
Litmus Test: n6 (Paul Loewenstein)
Can this program end with r1 = 1, r2 = 0, x = 1?
// Thread 1 // Thread 2
x = 1 y = 1
r1 = x x = 2
r2 = y
On sequentially consistent hardware: no.
On x86 TLO+CC model (2007): no.
On actual x86 hardware: yes!
On x86 TSO model: yes! (Example from x86-TSO paper.)
复制代码
2008年晚些时候对英特尔和AMD规范的修订保证了对IRIW情况的 “不”,并加强了内存障碍,但仍然允许意外的行为,这些行为似乎不可能出现在任何合理的硬件上。比如说。
Litmus Test: n5
Can this program end with 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等人提出了x86-TSO模型,基于早期的SPARCv8 TSO模型。当时他们声称:”据我们所知,x86-TSO是健全的,足够强大,可以在上面编程,并且大致符合供应商的意图。” 几个月后,英特尔和AMD发布了新的手册,大致采用了这种模式。
看来所有的英特尔处理器确实从一开始就实现了x86-TSO,尽管英特尔花了十年时间才决定致力于此。回过头来看,很明显,英特尔和AMD的架构师们在挣扎,到底如何编写一个内存模型,为未来的处理器优化留下空间,同时仍然为编译器编写者和汇编语言程序员提供有用的保证。”按要求的强度,但不强 “是一种困难的平衡行为。
ARM/POWER 宽松的内存模型
现在让我们来看看更加宽松的内存模型,即 ARM 和 POWER 处理器上的模型。在实现层面上,这两个系统在许多方面都不同,但保证内存一致性的模型原来大致相似,而且比 x86-TSO 或甚至 x86-TLO+CC 弱了不少。
ARM 和 POWER 系统的概念模型是,每个处理器从其自身的完整内存副本中读取和写入,并且每个写入都独立传播到其他处理器,在写入传播时允许重新排序。
在这里,不存在总存储顺序。没有描述,每个处理器也被允许推迟读取,直到它需要的结果:读取可以被推迟到后来的写入之后。在这个宽松的模型中,到目前为止我们看到的每一个试金石测试的答案都是 “是的,这真的可以发生”。
对于最初的消息传递试金石测试,单个处理器对写的重新排序意味着线程1的写可能不会被其他线程以同样的顺序观察到。
Litmus Test: Message Passing
Can this program see r1 = 1, r2 = 0?
// Thread 1 // Thread 2
x = 1 r1 = y
y = 1 r2 = x
On sequentially consistent hardware: no.
On x86 (or other TSO): no.
On ARM/POWER: yes!
复制代码
在ARM/POWER模型中,我们可以认为线程1和线程2各自拥有独立的内存副本,写操作以任何顺序在内存之间传播。如果线程1的内存在发送x的更新之前将y的更新发送给线程2,如果线程2在这两个更新之间执行,它确实会看到结果r1=1,r2=0。
这一结果表明,ARM/POWER内存模型比TSO更弱:它对硬件的要求更少。ARM/POWER模型仍然允许TSO所做的各种重新排序。
Litmus Test: Store Buffering
Can this program see r1 = 0, r2 = 0?
// Thread 1 // Thread 2
x = 1 y = 1
r1 = y r2 = x
On sequentially consistent hardware: no.
On x86 (or other TSO): yes!
On ARM/POWER: yes!
复制代码
在ARM/POWER上,对x和y的写可能已经写入本地存储器,但当读发生在相反的线程上时,还没有传播。
下面是显示x86具有总存储顺序的含义的试金石测试。
Litmus Test: Independent Reads of Independent Writes (IRIW)
Can this program see r1 = 1, r2 = 0, r3 = 1, r4 = 0?
(Can Threads 3 and 4 see x and y change in different orders?)
// Thread 1 // Thread 2 // Thread 3 // Thread 4
x = 1 y = 1 r1 = x r3 = y
r2 = y r4 = x
On sequentially consistent hardware: no.
On x86 (or other TSO): no.
On ARM/POWER: yes!
复制代码
在ARM/POWER上,不同的线程可能以不同的顺序了解不同的写操作。它们不能保证在到达主内存的总写入顺序上达成一致,因此线程 3 可以看到 x 在 y 之前发生变化,而线程 4 则看到 y 在 x 之前发生变化。
另一个例子是,ARM/POWER系统对内存读取(加载)有可见的缓冲或重新排序,正如这个试金石测试所证明的那样。
Litmus Test: Load Buffering
Can this program see r1 = 1, r2 = 1?
(Can each thread's read happen after the other thread's write?)
// Thread 1 // Thread 2
r1 = x r2 = y
y = 1 x = 1
On sequentially consistent hardware: no.
On x86 (or other TSO): no.
On ARM/POWER: yes!
复制代码
任何顺序一致的交织必须从线程 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有一些障碍,我们可以在上面的例子中插入这些障碍,以强制实现顺序一致的行为。但是,显而易见的问题是,没有障碍的ARM/POWER是否完全排除了任何行为。任何试金石测试的答案都可以是 “不,这不可能发生 “吗?可以,当我们专注于一个单一的内存位置时。
这里有一个试金石测试,测试即使在 ARM 和 POWER 上也不可能发生的事情。
Litmus Test: Coherence
Can this program see r1 = 1, r2 = 2, r3 = 2, r4 = 1?
(Can Thread 3 see x = 1 before x = 2 while Thread 4 sees the reverse?)
// Thread 1 // Thread 2 // Thread 3 // Thread 4
x = 1 x = 2 r1 = x r3 = x
r2 = x r4 = x
On sequentially consistent hardware: no.
On x86 (or other TSO): no.
On ARM/POWER: no.
复制代码
这个试金石测试与前一个一样,但现在两个线程都在向一个单一的变量x写,而不是两个不同的变量x和y。线程1和2向x写冲突的值1和2,而线程3和线程4都读x两次。如果线程3看到x=1被x=2覆盖,线程4能看到相反的情况吗?
答案是否定的,即使在ARM/POWER上也是如此:系统中的线程必须就写到一个内存位置的总顺序达成一致。也就是说,线程必须同意哪些写入会覆盖其他写入。这一属性被称为一致性。如果没有一致性属性,处理器要么对内存的最终结果意见不一,要么报告内存位置从一个值到另一个值再回到第一个值的翻转。对这样的系统进行编程将是非常困难的。
我故意漏掉了ARM和POWER弱存储器模型中的许多微妙之处。关于更多的细节,请看Peter Sewell关于这个话题的任何一篇论文。有两个重要的观点需要注意。首先,这里有大量的微妙之处,这是由非常执着、非常聪明的人进行了十多年的学术研究的主题。我并不声称自己能理解其中的任何地方。这不是我们应该希望向普通程序员解释的东西,也不是我们在调试普通程序时可以希望保持的东西。第二,允许的东西和观察到的东西之间的差距使得未来出现不幸的惊喜。如果目前的硬件没有表现出全部允许的行为–特别是当我们很难推理出什么是允许的行为时!–那么不可避免的是,编写的程序会意外地依赖于实际硬件的更多限制行为。如果一个新的芯片的行为限制较少,那么破坏你的程序的新行为在技术上是被硬件内存模型所允许的–也就是说,这个错误在技术上是你的错误–这一事实并不令人感到安慰。这不是编写程序的方法。
弱排序和无数据走廊的顺序一致性
现在我希望你相信,硬件的细节是复杂而微妙的,不是你每次写程序时都想解决的问题。相反,找出 “如果你遵循这些简单的规则,你的程序只会产生好像是通过一些顺序一致的交织的结果 “这样的捷径会有帮助。(我们仍在讨论硬件,所以我们仍在讨论各个汇编指令的交错。)
Sarita Adve和Mark Hill在他们1990年的论文《弱排序–一个新的定义》中正是提出了这种方法。他们对 “弱排序 “的定义如下。
让同步模型成为一组关于内存访问的约束条件,规定如何以及何时需要进行同步。
当且仅当硬件对所有服从同步模型的软件来说是顺序一致的,那么硬件对同步模型来说就是弱有序的。
虽然他们的论文是关于捕捉当时的硬件设计(不是x86、ARM和POWER),但将讨论提升到特定设计之上的想法,使该论文在今天仍有意义。
我以前说过,”有效的优化不会改变有效程序的行为”。规则定义了有效的含义,然后任何硬件优化都必须保持这些程序在顺序一致的机器上的工作状态。当然,有趣的细节是规则本身,即定义程序有效含义的约束。
Adve和Hill提出了一种同步模型,他们称之为无数据链(DRF)。这个模型假设硬件的内存同步操作与普通内存的读写分开。普通内存的读写可以在同步操作之间重新排序,但它们不能跨越同步操作进行移动。也就是说,同步操作也是重新排序的障碍)。如果在所有理想化的顺序一致的执行中,不同线程对同一位置的任何两个普通内存访问要么都是读,要么被同步操作分开,迫使一个在另一个之前发生,那么就可以说程序是无数据链的。
让我们来看看一些例子,这些例子来自Adve和Hill的论文(为便于表述而重新绘制)。这里有一个单线程,它执行了对变量x的写操作,然后是对同一变量的读操作。
垂直的箭头标志着单线程内的执行顺序:先写后读。在这个程序中不存在竞赛,因为所有的事情都在一个单线程中。
相反,在这个双线程程序中,存在着一个竞赛。
在这里,线程2在没有与线程1协调的情况下写到了x。线程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标志着硬件内存模型的一个转折点,为硬件设计者和软件作者,至少是那些用汇编语言编写软件的人提供了一个明确的策略。正如我们在下一篇文章中所看到的,高级编程语言的内存模型问题并没有那么整齐划一的答案。
本系列的下一篇文章是关于编程语言的内存模型。
鸣谢
这一系列的文章极大地受益于与一长串工程师的讨论和反馈,我很幸运地在谷歌工作。我对他们表示感谢。我对任何错误或不受欢迎的意见承担全部责任。
通过www.DeepL.com/Translator(免费版)翻译