译《Foundations of the C++ Concurrency Memory Model》

本文翻译了《Foundations of the C++ Concurrency Memory Model》的摘要和正文。译文如下:

Foundations of the C++ Concurrency Memory Model
Hans-J. Boehm, Sarita V. Adve
2008

摘要

当前,多线程(multi-threaded)的 C 或 C++ 程序使用单线程语言并配合另外的线程库。这不完全合理。

我们叙述了为解决这些问题而进行的尝试,目前该工作已经接近完成。该研究成果是在 C++ 标准的下一个修订版中显式地为线程提供语义(semantics)。我们的方案与近来的 Java 类似。在其中,至少有一个定义明确的、令人感兴趣的语言子集。我们为无数据竞争(data race)程序提供了顺序一致(sequentially consistent)的语义。尽管如此,我们的一部分决策常常令人感到惊讶,甚至对熟悉 Java 的人来说也会如此。

  • 尽管在 Java 之后出现了实现(implementation)层面上的问题,但我们对无数据竞争(race-free)程序主要是坚持顺序一致性(consistency)。
  • 我们不给有数据竞争程序提供语义。因为不存在良性的 C++ 数据竞争。
  • 与已存在的语言或线程相比,我们为 trylock 使用了更弱的语义。这允许我们用直观的竞争条件来保证顺序一致性,对于用到 trylock 的程序而言也会如此。

这篇论文叙述了我们希望能为 C++ 线程程序员提供的简单模型,并且连同一些实用的、但常常被低估的实现约束,解释了这个模型是如何推动我们做出以上决策的。

一般术语 语言,标准化,可靠性

关键字 内存一致性,内存模型,顺序一致性,C++,trylock,数据竞争

1. 介绍

随着技术上的约束越来越限制单个处理器内核的性能,计算机产业正越来越依赖于更多的内核数量以提供对未来性能的改进。在许多领域,未来任何可观的性能收益,都明确地要求并行(parallel)应用程序。

用标准语言编写并行应用程序且被广泛接受的方式,是使用共享地址空间(address space)的线程组。虽然桌面应用程序向来更多地是处理多事件流而非并行处理器,但实际上它们大部分已经用到了线程。大多数此类应用程序都用 C 或 C++ 编写,且用到了操作系统提供的线程。我们将使用 Pthreads 作为正式的范例。在 Microsoft 平台上的情况与此类似。

当前 C 和 C++ 都被定义为单线程语言,与线程没有关联。相应地,编译器在很大程度上也不了解线程,且编译产生的代码也只针对单线程应用程序。在缺少进一步限制的情况下,这就允许了编译器执行变换操作(transformation),例如对两个无关变量的赋值语句重新排序(reorder),而此类变换在多线程程序中不能保证其意义的正确性。

操作系统线程库通过禁止并发地访问普通变量或数据竞争的方式,尝试非正式地解决该问题。为了阻止此类并发访问,线程库提供了一组同步原语集(synchronization primitives),例如 pthread_mutex_lock() 原语能限制共享变量一次只能被一条线程访问。因此,关于在一条给定线程内的普通内存操作,具体实现被禁止对同步操作(synchronization operation)重新排序。通过将同步操作视为是不透明的且会潜在地修改任何共享位置(location),上述机制被强加于编译器中。介于同步操作之间的普通内存操作,向来是假定为在仅限于单线程的约束条件下,可被随意地重新排序的。

不幸的是,正如 Boehm 详细叙述的那样,出于多种原因用以上方式把多线程语义非正式地描述成线程库的一部分是不全面的。简要来说,关键原因如下:

  • 当前线程库提供的非正式规格说明(specification)充其量是含糊不清的。例如,什么是数据竞争?究竟什么是无数据竞争程序的语义?图 1 中的例子,就体现出从当前的规格说明来看,这些问题的答案是不清晰的。

          初始时 X=Y=0
      
      T1              T2
      r1=X            r2=Y
      if (r1==1)      if (r2==1)
          Y=1             X=1
      
      允许 r1=r2=1 的结果吗?
    

    图 1。没有准确的定义,就无法清楚地知道该例子是不是无数据竞争(data-race-free)的。下方显示的结果是可能出现的。当线程 T1 和 T2 推测 X 和 Y 各自均为 1,然后各线程在验证另一线程的推测基础上,都做赋值 1 的操作。这样的执行过程在 X 和 Y 上存在数据竞争,但当要评估该程序是否无数据竞争时,多数程序员都不会预见到这一情况。
  • 没有精确的语义,很难推断出编译器在什么时候会违背这些语义规则?例如,Boehm 提到,基于当前规格说明的合理(常见)解释,不能排除编译器的一类变换操作,该类变换实际上会给潜在的共享变量引入新的写操作(write)。例子包括图 2 以及可推测的寄存器提升(register promotion)。其结果是,传统的编译器在没有违背规格说明的情况下,会很偶然地引入数据竞争,导致完全没有预料到(且不希望)的结果。

      struct a { char a; char b; } x;
      
      Thread 1;           Thread 2;
      x.a = 1;            x.b = 1;
      
      Thread 1 不等同于
      struct s tmp = x;
      tmp.a = 1;
      x = tmp;
    

    图 2。编译器的变换操作必须注意到线程。当 a 被声明为一个位字段(bit-field)时,许多编译器会执行上述类似的变换操作。由于在 thread 2 中对 b 的更新可能会因对整个 x 结构体的存储操作(store)而被覆写(overwrite),所以该变换操作对客户代码(client code)来说可能是可见的。
  • 在许多情况下,防止并发访问共享数据的开销(overhead)太高了。在不使用锁的情况下,供应商和许多应用程序通常会提供原子(atomic)的(不可分割的,对于未被保护的并发场景来说是安全的)数据访问功能。例如,gcc 提供了一系列 __sync 内部程序,Microsoft 提供了 Interlocked 操作,而 Linux 内核定义了它自己的原子操作(常常被(滥)用于用户级别(user-level)代码中)。这些方案都不令人满意,不仅因为它们不可移植,而且因为它们与其它共享内存变量间的交互方式常常并不严谨也没有被清晰地说明(specify)。

为了解决上述问题,多年以前开始尝试在 C++ 语言标准的下一个修订版中,适当地定义多线程 C++ 程序的语义,特别是内存模型。尽管该标准并未期望在 2009 或 2010 年前被完全批准,但有关于支持线程以及此处提及的内存模型的核心变化,已经获得投票通过进入 了 C++ 工作文件(working paper)中,也在 C 委员会的讨论范围内。这项工作正同时与 Microsoft 原生编译平台类似的一项研究相配合地进行着。

上述工作的关键成果,是支持单一编程模型的多线程 C++ 程序内存模型。这篇论文记述了此模型,以及几个新的、亟待解决的基本问题。

1.1 内存模型的研究现状

内存模型,也称为内存一致性模型,说明了共享变量在多线程程序中被读取后允许返回的值。内存模型无疑影响到编程能力。通过约束系统中任何部分可执行的变换,它也影响到性能和可移植性。实践中,系统内能变换程序的任何一部分(硬件和软件)都必须指明一种内存模型,并且在不同的系统层面,这些模型也必须是兼容的。例如,C++ 模型约束了 C++ 编译器所允许的变换;运行编译的二进制程序的硬件内存模型,也不得允许出现对最初程序中的 C++ 模型而言不合法的结果。

