2093

C++11 引入了标准化的内存模型,但这究竟意味着什么?它将如何影响 C++ 编程?

这篇文章(由加文克拉克引用赫伯萨特的话)说,

内存模型意味着 C++ 代码现在有一个标准化的库可以调用,而不管编译器是谁制作的,也不管它在什么平台上运行。有一种标准方法可以控制不同线程如何与处理器的内存通信。

“当您谈论在标准中的不同内核之间拆分 [代码] 时,我们正在谈论内存模型。我们将在不破坏人们将在代码中做出的以下假设的情况下对其进行优化,”萨特说。

好吧,我可以记住这个和网上可用的类似段落(因为我从出生就拥有自己的记忆模型:P),甚至可以发布作为其他人提出的问题的答案,但老实说,我不完全理解这个。

C++ 程序员甚至以前也用于开发多线程应用程序,那么它是 POSIX 线程、Windows 线程还是 C++11 线程又有什么关系呢?有什么好处?我想了解底层细节。

我也觉得 C++11 内存模型在某种程度上与 C++11 多线程支持有关,因为我经常看到这两者在一起。如果是,具体是怎样的?为什么它们应该相关?

由于我不知道多线程的内部如何工作,以及内存模型的一般含义,请帮助我理解这些概念。:-)

4

8 回答 8

2442

首先,你必须学会​​像语言律师一样思考。

C++ 规范没有提及任何特定的编译器、操作系统或 CPU。它引用了一个抽象机器,它是实际系统的概括。在语言律师的世界里,程序员的工作是为抽象机器编写代码;编译器的工作是在具体机器上实现该代码。通过严格按照规范进行编码,您可以确定您的代码无需修改即可在任何具有兼容 C++ 编译器的系统上编译和运行,无论是现在还是 50 年后。

C++98/C++03规范中的抽​​象机基本上是单线程的。因此,就规范而言,不可能编写“完全可移植”的多线程 C++ 代码。该规范甚至没有说明内存加载和存储的原子性或加载和存储可能发生的顺序,更不用说诸如互斥锁之类的事情了。

当然,您可以在实践中为特定的具体系统(如 pthread 或 Windows)编写多线程代码。但是没有为 C++98/C++03 编写多线程代码的标准方法。

C++11 中的抽象机在设计上是多线程的。它还有一个定义明确的内存模型;也就是说,它说明了编译器在访问内存时可以做什么和不可以做什么。

考虑以下示例,其中两个线程同时访问一对全局变量:

           Global
           int x, y;

Thread 1            Thread 2
x = 17;             cout << y << " ";
y = 37;             cout << x << endl;

线程 2 可能输出什么?

在 C++98/C++03 下,这甚至不是 Undefined Behavior;这个问题本身是没有意义的,因为该标准没有考虑任何称为“线程”的东西。

在 C++11 下,结果是未定义行为,因为加载和存储通常不需要是原子的。这可能看起来并没有太大的改进......而且就其本身而言,它不是。

但是使用 C++11,你可以这样写:

           Global
           atomic<int> x, y;

Thread 1                 Thread 2
x.store(17);             cout << y.load() << " ";
y.store(37);             cout << x.load() << endl;

现在事情变得更有趣了。首先,定义了这里的行为。线程 2 现在可以打印0 0(如果它在线程 1 之前运行),37 17(如果它在线程 1 之后运行),或者0 17(如果它在线程 1 分配给 x 之后但在它分配给 y 之前运行)。

它不能打印的是37 0,因为 C++11 中原子加载/存储的默认模式是强制执行顺序一致性。这只是意味着所有加载和存储必须“好像”它们按照您在每个线程中编写它们的顺序发生,而线程之间的操作可以交错,但系统喜欢。因此,原子的默认行为为加载和存储提供了原子性排序

现在,在现代 CPU 上,确保顺序一致性可能代价高昂。特别是,编译器可能会在此处的每次访问之间发出完整的内存屏障。但是如果你的算法可以容忍乱序的加载和存储;即,如果它需要原子性但不需要排序;即,如果它可以容忍37 0这个程序的输出,那么你可以这样写:

           Global
           atomic<int> x, y;

