C++11 引入了标准化的内存模型,但这究竟意味着什么?它将如何影响 C++ 编程?
内存模型意味着 C++ 代码现在有一个标准化的库可以调用,而不管编译器是谁制作的,也不管它在什么平台上运行。有一种标准方法可以控制不同线程如何与处理器的内存通信。
“当您谈论在标准中的不同内核之间拆分 [代码] 时,我们正在谈论内存模型。我们将对其进行优化,而不会破坏人们将在代码中做出的以下假设,” 萨特 说。
好吧,我可以 记住 这个和网上可用的类似段落(因为我从出生就拥有自己的记忆模型:P),甚至可以发布作为其他人提出的问题的答案,但老实说,我不完全理解这个。
C++ 程序员甚至以前也用于开发多线程应用程序,那么它是 POSIX 线程、Windows 线程还是 C++11 线程又有什么关系呢?有什么好处?我想了解底层细节。
我也觉得 C++11 内存模型在某种程度上与 C++11 多线程支持有关,因为我经常看到这两者在一起。如果是,具体是怎样的?为什么它们应该相关?
我不知道多线程的内部是如何工作的,以及内存模型的一般含义。
原文由 Nawaz 发布,翻译遵循 CC BY-SA 4.0 许可协议
首先,你必须学会像语言律师一样思考。
C++ 规范没有提及任何特定的编译器、操作系统或 CPU。它引用了一个 _抽象机器_,它是实际系统的概括。在语言律师的世界里,程序员的工作是为抽象机器编写代码;编译器的工作是在具体机器上实现该代码。通过严格按照规范进行编码,您可以确定您的代码无需修改即可在任何具有兼容 C++ 编译器的系统上编译和运行,无论是现在还是 50 年后。
C++98/C++03规范中的抽象机基本上是单线程的。因此,就规范而言,不可能编写“完全可移植”的多线程 C++ 代码。该规范甚至没有说明内存加载和存储的 原子性 或加载和存储可能发生的 _顺序_,更不用说诸如互斥锁之类的事情了。
当然,您可以在实践中为特定的具体系统(如 pthread 或 Windows)编写多线程代码。但是没有为 C++98/C++03 编写多线程代码的 标准 方法。
C++11 中的抽象机在设计上是多线程的。它还有一个定义明确的 _内存模型_;也就是说,它说明了编译器在访问内存时可以做什么和不可以做什么。
考虑以下示例,其中两个线程同时访问一对全局变量:
线程 2 可能输出什么?
在 C++98/C++03 下,这甚至不是 Undefined Behavior;这个问题本身是 没有意义 的,因为该标准没有考虑任何称为“线程”的东西。
在 C++11 下,结果是未定义行为,因为加载和存储通常不需要是原子的。这可能看起来并没有太大的改进……而且就其本身而言,它不是。
但是使用 C++11,你可以这样写:
现在事情变得更有趣了。首先, 定义 了这里的行为。线程 2 现在可以打印
0 0
(如果它在线程 1 之前运行)、37 17
(如果它在线程 1 之后运行)或0 17
(它在之后运行)线程 1 分配给 x 但在分配给 y 之前)。它不能打印的是
37 0
,因为 C++11 中原子加载/存储的默认模式是强制执行 _顺序一致性_。这只是意味着所有加载和存储必须“好像”它们按照您在每个线程中编写它们的顺序发生,而线程之间的操作可以交错,但系统喜欢。因此,原子的默认行为为加载和存储提供了 原子性 和 _排序_。现在,在现代 CPU 上,确保顺序一致性可能代价高昂。特别是,编译器可能会在此处的每次访问之间发出完整的内存屏障。但是如果你的算法可以容忍乱序的加载和存储;即,如果它需要原子性但不需要排序;即,如果它可以容忍
37 0
作为该程序的输出,那么您可以这样写:CPU 越现代,它就越有可能比前面的示例更快。
最后,如果您只需要按顺序保持特定的加载和存储,您可以编写:
这将我们带回到有序的加载和存储——因此
37 0
不再是可能的输出——但它以最小的开销做到这一点。 (在这个简单的例子中,结果与完整的顺序一致性相同;在更大的程序中,它不会。)当然,如果您想看到的唯一输出是
0 0
或37 17
,您可以在原始代码周围包装一个互斥锁。但是如果你已经读到这里,我敢打赌你已经知道它是如何工作的,而且这个答案已经比我预期的要长:-)。所以,底线。互斥体很棒,C++11 将它们标准化。但有时出于性能原因,您需要较低级别的原语(例如,经典的 双重检查锁定模式)。新标准提供了诸如互斥锁和条件变量之类的高级工具,它还提供了诸如原子类型和各种形式的内存屏障之类的低级工具。因此,现在您可以完全使用标准指定的语言编写复杂、高性能的并发例程,并且您可以确定您的代码将在今天和明天的系统上编译和运行不变。
虽然坦率地说,除非您是专家并且正在处理一些严肃的低级代码,否则您可能应该坚持使用互斥锁和条件变量。这就是我打算做的。
有关这些内容的更多信息,请参阅 此博客文章。