过去的二十年中,在内存模型上已经开展了大量的工作。由 Lamport 定义的顺序一致性是最直观的模型。它确保内存操作看上去发生在单一的整体次序(total order)中(如,原子性地);而且,在这样的整体次序下,给定线程的内存操作以该线程的程序次序(program order)出现。

不幸的是,顺序一致性限制了许多常见的编译器和硬件优化(optimization)。在判断顺序一致性变换何时是安全的编译器算法方面,以及在不违背顺序一致性的前提下推测性地执行传统优化的硬件方面,都已经取得了显著的研究性进展。然而,当前的商业化编译器和大多数商业化硬件并不保证顺序一致性。

为了克服顺序一致性的性能限制,硬件供应商和研究者们已经提出了多个宽松的(relaxed)内存模型。这些模型允许多种硬件优化,但它们大多数都被规定于低层中,并且通常难以给高级语言程序员解释明白。他们也受限于某些编译器优化。

无数据竞争的或被正确标注的模型,被作为替代方案提出,以达到顺序一致性的简单编程能力和宽松模型的实现灵活性。这种方案基于对良好编程实践的洞察。该实践让程序被正确地同步或无数据竞争。这些模型使正确的程序得以规范化,就像在任何顺序一致的执行过程(execution)中都不包含数据竞争的程序那样。它们为此类无数据竞争程序保证了顺序一致性,同时也不提供其他的任何保证。因此,该方式组合了一个简单的编程模型(顺序一致性)和高性能(通过只向编写良好的程序保证顺序一致性)。不同的无数据竞争模型相继完善了竞争的概念,以提供不断增加的灵活性,但同时也要求程序员提供不断增加的大量信息。data-race-free-0 使用了最简单的数据竞争定义,例如:两个互相冲突的并发访问(后面会对其规范化)。

在高级语言中,Ada 可能是第一个采用了要求同步访问且未定义语义方案的语言。正如上面提到的,Pthreads 遵从了一个类似的方案,但无论是 Pthreads 还是 Ada 都没有将其足够地规范化。最近,Java 内存模型经历了一次重大修订。出于实践的目的,对程序员来说,新的 Java 模型是 data-race-free-0。然而,Java 的安全保证杜绝了对数据竞争的未定义语义。故而,Java 的大部分工作聚焦于用某一方式定义这些语义。这种方式在不破坏 Java 的安全性和安全保障的前提下,极大地保持了实现上的灵活性。尽管如此,Java 模型也的确将一些编译器优化排除在外,而且整个模型相当复杂。因为 C++ 不是类型安全的语言,所以整个 Java 模型的限制和复杂性似乎都不适合 C++。

1.2 C++ 模型和这篇论文的贡献

C++ 所采用的模型是 data-race-free-0 的改编版。例如,它保证了无数据竞争程序的顺序一致性,且没有为数据竞争提供定义好的语义。考虑到近期在上述 Java 模型上开展的工作,data-race-free-0 模型可能看上去是一个显而易见的选择。然而当这一过程开始后,存在许多因素阻止我们使用 data-race-free-0,部分因素是在得出 Java 的工作结论后被暴露出来的,且与 Java 的工作相关。这篇论文描述了的这些因素的认识;展示了是如何解决它们的,以使得 data-race-free-0 可被 C++ 程序员和大多数硬件供应商接受;还提供了在之前工作的完整背景下的 C++ 模型的首次公开描述。特别是在这篇论文中我们所讨论的三个主要问题:

(1)顺序一致的原子操作(atomics)。无数据竞争模型要求所有的非普通数据操作(data operation)(例如,同步操作、C++ 的原子操作、Java 的 volatile 变量)都显得是顺序一致的。最近出现的多核系统暴露出的重要硬件优化似乎与这些要求有冲突,最初导致了大部分硬件供应商和许多软件开发人员抵制它。我们描述了该冲突,并展示了无约束地利用此类硬件优化会导致模型难以规范化和使用。这些论断负责说服硬件供应商(如,AMD 和 Intel),让其开发出的规格说明与顺序一致的原子操作保持一致。

(2)Trylock 及其对数据竞争定义的影响。类似 trylock 的同步原语能用于非直观的方式,但它要求更复杂的数据竞争定义和非常严格的壁垒。我们展示了解决该问题的一种简单方法。

(3)数据竞争的语义。我们不给有数据竞争程序提供任何语义。这并非无可争议,我们在第 5 节中解释关于此状况的论述。

我们相信顺序一致的原子操作提供了一种编程模型。相较于替代方案,它更易于使用和描述,这正是我们所努力追求的。然而,在标准化的过程中,我们清楚地认识到,给顺序一致的原子操作提供特有的支持是不切实际的,主要有两个原因:

  • 在既有的一些处理器上实现一套对性能关键代码而言必要的、仅限于专家使用的替代方案,是过于昂贵了。我们并不清除这种需求是否会长期存在。
  • 既有的代码常使用一种假定了弱内存排序(weak memory ordering)语义的方式编写,且依赖程序员明确地提供与平台相关的、必要的硬件指令用于强化必要的排序。Linux 内核就是一个很好的例子。如果我们提供的原语更接近此类代码假设的语义(当前偶尔不正确),迁移此代码常常会更简单。

结果是,C++ 工作文件既提供了顺序一致的原子操作,还提供了一种用明确的规格说明来弱化内存排序的机制。我们把后者称为低层原子操作(low-level atomics)。不幸的是,这需要一个更加相当复杂的内存模型。

这里我们首先呈现的是一个简单模型,我们期望的是有更多的程序员使用它。这就足以理解这篇论文的核心要点。然后我们回过头来,更近地展示支持额外低层原子操作的标准工作文件,并概要地证明了这两种规范是等价的。

2. 没有低层原子操作的 C++ 模型

如果我们有一个完全正式的、顺序一致的语义,我们会尝试在论文中精确且充分地说明内存模型,这能够转变为完全正式的、对多线程语义的描述。本节假设只有一种风格的原子操作,即标准中默认的、顺序一致的原子操作。

内存操作被视为操作抽象的内存位置。除了在相同的、最深层的 struct 或 class 内部的连续相邻位字段的声明被视为同一个位置之外,其它每个标量值都占据单独的内存位置。基于此,图 2 中的 struct s 中的各个域,都占有可独立更新的位置,除非我们把两个域的声明都改为位字段。

为了在由单个线程执行的内存操作上定义 sequenced-before 关系,C++ 标准备忘录也因此进行了修改。这个类似于 Java 中的程序次序关系和关于内存模型的其它工作。不同于之前工作的是,这仅仅是每条线程的部分次序(partial order),它反映出了未定义的论证评估次序(evaluation order)。

定义一个内存动作(memory action)要包括:

  1. 动作类型;例如,lock、unlock、atomic load、atomic store、atomic read-modify-write、load 和 store。除了最后两类动作,其他都被认为是同步操作,因为它们都用于线程间通信。后两个动作被认为是数据操作。
  2. 标识相应程序点的标签。
  3. 读和写的值。

位字段的更新可被建模为相邻位字段连续的 load 操作,并紧接着对整个顺序的 store 操作。

线程的执行过程可定义为,连同与 sequenced-before 排序相应的部分次序一起的一组内存动作。