Thread 1                            Thread 2
x.store(17,memory_order_relaxed);   cout << y.load(memory_order_relaxed) << " ";
y.store(37,memory_order_relaxed);   cout << x.load(memory_order_relaxed) << endl;

CPU 越现代,它就越有可能比前面的示例更快。

最后,如果您只需要按顺序保持特定的加载和存储,您可以编写:

           Global
           atomic<int> x, y;

Thread 1                            Thread 2
x.store(17,memory_order_release);   cout << y.load(memory_order_acquire) << " ";
y.store(37,memory_order_release);   cout << x.load(memory_order_acquire) << endl;

这将我们带回到有序的加载和存储——因此37 0不再是可能的输出——但它以最小的开销这样做。(在这个简单的例子中,结果与完整的顺序一致性相同;在更大的程序中,它不会。)

当然,如果您想看到的唯一输出是0 0or 37 17,您可以在原始代码周围包裹一个互斥锁。但是如果你已经读到这里,我敢打赌你已经知道它是如何工作的,而且这个答案已经比我预期的要长:-)。

所以,底线。互斥体很棒,C++11 将它们标准化。但有时出于性能原因,您需要较低级别的原语(例如,经典的双重检查锁定模式)。新标准提供了诸如互斥锁和条件变量之类的高级工具,它还提供了诸如原子类型和各种形式的内存屏障之类的低级工具。因此,现在您可以完全使用标准指定的语言编写复杂、高性能的并发例程,并且您可以确定您的代码将在今天和明天的系统上编译和运行不变。

虽然坦率地说,除非您是专家并且正在处理一些严肃的低级代码,否则您可能应该坚持使用互斥锁和条件变量。这就是我打算做的。

有关这些内容的更多信息,请参阅此博客文章

于 2011-06-12T00:23:46.617 回答
375

我将给出我理解内存一致性模型(或简称内存模型)的类比。它的灵感来自 Leslie Lamport 的开创性论文“分布式系统中的时间、时钟和事件排序”。这个类比很贴切,具有根本意义,但对许多人来说可能有点矫枉过正。但是,我希望它提供一个心理图像(图形表示),有助于推理内存一致性模型。

让我们在时空图中查看所有内存位置的历史,其中横轴表示地址空间(即每个内存位置由该轴上的一个点表示),纵轴表示时间(我们将看到,一般来说,没有一个普遍的时间概念)。因此,每个内存位置保存的值的历史由该内存地址处的垂直列表示。每个值更改都是由于其中一个线程将新值写入该位置。内存映像是指特定线程在特定时间可观察到的所有内存位置的值的聚合/组合。

引自“A Primer on Memory Consistency and Cache Coherence”

直观的(也是最严格的)内存模型是顺序一致性 (SC),其中多线程执行应该看起来像每个组成线程的顺序执行的交错,就好像线程在单核处理器上进行时间复用一样。

该全局内存顺序可能因程序的一次运行而异,并且可能事先不知道。SC 的特征是地址-空间-时间图中的一组水平切片表示同时性平面(即内存图像)。在给定的平面上,它的所有事件(或内存值)都是同时发生的。有一个Absolute Time的概念,其中所有线程都同意哪些内存值是同时的。在 SC 中,在每一个瞬间,所有线程只共享一个内存映像。也就是说,在每个时刻,所有处理器都同意内存映像(即内存的聚合内容)。这不仅意味着所有线程查看所有内存位置的相同值序列,而且所有处理器都观察相同所有变量的值的组合。这与所有线程以相同的总顺序观察所有内存操作(在所有内存位置上)相同。

在宽松的内存模型中,每个线程将以自己的方式分割地址空间时间,唯一的限制是每个线程的切片不能相互交叉,因为所有线程必须就每个单独的内存位置的历史达成一致(当然, 不同线程的切片可能并且将会相互交叉)。没有通用的方法来分割它(没有地址空间时间的特权叶子)。切片不必是平面的(或线性的)。它们可以是弯曲的,这可以使线程读取由另一个线程写入的值与它们写入的顺序不同。当任何特定线程查看时,不同内存位置的历史可能相对于彼此任意滑动(或拉伸). 每个线程对哪些事件(或等效地,内存值)是同时发生的有不同的感觉。与一个线程同时发生的一组事件(或内存值)与另一个线程不同。因此,在宽松的内存模型中,所有线程仍然为每个内存位置观察相同的历史记录(即值序列)。但是他们可能会观察到不同的内存图像(即所有内存位置的值的组合)。即使两个不同的内存位置被同一个线程按顺序写入,这两个新写入的值也可能被其他线程以不同的顺序观察到。