程序的顺序一致的执行过程可定义为,连同在所有内存动作上的 <T 的整体次序一起的一组线程的执行过程。其满足以下约束条件:

  1. 每条线程的执行过程都是内部一致(internally consistent)的,针对从内存中读到的值,该过程与那条线程正确的顺序执行过程保持一致,同时也考虑到操作的排序也受到 sequenced-before 关系的影响。
  2. T 与 sequenced-before 的次序一致。例如,如果 a 排在 b 之前,则 a <T b。
  3. 每个 load、lock 和 read-modify-write 操作根据 <T 关系,从先前最后一个写到的相同位置读取值。在给定锁上的、先于 unlock 的最后一个操作,必须是由同一条线程执行的 lock 操作。

这有效地要求了 <T 只交替(interleaving)地运用于单个线程动作之间。

如果访问相同内存位置的两个内存操作之间发生冲突,它们中至少有一个是 store、atomic store 或 atomic read-modify-write 操作。在顺序一致的执行过程中,来自不同线程的两个内存操作之间如果发生冲突,就形成了 1 类数据竞争(type 1 data race),它们中至少有一个是数据操作,且它们在 <T 关系上是相邻的(例如,它们可能被并发执行)。

现在,我们可以简单地说明 C++ 内存模型:

  • (在给定的输入条件下)如果程序含有带 1 类数据竞争的、顺序执行的执行过程,那么它的行为是未定义的。
  • 否则,(在相同输入条件下)该程序的行为表现应与某条线程的顺序一致的执行过程一样。

2.1 此模型允许的优化

之前的工作表明,使用上述模型的话,如果线程内语义允许重新排序,那么硬件和编译器可以自由地将如下内存操作 M1 重新排到内存操作 M2 之前:(1)M1 是数据操作,M2 是同步的读操作;(2)M1 是同步的写操作,M2 是数据操作;(3)M1 和 M2 都是数据操作,且它们之间没有同步的 sequenced-order 关系。

此外,当 lock 和 unlock 操作被用于结构良好(well-structured)的方式时,重新排序如下 M1 和 M2(它们是 sequenced-before 关系)是安全的(假设它们被线程内语义所允许):M1 是数据操作,M2 是 lock 后的写操作;或者 M1 是 unlock 操作,M2 是 lock 后的读或写操作。

最后,之前的工作也讨论了针对非原子地(non-atomically)执行写操作的硬件优化,例如,在写数据操作的值对所有线程可见之前,让其对某一线程可见。对于上述模型,数据的写操作,以及来自与结构良好的 lock 和 unlock 中的写操作,能被非原子化地执行。

我们标注了对术语 atomic 的过度使用。(1)上述用于描述写操作是如何在硬件上被执行的。(2)作为 C++ 的修饰符,用于说明一类特殊的内存操作。C++ 中默认的写原子操作(限定为顺序一致的,且只在这里讨论)需要由硬件原子化地执行。但另一类低层的写原子操作(之后讨论)在上述情境中不必被原子化地执行,尽管在没有其它单独线程能看到部分更新的值的情况下它依然是原子的。

这个模型给同步操作强加了重要的限制。出于所有实际目的,同步操作必须对彼此显得是顺序一致的。这意味着,今后一旦分别谈到 sequenced-order 和 write-atomically 需求时,原子操作必须在 sequenced-before 次序中执行,且必须原子地执行写原子操作。对于 lock 和 unlock,由于它们的特殊用法限制,使上面提到的一些优化是可行的,而不必违背顺序一致性的表象。

我们在允许的优化上做进一步的研究。

3. 使 Trylock 高效

通过尝试在不阻塞的情况下获取锁的调用,前述内容给程序带来了强烈的、令人不爽的语义。例如,pthread_mutex_trylock(),或在一定时间内获取(acquisition)锁。考虑图 3 中的例子:

    T1              T2

x = 42;         while (trylock(l) == success)
lock(l);            unlock(l);
                assert(x == 42);

图 3。令人不爽的 trylock 用法

该程序让一条线程等待 T1 以获取锁,而不是等待锁被释放,它必然颠倒了对锁 l 的正确使用。这不是一个值得学习的编程惯例。

基于对 trylock() 的传统解释,在顺序一致的执行过程中,T2 中的断言不能被执行(例如,它在 <T 排序中),直到 T1 获得了锁且它将 42 赋值给 x。因此,该程序在我们当前的语义中是无数据竞争的,此断言不会失败。

麻烦在于如果编译器或硬件将赋值语句移到 lock() 之后,T1 中的两条语句会被重新排序,断言就会失败。在许多的计算机体系中,禁止此类重排则需要在 lock() 之前放置一个内存屏障。正如在 2.1 节中提到的,对于使用结构良好的 lock 和 unlock 而言,这样的重新排序是安全的。然而这个屏障体现了不必要的开销,它很可能使获取锁的成本翻倍,对合理地编写代码没有好处。其结果是,尽管此要求似乎已经存在于 Posix 中,但许多实现锁的方式未能强化此项要求。

存在一个与在 T2 中给最终失败的 trylock() 和断言重新排序的类似问题。甚至 Posix 都试着允许这样的重排。

这里本质的问题是,trylock() 读取由 T1 的 lock 写入的值,并以此推断锁已经被获取了。从 lock 的写操作到 trylock 的读操作之间的通信被用于同步对 x 的访问。我们希望阻止此类用法,以避免为所有锁的屏障付出额外开销。通过明确地区分同步操作的不同类型,或者重新定义仅仅被诸如数据竞争的同步所隔开的数据操作,之前的工作已经达到了这个目的。例如,data-race-free-1 模型,区分成对和不成对的同步,只允许前者阻止数据竞争。Java 内存模型要求,存在冲突的普通(数据)操作要明确地被 volatiles 或成对的 lock/unlock 排序,以避免数据竞争。happens-before 关系被用于规范化这些概念。

我们提出了一个简单的解决方案,而不是为了定义数据竞争而引入不同同步类型的复杂性或是 happens-before 关系。尽管最好是不改变 trylock() 的实现,但我们还是改变了 trylock() 的规格说明:当锁是可用的时,不保证 trylock() 会成功。C++0x 的规格说明预期会允许 trylock() 在此情境下“伪造地失败”。这有效地阻止了因准确暴露任何有关锁的状态而导致失败的 trylock(也因此,lock 写操作对于传递同步信息而言没有意义)。特别是,现在上述例子中的断言无疑可能会失败。断言失败的执行过程现在是顺序一致的。这仅仅是包含了一个“伪造的” trylock() 失败。

对于我们的能力而言,尽管它很简单,但这是第一个消除了在 lock 之前需要提供屏障的解决方案,同时依然维持了竞争的简单定义(即,在整体次序中相邻的访问之间会产生冲突)。

为了备注这次讨论,读者可能假设 trylock() 允许包含伪造的失败。成功的 trylock() 被视为 lock(),而不成功的 trylock() 被内存模型视为无此操作(no-op)。

4. 顺序一致的原子操作的成本

正如在 2.1 节中提到的,此模型要求原子操作看上去是顺序一致的。在这份工作开始时,来自许多硬件供应商和软件开发人员担心这个要求过于严苛且不必要地限制了性能。

原则上,这些要求对性能的影响应仅限于同步操作。不幸的是,当前多数处理器指令集没有直接区分同步操作(Itanium 是一个明显的例外)。编译器把内存排序的要求传递给硬件的常见方式,是通过内存屏障或内存阻挡指令。

屏障是主要被设计用于传递 sequenced-order 要求。例如,最严格的屏障确保顺序在屏障之前的所有操作将会先于顺序在屏障之后的所有操作执行。其它种类的屏障会在内存操作的不同子集上强制排序,例如, Store|Load 屏障将 store 操作排在后面的 load 操作之前。许多处理器默认地保证某种排序方式,并在那些情况下会删除对屏障的要求。例如,AMD64 和 Intel64 隐式地在每个 load 操作后提供 Load|Load 和 Load|Store 屏障语义,也在每个 store 操作后提供 Store|Store 屏障语义。

就像在 2.1 节中描述的(Alpha 和 Sun 是明显的例外),在这个工作开始时,许多屏障的规格说明中,关于它们对硬件的写-原子性(write-atomicity)的影响是含糊不清的。一些处理器供应商声称,完整的写-原子性其代价太高;软件开发人员声称,弱化版的写-原子性已经足够了。4.1 节讨论了在硬件中强制实施写-原子性的成本,以及为什么对某些程序来说是非必要的。4.2 节中系统性地展示了写-原子性的宽松处理方式导致其他程序产生意外后果的重要意义。虽然我们尝试了许多次,但结果是,正式确定比硬件顺序一致性还要弱的、且有意义的同步操作语义是很困难的,且我们给程序员提供了足够简单的接口。

在本节中,例子中用到的所有变量都是原子的。(语法规则没有体现当前 C++ 工作文件的内容。)

4.1 强制实施写-原子性的成本

考虑图 4 中被称为 Independent-Reads-Independent-Writes(IRIW)的例子。屏障能确保读操作是按程序顺序执行的,但如果写操作以非原子地方式执行,这就没有保证顺序一致性。举个例子,如果写入 X 和 Y 的操作以不同的次序传达到 T3 和 T4 线程,就能出现图中违背顺序一致性的结果(T3 看到了 X 的新值和 Y 的旧值,T4 反过来也这样)。

            初始时 X=Y=0
T1          T2          T3          T4
X=1         Y=1         r1=X        r3=Y
                        fence       fence
                        r2=Y        r4=X

r1=1, r2=0, r3=1, r4=0 违背了写-原子性

图 4。写-原子性的代价对于 IRIW 而言可能太高。

具有基于所有权的无效性协议(ownership-based invalidation protocols)和单核或单线程处理器的系统,能以简单的方式避免非顺序一致的结果。考虑这样一个典型的、使用基于目录的缓存一致性协议(directory-based cache coherence protocol)的系统。如果 T1 没有有 X 的所有权,那么它就必须从目录中请求所有权。目录告知 X 的所有缓存拷贝均已失效,同时它或者 T1 收集这些失效通知的认可应答。为了看到 X 的新值,T3 首先必须进到转发请求给 T1 的目录中,为了确保写操作也像是原子的,该系统只需要在 T3 得到 X 的更新拷贝前,确保 X 的所有拷贝都已失效(即已收到了所有的认可应答)。与此类似,在 T4 得到 Y 的更新拷贝前,所有 Y 的拷贝都已失效。现在,T3 和 T4 各自都不可能再读到 X 和 Y 的旧值。

确保上述例子中顺序一致性的关键是,不允许读操作在被访问位置的所有旧拷贝失效前从该位置返回新值。那些对读操作排序需要明确屏障的处理器来说,这个要求可以被进一步放宽——只要随后的屏障等到该位置的所有旧值失效,这些读操作就能返回新值。以下内容中,我们称之为 read-others'-write-early 约束。

请注意,对于任何位置(如,总线)的所有操作来说,上述 read-others'-write-early 约束不需要全局的序列化点。对于在相同位置的读写操作来说,它仅需要一个序列化点。这通常由缓存一致性协议提供(这种需求是被广泛接受的)。进而从技术上说,每个位置的序列化只对原子操作来说是必要的。不幸的是,使用屏障进行内存排序,混淆了哪些内存访问是原子的。因此,一旦缺少区分写原子操作的机制,我们的系统必须为所有写操作维护写-原子性。

多核和并发多线程(simultaneous multithreading, SMT)的出现让线程能共享数据缓存和存储队列,拥有之前从未探索过的 read-others'-write-early 约束的本质。这些体系结构提供了吸引人的性能优化的机会,能让 IRIW 中的范例违反顺序一致性。假定 T1 和 T3 共享一级写通数据缓存(L1 writethrough data cache)。假定 T3 读 X 就正好发生在 T1 写 X 的前面。即便 T1 还没有通过低层缓存体系获取到 X 的所有权,给 T3 返回 X 的新值也是很吸引人的事。如果 T2 和 T4 共享一个缓存,那么它们读写 Y 的操作也会发生类似的情况——甚至在 T2 发出获取所有权的请求到达内存系统的其余部分前,T4 就能读到 Y 的新值。现在,T3 和 T4 分别(从它们的缓存中)各自读到 Y 和 X 的旧值,这违反了顺序一致性。

因此,read-others'-write-early 约束似乎只对主存系统和存储一致性协议有(可接受的)影响,新的 SMT 和多核系统体系结构将此约束一直移到一级缓存,甚至(如果有共享队列的话)会放到处理器中。与此同时,大多数程序员认为IRIW 代码不代表有用的编程惯例,为提供顺序一致性而给它强加约束显得多余且没有必要。

出于这个原因,许多既有的机器没有为确保 IRIW 的顺序一致性而提供有效方法,且最初不情愿接纳原子操作的顺序一致性语义。

4.2 放宽写-原子性的意外后果

接下来我们展示一些例子,它们按照 IRIW 描述的方式放宽 read-others'-write-early 要求,会带来不可接受的结果。

4.2.1 必须维持 Write-to-Read 因果关系

图 5 例举了一个简单的因果关系。线程 T1 给 X 写入新值,T2 读取它,放置一个屏障,再给 Y 写入新值。T3 读取 Y 的新值,放置一个屏障,之后读取 X。

            初始时 X=Y=0
T1          T2          T3
X=1         r1=X        r2=Y
            fence       fence
            Y=1         r3=X

r1=1, r2=1, r3=0 违反了写-原子性

图 5。必须考虑 Write-to-Read 因果关系(WRC)。

顺序一致性要求 T3 返回 X 的新值。因为一条线程写入新值,紧跟着另一条线程读取相同的值。这被用于在线程间建立因果联系。所以,我们称这个例子为 Write-to-Read 因果关系(WRC)。大多数程序员认为应该遵守这种因果关系,而不允许出现图 5 所示的违反顺序一致性的结果。

现在考虑在 WRC 上应用 IRIW 优化。假定 T1 和 T2 共享一级写通缓存,T3 在单独的系统节点上。假定允许 T2 较早地读取到 T1 给 X 的新值,那么有可能 T3 在得到无效的 X 之前就得到了无效的 Y。结果是 T3 读到了 Y 的新值和 X 的旧值。

既保持 WRC 的顺序一致性,又保留大部分 IRIW 优化所带来的影响的一个解决方案,是确保在所有节点看来,来自于给定系统节点的、被屏障隔离的写操作是在同一次序下执行的。该方案依然允许节点内的读操作较早地返回新值。我们接下来展示这个解决方案。不幸的是,它对其他程序来说是不够的。

4.2.2 Read-to-Write 因果关系(RWC)