[图片来自维基百科] 图片来自维基百科

熟悉爱因斯坦狭义相对论的读者会注意到我在暗示什么。将 Minkowski 的话翻译成内存模型领域:地址空间和时间是地址空间时间的影子。在这种情况下,每个观察者(即线程)会将事件的影子(即内存存储/加载)投射到他自己的世界线(即他的时间轴)和他自己的同时性平面(他的地址空间轴)上. C++11 内存模型中的线程对应于狭义相对论中相对于彼此移动的观察者。顺序一致性对应于伽利略时空(即,所有观察者都同意事件的一个绝对顺序和全局同时性)。

记忆模型和狭义相对论之间的相似之处源于两者都定义了一组部分有序的事件,通常称为因果集。一些事件(即内存存储)可以影响(但不受其影响)其他事件。C++11 线程(或物理学中的观察者)只不过是一个事件链(即完全有序的集合)(例如,内存加载和存储到可能不同的地址)。

在相对论中,部分有序事件的看似混乱的画面恢复了某种秩序,因为所有观察者都同意的唯一时间顺序是“类时间”事件之间的排序(即原则上可以通过任何速度变慢的粒子连接的那些事件)比真空中的光速还要快)。只有类时相关的事件是不变排序的。 物理学时间,克雷格·卡伦德

在 C++11 内存模型中,使用了类似的机制(获取-释放一致性模型)来建立这些局部因果关系

为了提供内存一致性的定义和放弃 SC 的动机,我将引用“A Primer on Memory Consistency and Cache Coherence”

对于共享内存机器,内存一致性模型定义了其内存系统的架构可见行为。单个处理器核心的正确性标准将行为划分为“<em>一个正确的结果”和“<em>许多不正确的选择”。这是因为处理器的架构要求线程的执行将给定的输入状态转换为单个明确定义的输出状态,即使在无序内核上也是如此。然而,共享内存一致性模型涉及多个线程的加载和存储,通常允许许多正确的执行同时禁止许多(更多)不正确的。多次正确执行的可能性是由于 ISA 允许多个线程同时执行,通常具有来自不同线程的许多可能的合法指令交错。