图 6 例举了与 WRC 相似的例子。只不过 T2 和 T3 间的因果关系是通过在 T2 读取一份 Y 的旧值,紧接着 T3 给 Y 写入新值而建立的。对于这个与 WRC 相似的例子,IRIW 优化能违反顺序一致性。不幸的是,WRC 解决方案(为那些被相同节点的屏障所分隔的写操作排序)在这种情况下不起作用。因为每个节点(T1 / T2 或 T3)都至少包含一个写操作。

            初始时 X=Y=0
T1          T2          T3
X=1         r1=X        Y=1
            fence       fence
            r2=Y        r3=X

r1=1, r2=0, r3=0 违反了写-原子性

图 6。应该遵守 Read-to-Write 因果关系(RWC)吗?

与 WRC 例子不同,许多程序员可能发现,这种违反 read-to-write 因果关系的结果是可接受的。然而,它不像 IRIW 代码那样清晰明了。

4.2.3 一致性和因果关系间的微妙相互作用(Interplay)

我们最后的例子展示了,IRIW 风格的优化是如何以非直观方式,用微妙的相互作用干涉缓存一致性和 write-to-read 因果关系(被称为 CC)之间的。缓存一致性是被广泛接受的特性,用于保证从所有线程来看,对同一位置的写操作是以相同次序发生的。广泛的共识认为,(同步操作)必须考虑这一特性才能编写出有意义的代码。还有人认为 write-to-read 因果关系也必须考虑在内。图 7 说明了 IRIW 风格的优化方案难以将缓存一致性与简单的 write-to-read 因果关系之间的交互组合到一起。

                初始时 X=Y=0
T1          T2          T3          T4
X=1         r1=X        Y=1         r3=X
            fence       fence       fence
            r2=Y        X=2         r4=X

r1=1, r2=0, r3=2, r4=1 违反了写-原子性

图 7。CC:缓存一致性和 write-to-read 因果关系间的微妙相互作用

在图 7 中,再次假设 T1 和 T2 都在同一个带有共享一级写通缓存的节点上。T3 和 T4 在单独的节点上。考虑用到 RWC,T2 较早地读取 T1 中 X 的新值,放置一个屏障,然后读取 Y 的旧值。现在,假设 T3 给 Y 写入一个新值,放置一个屏障,然后将 X 更新为 2。当 T3 的所有操作在内存系统中完成后,T4 读取 X(返回 2)并放下一个屏障。在这一点上,假设 T1 中要求写入 X 的请求遍历了整个内存层次结构,且所有的失效化请求均已完成。那么当 T4 第二次读取 X 时,它得到的是 1。

因此,如图 7 所示,IRIW 优化能导致违反了顺序一致性的结果。和 RWC 一样,WRC 解决方案也不适用,因为没有节点拥有多于一个的写操作。很明显,没有其他的简单解决方案能保持 IRIW 优化的优势。

似乎难以规范化和解释以上例子中违背顺序一致性的内存模型,因为这样的模型无法简单地组合缓存一致性和 write-to-read 因果关系。特别是在上面的例子中,T4 确定了 X=2 在 X=1 之前于内存体系中被序列化。因此缓存一致性允许以下推论:T2 从 X 中读到 1,必须发生在 T3 给 X 写入 2 之后。使用 write-to-read 因果关系的论断可以得出的推论是:T3 对 Y 的写操作,必须发生在 T2 对 Y 的读操作之前,而后者应该返回 1 而不是 0。

因此,IRIW 优化以及图 7 的后续结果,排除了来自缓存一致性和 write-to-read 因果关系之间的、简单直观的组合中的推论。

4.3 对当前处理器的影响

上述例子表明,同步操作违背顺序一致性会导致微妙的、非直观的行为,难以用直观的方式规范它。所以,我们为默认的原子操作和同步操作保留了顺序一致性语义。

受我们工作的影响,现在新的 AMD64 和 Intel64 内存排序规格说明提供了一个清晰的方式来保证顺序一致性,尽管这些规格说明要求原子的写操作映射到原子的 xchg 指令(这是 read-modify-write 指令)。硬件现在仅需要确保原子化地执行 xchg 写操作。此外,有了这些规格说明,xchg 也隐式地确保了 Store|Load 屏障的语义,也不必在原子的 store 操作后明确地放置一个屏障(否则 sequenced-before 将需要这样一个屏障)。

尽管把原子的 store 操作转换为 read-modify-writes 操作显得有些笨拙且低效,但以下的研究结果使其成为合理的妥协方案:(1)惩罚 store 操作而不是 load 操作会更好,因为前者用得不太频繁;(2)如今在许多处理器上,将 Store|Load 屏障替换成 read-modify-write 也同样昂贵。

有三种其他的方式可以实现原子操作的顺序一致性,也不必将原子的 store 操作转变为 read-modify-write。第一种方式,处理器 ISA(指令集架构)能提供一种简单的机制以区分原子的 store 操作(例如,Itanium 的存储和释放操作),而不必使用 read-modify-write 操作。这样,硬件能确保 store 操作被原子化地执行。

第二种方式,写-原子性可以被简便地提供给所有的 write 操作,就如同 Sun 的整体存储次序(Total Store Order,TSO)和 Alpha 内存模型。除了它们因写操作而被调用之外,这种方式的实现机制类似于那些实现了 read-modify-write 和存储/释放类型的解决方案。

第三种方式,当前许多处理器使用推测的方式,给内存模型不允许的内存操作重新排序。例如,AMD 和 Intel 的处理器甚至在内存模型不允许的情况下,推测性地重排 load 操作。一种类似的方式被用于允许读操作较早地从共享缓存中返回值。到目前为止,关于此类优化的文献,都关注推测性地放宽 sequenced-order 要求所带来的好处。我们不清楚以前工作对关于推测性地放宽写-原子性带来的影响的认识。

据我们所知,当今大多数既有的机器都会默认提供写-原子性。一个显著的例外是某些 PowerPC 机器。它们为了实现顺序一致性的结果,对 RWC 和 CC 的例子来说,要求在原子的 load 操作之后放置一个特别昂贵的屏障指令。

5. 数据竞争语义

对于像 Java 这样的语言,定义所有的程序语义是至关重要的,其中就包括有数据竞争的程序。Java 必须支持不受信任的、“沙箱(sandbox)中的”代码的执行过程。很明显,此类代码能引入数据竞争。语言必须保证当出现此类数据竞争时,至少没有违反基本的安全特性。因此在规格说明中,Java 内存模型以极大的复杂性为代价,异常谨慎地给有数据竞争的程序提供合理的语义。

对 C++ 来说,没有这样的问题。最初的顾虑是,我们应该限制有竞争的程序的行为。然而,我们最后决定将这样的程序语义作为完全未定义的。尽管在 C++ 标准的当前工作文件中,还有诸如“有没有良好的数据竞争”的争论。