放松虚弱内存一致性模型的动机是强模型中的大多数内存排序都是不必要的。如果一个线程更新了十个数据项,然后更新了一个同步标志,程序员通常不关心数据项是否按顺序更新,而只关心在更新标志之前更新所有数据项(通常使用 FENCE 指令实现)。宽松模型试图捕捉这种增加的排序灵活性,并仅保留程序员“<em>要求”以获得更高性能和 SC 正确性的命令。例如,在某些架构中,每个内核使用 FIFO 写入缓冲区来保存提交(退休)存储的结果,然后再将结果写入缓存。这种优化提高了性能,但违反了 SC。写缓冲区隐藏了服务存储未命中的延迟。因为商店很常见,所以能够避免在大多数商店中停滞不前是一个重要的好处。对于单核处理器,通过确保对地址 A 的加载将最近存储的值返回给 A,即使对 A 的一个或多个存储在写缓冲区中,也可以使写缓冲区在架构上不可见。这通常通过将最近存储到 A 的值绕过到从 A 加载来完成,其中“最近”由程序顺序确定,或者如果到 A 的存储在写缓冲区中,则停止加载 A . 当使用多个内核时,每个内核都有自己的旁路写入缓冲区。没有写缓冲区,硬件就是 SC,但有了写缓冲区,它就不是了,这使得写缓冲区在多核处理器中在架构上是可见的。因为商店很常见,所以能够避免在大多数商店中停滞不前是一个重要的好处。对于单核处理器,通过确保对地址 A 的加载将最近存储的值返回给 A,即使对 A 的一个或多个存储在写缓冲区中,也可以使写缓冲区在架构上不可见。这通常通过将最近存储到 A 的值绕过到从 A 加载来完成,其中“最近”由程序顺序确定,或者如果到 A 的存储在写缓冲区中,则停止加载 A . 当使用多个内核时,每个内核都有自己的旁路写入缓冲区。没有写缓冲区,硬件就是 SC,但有了写缓冲区,它就不是了,这使得写缓冲区在多核处理器中在架构上是可见的。因为商店很常见,所以能够避免在大多数商店中停滞不前是一个重要的好处。对于单核处理器,通过确保对地址 A 的加载将最近存储的值返回给 A,即使对 A 的一个或多个存储在写缓冲区中,也可以使写缓冲区在架构上不可见。这通常通过将最近存储到 A 的值绕过到从 A 加载来完成,其中“最近”由程序顺序确定,或者如果到 A 的存储在写缓冲区中,则停止加载 A . 当使用多个内核时,每个内核都有自己的旁路写入缓冲区。没有写缓冲区,硬件就是 SC,但有了写缓冲区,它就不是了,这使得写缓冲区在多核处理器中在架构上是可见的。对于单核处理器,通过确保对地址 A 的加载将最近存储的值返回给 A,即使对 A 的一个或多个存储在写缓冲区中,也可以使写缓冲区在架构上不可见。这通常通过将最近存储到 A 的值绕过到从 A 加载来完成,其中“最近”由程序顺序确定,或者如果到 A 的存储在写缓冲区中,则停止加载 A . 当使用多个内核时,每个内核都有自己的旁路写入缓冲区。没有写缓冲区,硬件就是 SC,但有了写缓冲区,它就不是了,这使得写缓冲区在多核处理器中在架构上是可见的。对于单核处理器,通过确保对地址 A 的加载将最近存储的值返回给 A,即使对 A 的一个或多个存储在写缓冲区中,也可以使写缓冲区在架构上不可见。这通常通过将最近存储到 A 的值绕过到从 A 加载来完成,其中“最近”由程序顺序确定,或者如果到 A 的存储在写缓冲区中,则停止加载 A . 当使用多个内核时,每个内核都有自己的旁路写入缓冲区。没有写缓冲区,硬件就是 SC,但有了写缓冲区,它就不是了,这使得写缓冲区在多核处理器中在架构上是可见的。通过确保对地址 A 的加载将最近存储的值返回给 A,即使对 A 的一个或多个存储在写缓冲区中,也可以使写缓冲区在架构上不可见。这通常通过将最近存储到 A 的值绕过到从 A 加载来完成,其中“最近”由程序顺序确定,或者如果到 A 的存储在写缓冲区中,则停止加载 A . 当使用多个内核时,每个内核都有自己的旁路写入缓冲区。没有写缓冲区,硬件就是 SC,但有了写缓冲区,它就不是了,这使得写缓冲区在多核处理器中在架构上是可见的。通过确保对地址 A 的加载将最近存储的值返回给 A,即使对 A 的一个或多个存储在写缓冲区中,也可以使写缓冲区在架构上不可见。这通常通过将最近存储到 A 的值绕过到从 A 加载来完成,其中“最近”由程序顺序确定,或者如果到 A 的存储在写缓冲区中,则停止加载 A . 当使用多个内核时,每个内核都有自己的旁路写入缓冲区。没有写缓冲区,硬件就是 SC,但有了写缓冲区,它就不是了,这使得写缓冲区在多核处理器中在架构上是可见的。或者,如果 A 的存储在写入缓冲区中,则暂停 A 的加载。当使用多个内核时,每个内核都有自己的旁路写入缓冲区。没有写缓冲区,硬件就是 SC,但有了写缓冲区,它就不是了,这使得写缓冲区在多核处理器中在架构上是可见的。或者,如果 A 的存储在写入缓冲区中,则暂停 A 的加载。当使用多个内核时,每个内核都有自己的旁路写入缓冲区。没有写缓冲区,硬件就是 SC,但有了写缓冲区,它就不是了,这使得写缓冲区在多核处理器中在架构上是可见的。

如果一个内核有一个非 FIFO 写缓冲区,它允许存储以不同于它们进入的顺序离开的顺序,则存储-存储重新排序可能会发生。如果第一个存储在缓存中未命中而第二个命中,或者如果第二个存储可以与较早的存储合并(即,在第一个存储之前),则可能会发生这种情况。加载-加载重新排序也可能发生在动态调度的内核上,这些内核以程序顺序执行指令。这与在另一个核心上重新排序存储的行为相同(你能想出一个在两个线程之间交错的示例吗?)。将较早的加载与稍后的存储重新排序(加载-存储重新排序)可能会导致许多不正确的行为,例如在释放保护它的锁之后加载一个值(如果存储是解锁操作)。

因为缓存一致性和内存一致性有时会被混淆,所以也有这样的引用是有启发性的:

与一致性不同,缓存一致性对软件既不可见也不要求。Coherence 试图使共享内存系统的缓存在功能上与单核系统中的缓存一样不可见。正确的连贯性可确保程序员无法通过分析加载和存储的结果来确定系统是否以及在何处具有缓存。这是因为正确的一致性确保缓存永远不会启用新的或不同的功能行为(程序员可能仍然能够使用时序推断可能的缓存结构信息)。缓存一致性协议的主要目的是维护每个内存位置的单写多读 (SWMR) 不变性。一致性和一致性之间的一个重要区别是一致性是基于每个内存位置指定的,而一致性是针对所有内存位置指定的。

继续我们的心理图景,SWMR 不变量对应于物理要求,即在任何一个位置最多有一个粒子,但在任何位置都可以有无限数量的观察者。

于 2013-08-29T20:42:27.170 回答
135

这是一个多年前的问题,但非常受欢迎,值得一提的是学习 C++11 内存模型的绝佳资源。我认为总结他的演讲以使这成为另一个完整的答案没有意义,但鉴于这是实际编写标准的人,我认为值得观看演讲。

Herb Sutter 就 C++11 内存模型进行了长达三个小时的演讲,题为“atomic<> Weapons”,可在 Channel9 站点 -第 1部分和第 2 部分上找到。演讲非常技术性,涵盖以下主题:

  1. 优化、竞争和记忆模型
  2. 订购 - 内容:获取和发布
  3. 排序 - 如何:互斥体、原子和/或栅栏
  4. 编译器和硬件的其他限制
  5. 代码生成和性能:x86/x64、IA64、POWER、ARM
  6. 宽松原子

该演讲没有详细说明 API,而是详细说明了推理、背景、幕后和幕后(您是否知道将宽松语义添加到标准中只是因为 POWER 和 ARM 不能有效地支持同步加载?)。

于 2013-12-20T13:22:43.583 回答
80

这意味着该标准现在定义了多线程,它定义了在多线程的上下文中发生的事情。当然,人们使用了不同的实现,但这就像在问为什么我们应该有一个std::string当我们都可以使用家庭滚动string类的时候。

当您谈论 POSIX 线程或 Windows 线程时,这有点像您在谈论 x86 线程时的错觉,因为它是并发运行的硬件功能。C++0x 内存模型可以保证,无论您使用的是 x86、ARM、MIPS还是您能想到的任何其他东西。

于 2011-06-11T23:42:51.893 回答
60

对于未指定内存模型的语言,您正在为处理器架构指定的语言和内存模型编写代码。处理器可以选择重新排序内存访问以提高性能。因此,如果您的程序存在数据竞争(数据竞争是指多个内核/超线程可以同时访问同一内存),那么您的程序不是跨平台的,因为它依赖于处理器内存模型。您可以参考 Intel 或 AMD 软件手册来了解处理器如何重新排序内存访问。

非常重要的是,锁(以及带有锁的并发语义)通常以跨平台的方式实现......因此,如果您在没有数据竞争的多线程程序中使用标准锁,那么您不必担心跨平台内存模型.

有趣的是,微软的 C++ 编译器已经为 volatile 获取/释放语义,这是一个 C++ 扩展,用于处理 C++ 中缺少内存模型http://msdn.microsoft.com/en-us/library/12a04hfd(v=vs .80).aspx。但是,鉴于 Windows 仅在 x86 / x64 上运行,这并不能说明什么(英特尔和 AMD 内存模型可以轻松高效地在语言中实现获取/释放语义)。