在 C++ 中,关于未定义的数据竞争最基本的论断是:

  1. 尽管被低估,但这实际上就是现状。Pthreads 声明“应用程序应确保,被多于一条控制线程访问的任何内存位置,应该被限制为:当一条控制线程正在修改某个内存位置时,没有其它的线程可以读或写。”。正如我们在介绍中提到的,Ada 早些时候采用了相同方式。Win32 线程背后的意图看起来也类似。
  2. 由于 C++ 工作文件提供的低层原子操作带有很弱的、可廉价实施的的排序特征,因此除了混淆代码以外,允许数据竞争不会带来任何好处。我们实际上只要求程序员标注出此类数据竞争。由于数据竞争的结果常常及其微妙,我们相信在任何情况下、任何合理的代码标准都需要这个建议。
  3. 为数据竞争提供类似 Java 的语义,可能大幅增加 C++ 的构造成本。由于那样会导致不可控的分支,所以这大概需要我们在出现数据竞争时,不公开未初始化的虚函数表。这反过来又常常需要在创造对象时加上屏障。在 Java 中,由于对象的创建总是与消耗一些成本的内存分配有关,所以这个问题可以说不是那么重要。这不适用于 C++。
  4. 当前的编译器优化常常假设对象一直不变,直到通过潜在的别名发生中途的赋值操作为止。违反这样的内在假设,将产生非常复杂的影响,这将难以向程序员解释或者难以在标准上界定。我们相信这样的假设在当前的优化器中是根深蒂固的,且难以有效地删除它。

作为最后一种情况的例子,考虑一个相对简单的、不包括任何同步代码的例子。假设 x 是全局共享变量,其他变量是局部变量,

unsigned i = x;
if (i < 2) {
    foo: ...
    switch (i) {
        case 0: ...; break;
        case 1: ...; break;
        default: ...;
    }
}

假设在 foo 标签处的代码相当复杂,会强制使 i 溢出,switch 的实现使用了一个分支表。(现实中,第二条假设可能会要求一个更大的 switch 声明。)

现在,编译器执行以下合理的优化:

  • 注意 i 和 x(通常来看)包含相同的值。因此当 i 溢出时,无需存储其值;该值能从 x 重新加载。
  • 注意,通过范围分析,switch 表达式的值要么是 0 要么是 1。因此在分支表中去掉了边界检查和 default 分支。

现在考虑一个编译器不了解的情况:x 实际上存在数据竞争,foo 标签处的代码在执行过程中,x 的值变为了 5。结果是,

  1. 当评估 switch 表达式而重新加载 i 时,会得到 5 而非它的初始值。
  2. 使用越界的值 5 去访问分支表,会导致无用的分支目标。
  3. 我们将不可控的分支引向了随意代码处。

如果 switch 判断的是 x 而不是 i,那么结果就不会那么令人惊讶。除了让其完全未定义之外,似乎很难用任何其他方式解释语言标准中的任何一种行为。

虽然 Java 最终付出了巨大的努力来定义语义,但近期 David Aspinall 和 Jaroslav Sevcik 却从 Java 的规格说明中发现了一些问题。

因此,尽管在可调试性上有一些潜在的负面影响,但我们还是决定让数据竞争语义是未定义的。

5.1 编译器引入的数据竞争

由于我们期望 C++ 的下一个标准会在源码层面完全禁止数据竞争,所以不太可能写一个可移植的、引入数据竞争的 C++-to-C++ 源码转换器。特别是 source-to-source 优化器可能永远不会引入数据竞争。

这个需求不允许一些重要的优化,特别是它禁止优化器引入推测性的 load 和 store 操作。诸如部分冗余消除(Partial Redundancy Elimination)的优化方案常常引入推测性的 load 操作。例如,如果潜在共享的、且在循环中不变的变量 x 不可能在循环内被实际引用,那么该要求会阻止它在循环之外被加载到寄存器中。尽管预先取出此类变量的值通常是正确的,但在很明确地需要它的值之前,这个要求也阻止预先安排 load 操作。

重要的是要注意,这个限制只适用于优化器的目标语言禁止数据竞争的情况。我们不清楚是否有哪个硬件体系结构这样做了。上面的优化潜在地引入了有竞争的 load 操作,而这些操作的结果没有被使用。在不必修改剩余代码的含义的情况下,机器体系结构允许这样做。因此,在传统硬件体系结构上运行的编译器,可以继续插入推测性的 load 操作。

这样的优化通常不会插入推测性的 store 操作。甚至当原始程序不包含竞争时,那些 store 操作会覆写另一条线程中正当的 store 操作,也因此改变了程序的语义。

如果一个编译器要转换到某种体系结构上,且该体系结构会在机器层面为竞争提供未定义的语义,那么该编译器将被禁止引入推测性的 load 操作。例如,如果目标环境是一个会检测竞争的虚拟机,就会出现前面的情况。这也是应该出现的结果。任何被引入的推测性的 load 操作会与另一条线程中的 store 操作之间产生竞争,从而也导致会在没有竞争的原始程序中检测是否有竞争。基于相同理由,由于 C++-to-C++ 转换器不了解最终的目标环境,所以它不应该引入数据竞争。

6. 支持低层原子操作的 C++ 模型

正如先前提到的,在一些平台上强制实施顺序一致性会很昂贵,且有一些常用的编程惯例不需要顺序一致性。

例如,通常,频繁地被多个线程累加的计数器在所有线程结束后就只用于读。我们的语义保证了顺序一致性,在计数器更新之前的所有内存操作要在另一条线程中任何稍后的计数器更新之后变得可见。该特性向来会要求额外的屏障,这在许多情况下比其余的操作要昂贵得多。

类似的,在缺少上下文信息的情况下,顺序一致的、原子的 store 操作常常需要 2 个屏障,一个在 store 之前,一个在 store 之后。第一个屏障确保在 store 之前的所有内存操作,对于要查看 store 结果的任何线程来说是可见的。第二个屏障常常更昂贵,它确保 store 操作不会因后面的 load 原子操作而被重新排序。例如,实现 Dekker's 算法时需要后一个的特性,但常常不是必须的。

实际上 C++ 原子库规格说明支持低层原子操作,允许程序员明确地指明内存排序约束,让非常谨慎的程序员接近编程惯例的优化实现方案。

截止目前所述,我们的内存模型本身不支持非顺序一致(non-sequentially-consistent)的同步操作。假设 incr 操作在不强制要求任何类型的明显排序的情况下,递增一个变量的值。(例如,Alpha,ARM 和 PowerPC 允许 incr 采用一种比顺序一致性廉价很多的实现方案。)现在,考虑下面的内容,x 和 y 都是原子的,z 不是,所有的初始值都是 0:

Thread 1:           Thread 2:
incr(x);            incr(y);
r1 = y;             r2 = x;
if (r1 == 0)        if (r2 == 0)
    z = 1;              z = 2;

在顺序一致的执行过程中,r1 和 r2 不可能都为零,因此在顺序一致的执行过程中不会有数据竞争。然而,利用了原子操作间无排序这一优点的任何实现方案,允许两个值均为零,也因此会潜在地遇到数据竞争。所以,通过上一节的讨论,这种情况需要给予未定义语义。但这要求我们用实际的语义来定义数据竞争的概念,而不是用顺序一致的执行过程来定义。

这里,我们给出一份备选的内存模型规格说明,能扩展到低层的原子操作。该描述是 C++ 工作文件中的一个更为精确的数学公式。我们暂时忽略低层原子操作。这里出现的模型等价于之前的模型(在后续章节中展示)。本论文的最后一节简要地描述了低层的原子操作实际上是如何被合并进 C++ 工作文件的。

虽然存在差异,但相较于我们的最初版本,这个版本更接近于简化的 Java 内存模型。

将程序执行过程定义为:

  1. 一组线程执行过程。
  2. 映射关系 W:在相同内存位置上,从原子的 load 和 原子的 read-modify-write 操作映射到原子的 store 和 原子的 read-modify-write 操作;在同一个锁上,从获取锁映射到释放锁。W 旨在匹配类读(read-like)操作和相应的写操作,这些写操作的值正是 W 所监测的。
  3. 在原子操作中不相反的整体次序 <S 旨在反应它们被执行过程中的全局次序。

我们把原子的 load、read-modify-write 操作或获取锁操作,定义为读取位置处的 acquire 操作。与之相反,原子的 store、read-modify-write 操作或释放锁操作,被定义为在受影响的内存位置上的 release 操作。如果内存动作 A 是某个位置上的 release 操作,B 是同一位置上的 acquire 操作,我们定义 A 与 B 是同步的,记为 W(B) = A。

我们把 happens-before(<hb)定义为内存动作间的最小关系,例如,

  • 如果 a 在顺序上先于 b,那么 a 在 b 之前发生。
  • 如果 a 与 b 同步,那么 a 在 b 之前发生。
  • 如果 a 在 b 之前发生,b 在 c 之前发生,那么 a 在 c 之前发生。

我们把关于 load 或 read-modify-write 操作 b 的可见负面影响(visible side effect),定义为在相同位置 l 处的一次更新(store 或 read-modify-write 操作)a。例如,a 在 b 之前发生,但不存在此期间发生在 l 位置上的更新 c,即 a 在 c 之前发生,c 也在 b 之前发生。(其目的是在不存在数据竞争的情况下,普通的 load 操作会看到惟一的可见负面影响。)

如果程序的执行过程满足以下条件,我们将该过程定义为一致的(这是在 C++ 工作文件中没有明确提及的观念),

  1. 给定从内存中读取的值,每条线程的执行过程内部是一致的。
  2. 称为 <s 的次序与 happens-before 保持一致,即如果 a 在 b 之前发生,则 a <s b。
  3. 对于每个非原子的 load 操作 l,W(a) 是关于 l 的可见负面影响。(如果没有数据竞争,这就是与 l 相关的唯一可见负面影响。)
  4. 对于每个原子的(即同步)load 或 read-modify-write 操作 a,W(a) 是 <s 次序中,在同一位置上前面的、最后一个更新动作。
  5. 针对每个单独的锁的 lock 和 unlock 操作,是完全用 happens-before 排序的,且在每个单独的顺序中交替进行。即如果有 unlock 操作,则在整体次序中,lock 操作在顺序上先于下一个 unlock 操作,也就是说,锁仅会被获得该锁的线程释放。

如果访问相同内存位置的两个操作没有根据 happens-before 排序,那么顺序一致的执行过程会包含 2 类数据竞争(type 2 data race)。现在我们可以简单地说明 C++ 内存模型如下:

  • 如果(在给定输入上的)程序有一致的执行过程,且有 2 类数据竞争,那么它的行为是未定义的。
  • 否则,(在相同输入上的)程序就会根据一致执行过程来执行。

下面两个小节说明了这个特征等价于第 2 节中的初始模型:其一,data-race-free 程序依然有顺序一致语义;其二,数据竞争的两个概念是等价的。最后,我们用大纲的形式总结了为实际包括低层原子操作所做的额外更改。

7. Data-Race-Free 程序的顺序一致性

理论 7.1 第 6 节中定义的模型给程序提供了顺序一致性,这种程序在顺序执行过程中不包含 2 类数据竞争。

证明 考虑一个无 2 类数据竞争(type 2 data-race-free)的程序及其顺序一致的执行过程(在给定输入上)。我们需要体现的是,这个执行过程展示了顺序一致的行为。

相应的 happens-before 关系(<hb)和同步次序(<S)是不相反的、一致的,因此可以扩展为严格的整体次序 <T。

很明显,处于 sequenced-before 次序下,线程的动作显现出 <T 次序。因为 lock 操作完全是由 happends-before 排序的,因此它们必须以相同顺序出现在 <T 次序中。

我们无法知道在 <T 次序中,在锁 l 上失效的 trylock() 操作出现在什么位置。但是由于我们假设 trylock 可以假装失败,所以这也没有问题。无论在锁 l 上前面最后一个操作(<T 次序中)留给锁的状态是什么,失败的后果都是可以接受的。无论失败的 trylock() 发生在 <T 次序中的什么位置,锁 l 上的操作总会按照那个次序执行,并产生最初的结果。

这依然意味着,在 <T 次序中,每个 load 操作都会看到在相同内存位置上的、前面最后一个 store 操作存储的结果。

很明显,对于在原子对象上的操作而言这是对的。因为所有这样的操作都以相同的次序出现,如在 <S 次序中,且在 <S 次序中的每个 load 操作都能看到 <S 次序中前面的 store 操作的结果。

从现在起,我们只考虑普通的、非原子的内存操作。

考虑一个名为 Ss 的 store 操作,其结果被名为 L 的 load 操作看到。

对 L 来说,Ss 必须是一个可见负面影响,因此必须在 L 之前发生。因此,在 <T 次序中,Ss 在 L 之前。

假设在 <T 次序中另一个名为 Sb 的 store 操作位于 Ss 和 L 之间。

我们知道 <T 事实上是 <hb 的扩展。因此,我们不能得到 L <hb Sb 或 Sb <hb Ss 的结论,因为那样会与 <T 次序中反向排序不一致。

虽然三个操作全部发生了冲突,但我们没有数据竞争。因此,他们必须用 <hb 次序排序,Sb 也必须在其它两个之间且用 happens-before 次序排序。但是这与如下事实相互矛盾:对 L 来说 Ss 必须是一个可见负面影响,到此证明结束。

尽管这是一个有用的理论,但它没有确保我们提炼的内存模型等价于最初的版本。我们还必须体现,在最初的 1 类定义下的 data-race-free 程序同样也满足修订的(2 类)定义下的 data-race-free 条件。

8. 数据竞争定义的等价性

理论 8.1 如果程序允许在一致执行过程中有 2 类数据竞争,那么会存在这样的顺序一致的执行过程:有相互冲突的两个动作,无论哪一个都不会在另一个之前发生。

实际上,为了查明在一致的执行过程中是否存在数据竞争,我们只需要考虑顺序一致的执行过程。例如,诸如在图 1 中的程序不可能包含数据竞争,因为其在顺序一致的执行过程中,每个变量都仅会被一条线程访问。

证明 我们展示了在一致的执行过程中,任何 2 类数据竞争都对应于顺序一致的执行过程中的数据竞争(再次由 happens-before 关系定义,而非同步执行)。

考虑一个有数据竞争的一致的执行过程。让 <T 成为 happens-before 的全部扩展和同步次序,如上面所构造的那样。

考虑 <T 的最长前缀 P 不包含数据竞争。注意,在 P 中的每个 load 操作必须看在它的同步或 happens-before 次序之前的 store 操作的结果。因此,P 中每个 load 操作都必须能看到 P 中 store 操作的结果。与之类似,在 P 中,每个 lock 操作都必须能看到另一个 lock 操作产生的状态,或者是 trylock() 看到这样的状态,它就会产生失败的结果。

通过前面章节的讨论,仅限于 P 的最初的执行过程等价于顺序一致的执行过程的前缀。