于 2011-07-26T04:27:04.017 回答
31

如果您使用互斥锁来保护您的所有数据,您真的不必担心。互斥锁总是提供足够的顺序和可见性保证。

现在,如果您使用原子或无锁算法,您需要考虑内存模型。内存模型精确地描述了原子何时提供排序和可见性保证,并为手动编码保证提供可移植的栅栏。

以前,原子操作将使用编译器内在函数或一些更高级别的库来完成。栅栏将使用特定于 CPU 的指令(内存屏障)来完成。

于 2011-06-11T23:49:53.727 回答
7

上述答案涉及 C++ 内存模型的最基本方面。在实践中,“just work”的大多数用法std::atomic<>,至少在程序员过度优化之前(例如,通过尝试放松太多事情)。

有一个地方错误仍然很常见:序列锁在https://www.hpl.hp.com/techreports/2012/HPL-2012-68.pdf上对挑战进行了精彩且易于阅读的讨论。序列锁很有吸引力,因为读者避免写入锁字。以下代码基于上述技术报告的图 1,突出了在 C++ 中实现序列锁时的挑战:

atomic<uint64_t> seq; // seqlock representation
int data1, data2;     // this data will be protected by seq

T reader() {
    int r1, r2;
    unsigned seq0, seq1;
    while (true) {
        seq0 = seq;
        r1 = data1; // INCORRECT! Data Race!
        r2 = data2; // INCORRECT!
        seq1 = seq;

        // if the lock didn't change while I was reading, and
        // the lock wasn't held while I was reading, then my
        // reads should be valid
        if (seq0 == seq1 && !(seq0 & 1))
            break;
    }
    use(r1, r2);
}

void writer(int new_data1, int new_data2) {
    unsigned seq0 = seq;
    while (true) {
        if ((!(seq0 & 1)) && seq.compare_exchange_weak(seq0, seq0 + 1))
            break; // atomically moving the lock from even to odd is an acquire
    }
    data1 = new_data1;
    data2 = new_data2;
    seq = seq0 + 2; // release the lock by increasing its value to even
}

就像一开始的接缝一样不直观,data1并且data2需要atomic<>。如果它们不是原子的,那么它们可以在reader()写入(in )的同时被读取(in writer())。根据 C++ 内存模型,即使reader()从未实际使用过数据,这也是一场竞赛。此外,如果它们不是原子的,那么编译器可以将每个值的第一次读取缓存在寄存器中。while显然你不希望这样......你想在循环的每次迭代中重新读取reader().

atomic<>制作它们并使用它们访问它们也是不够的memory_order_relaxed。原因是 seq (in reader()) 的读取只有获取语义。简单来说,如果 X 和 Y 是内存访问,X 在 Y 之前,X 不是获取或释放,并且 Y 是获取,那么编译器可以在 X 之前重新排序 Y。如果 Y 是 seq 的第二次读取,并且 X是读取数据,这样的重新排序会破坏锁的实现。

论文给出了一些解决方案。今天性能最好的可能是在第二次读取 seqlock之前atomic_thread_fence使用with的那个。在论文中,它是图 6。我不是在这里复制代码,因为读过这里的任何人都应该阅读这篇论文。它比这篇文章更精确和完整。memory_order_relaxed

最后一个问题是使data变量原子化可能是不自然的。如果你不能在你的代码中,那么你需要非常小心,因为从非原子到原子的转换只对原始类型是合法的。C++20 应该添加atomic_ref<>,这将使这个问题更容易解决。

总结一下:即使您认为自己了解 C++ 内存模型,在滚动自己的序列锁之前也应该非常小心。

于 2019-12-20T03:56:25.297 回答
-6

C 和 C++ 曾经由格式良好的程序的执行跟踪定义。

现在它们一半是由程序的执行轨迹定义的,一半是由同步对象上的许多排序后验定义的。

这意味着这些语言定义根本没有任何意义,因为没有逻辑方法可以混合这两种方法。特别是,对互斥体或原子变量的破坏没有很好的定义。

于 2019-07-28T20:09:02.437 回答