在 <T 次序中,紧随 P 之后的下一个元素 N 必须是一个普通的、会引入竞争的内存访问操作。如果 N 是一个 store 操作,考虑最初的执行过程限于 P ∪ {N} 。否则,考虑相同的执行过程,除了 N 能看到被最后的写操作写入 P 中相同变量之外。

无论哪种情况,导致的执行过程(位于 P 中且带有引入竞争的操作)是顺序一致的执行过程的前缀;如果 N 是 write 操作,因为这些 read 操作在 <T 次序中被排在 N 的前面,所以 write 的过程不会被 P 中任何 read 操作看见;如果 N 是 read 操作,它将会被调整以确保能看见 P 中最后一个适当的 write 操作。无论是哪种情况,我们都会得到执行过程是顺序一致的程序,它也展示出了数据竞争(可视为 P ∪ {N} 的简单扩展)。

理论 8.2 当且仅当在顺序一致的执行过程中,两个未排序的且有冲突的操作在顺序交错状态下相邻,例如允许 1 类数据竞争,那么该程序在给定输入上允许 2 类数据竞争。

证明 假设我们有一个整体次序为 <T 的顺序一致的执行过程,和一个 1 类数据竞争。有一个关于该执行过程的简单明了的映射关系。到第一个这样的数据竞争,再到第 6 节中定义的一致的执行过程,其中每个 load 操作会看到 <T 次序中前面相应的 store 操作的结果,而同步次序仅仅是对 <T 的约束。这就很容易明白 1 类数据竞争映射到 2 类数据竞争。

它也展示了反向推论:如果我们有 2 类数据竞争,必然有一个顺序一致的执行过程,它带有会发生冲突的相邻操作。

和上面一样,在 P ∪ {N} 约束下,开始这样的执行过程,N 读取的任何值同样都根据需要进行调整。我们知道,在这样的部分执行过程中,没有什么会依赖 N 读取的值。把 M 定义为在数据竞争中涉及的另一个内存引用。

我们能进一步将执行过程限制在发生于 M 或 N 之前的那些操作上。该集合仍然包括由每条线程所执行的操作的顺序前缀。由于每个 load 操作看到在它之前的 store 操作的结果,所以被忽略的操作不可能影响剩余的执行过程。

在 {x | x <hb M} ∪ {x | x <hb N} ∪ {M} ∪ {N} 集合中,定义一个部分次序(竞争次序,race-order),它将前两个集合中的所有内容都排在另外两个之前,但没有将其它次序强加于其上。

竞争次序与 happens-before 及同步次序保持一致,它没有给初始子集强加额外的次序。M 和 N 都没有用同步次序排序,且两个都不是因竞争而被排序的,也不会在第一个子集的任何元素之前发生。如果我们有一个循环 A0, A1, A2, ..., An = A0,该序列中的每个元素都在下个元素之前发生,或者因竞争排在下一个元素之前,亦或因同步而排在下一个元素之前。M 或 N 都不可能出现在循环中,因为 happens-before 和同步次序都要求一致性,所以这是不可能发生的。

因此我们能将 <T 的整体次序构造成为 happens-before、同步次序和竞争次序的组合体的反向传递闭包。通过之前的讨论,这种情况是存在的。

采用与证明理论 7.1 相同的论断,每个 read 内存操作必须要能看到该序列中前面的 write 操作的结果,除了可能的 N,因为只有它可能看到竞争操作存储的值。但我们也能简单地调整 N 所能看到的值,以获得我们期望的特性,而不会影响到执行过程的其余部分。因此,尽管 <T 次序中的最后两个操作 M 和 N 之间存在冲突,但它给予了我们所期望的顺序一致的执行过程。

9. 模型对低层原子操作的调整

关于内存排序约束,C++ 工作文件提供了被明确参数化的低层原子操作。

可以通过,例如 x.load(memory_order_relaxed),获取原子变量 x 的值,允许它被其他内存操作重新排序。关于内存模型,这就说明了 load 操作永远不会是 acquire 操作,因此也不会有助于 synchronizes-with 排序。对于 read-modify-write 操作,程序员可以指明该操作是否充当 acquire 操作或 release 操作,或者两者都充当,或者都不充当。

正如在 C++ 工作文件所做的那样,为了在我们的内存模型中包含低层的原子操作,我们需要对第 6 节中的模型做一些改进:

  1. 同步操作的整体次序 S 仅包含高级(顺序一致的)原子操作。(这些可以用显式的 memory_order_seq_cst 参数来指定)
  2. 然而,我们仍然希望单个变量的更新按照整体次序进行,这在标准中被称为修改次序(modification order)。
  3. 有一种感觉,例如在 store 和 load 原子操作之间,由另一条线程执行的、弱排序的递增原子操作,不应该破坏 store 和 load 操作之间的 synchronizes-with 关系。因此,synchronizes-with 关系的定义得到强化,以覆盖这个例子(参考 C++ 工作文件中的“释放顺序(release sequence)”)。工作文件中的准确定义是,介于易用性和在常见硬件上的可实现性之间的折中。
  4. 由于 S 不再包含所有同步操作,因此被 load 原子操作看到的值不再由 S 唯一确定。对于在内存位置 l 的、名为 a 的 load 原子操作或 read-modify-write 操作,我们将“可见顺序(visible sequence)”定义为 l 的修改顺序的最大子序列 V,且 V 中的每个元素要么是或在 a 的可见副作用之后发生,但 V 中没有元素在 a 之后发生。那么 V 就代表了所有可被 a 看见的潜在更新。

尽管我们现在可以在内存模型中吸纳低层原子操作,但重要的是要记住,这些功能仍然难以被正确使用,是“仅限于专家”使用的特性。大多数用户仅会间接地受益于用这些功能编写的程序库。

10. 结论

我们已经概述了 C++ 线程内存模型的基础。

从用户的角度来看,我们提供了一个简单的编程模型。作为避免数据竞争的结果,或者同样地,将数据竞争中涉及的变量和其他对象识别为原子类型,大多数用户可以忽略硬件内存模型和编译器优化带来的复杂性;它们被保证是顺序一致的执行过程。

所有这一切都能基于数据竞争最直观的定义:同时执行有冲突的操作。现代计算机体系结构不可避免地暴露出的一个问题是:更新相邻位字段所产生的冲突;在其它方面,内存操作间的冲突仅发生在它们触及同一对象时。

少数用户的性能需求不满足于锁操作或顺序一致性的原子操作。我们为他们提供了低层的、显式排序的原子操作。它以简单性换取跨平台的性能。

从编译器实现者的角度来看,我们保证普通变量似乎不会异步变化。因此即使在多线程的场合,除了原子类型的对象外,标准程序分析仍然有效。其结果是,编译器的实现要避免引入用户可见的数据竞争,例如,重写结构体的相邻字段的结果或寄存器提升。

从硬件实现者的角度来看,除了现在的标准功能,例如 compare-and-swap 及类似的操作,我们需要一个低成本的技术,允许我们实现顺序一致的原子操作,特别是写操作的原子性。我们不需要为所有 store 操作提供写操作原子性;但我们需要为原子操作提供写操作原子性。理想情况
下,应该可以用非常小的开销为 load 操作实现顺序一致的原子操作。

(译文完)

致原文作者 Hans-J. Boehm 及 Sarita V. Adve。

posted @ 2025-07-04 19:01  green-cnblogs  阅读(26)  评论(0)    收藏  举报