C ++ 11 引入了标准化的内存模型,但究竟是什么意思呢?它将如何影响 C ++ 编程?
这篇文章 (引用Herb Sutter的Gavin Clarke )说,
内存模型意味着 C ++ 代码现在有一个标准化的库可以调用,无论是谁编译器以及它运行的是什么平台。有一种标准方法可以控制不同线程与处理器内存的对话方式。
“当你谈论在标准中的不同内核之间分割 [代码] 时,我们讨论的是内存模型。我们将优化它,而不会破坏人们将在代码中做出的以下假设,” Sutter说。
好吧,我可以在网上记住这个和类似的段落(因为我从出生以来就拥有自己的记忆模型:P),甚至可以发布作为其他人提出的问题的答案,但说实话,我并不完全明白这个。
所以,我基本上想知道的是,C ++ 程序员以前用于开发多线程应用程序,那么如果它是 POSIX 线程,Windows 线程或 C ++ 11 线程,它又如何重要呢?有什么好处?我想了解低级细节。
我也觉得 C ++ 11 内存模型在某种程度上与 C ++ 11 多线程支持有关,因为我经常将这两者结合在一起。如果是,究竟是怎么回事?他们为什么要相关?
由于我不知道多线程的内部如何工作,以及一般的内存模型是什么意思,请帮助我理解这些概念。 :-)
首先,你必须学会像语言律师那样思考。
C ++ 规范不引用任何特定的编译器,操作系统或 CPU。它引用了一个抽象机器 ,它是实际系统的概括。在语言律师的世界里,程序员的工作就是为抽象机器编写代码; 编译器的工作是在具体机器上实现该代码。通过严格按照规范进行编码,无论是今天还是 50 年后,您都可以确定您的代码无需在任何具有兼容 C ++ 编译器的系统上进行编译和运行。
C ++ 98 / C ++ 03 规范中的抽象机器基本上是单线程的。因此,不可能编写相对于规范 “完全可移植” 的多线程 C ++ 代码。该规范甚至没有说明内存加载和存储的原子性或加载和存储可能发生的顺序 ,更不用说像互斥体这样的东西了。
当然,您可以在实践中为特定的具体系统编写多线程代码 - 例如 pthreads 或 Windows。但是没有标准的方法来编写 C ++ 98 / C ++ 03 的多线程代码。
C ++ 11 中的抽象机器在设计上是多线程的。它还有一个明确定义的内存模型 ; 也就是说,它说明了在访问内存时编译器可能会做什么,也可能不会做什么。
请考虑以下示例,其中两个线程同时访问一对全局变量:
Global
int x, y;
Thread 1 Thread 2
x = 17; cout << y << " ";
y = 37; cout << x << endl;
Thread 2 的输出可能是什么?
在 C ++ 98 / C ++ 03 下,这甚至不是 Undefined Behavior; 问题本身毫无意义,因为标准没有考虑任何称为 “线程” 的东西。
在 C ++ 11 下,结果是 Undefined Behavior,因为加载和存储通常不需要是原子的。这可能看起来不是很大的改善...... 而且它本身并不是。
但是使用 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 中原子加载 / 存储的默认模式是强制执行顺序一致性 。这只是意味着所有加载和存储必须 “好像” 它们按照您在每个线程中编写它们的顺序发生,而线程之间的操作可以交错但系统喜欢。因此,atomics 的默认行为为加载和存储提供了原子性和排序 。
现在,在现代 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 0
或37 17
,则可以在原始代码周围包装互斥锁。但是如果你已经阅读过这篇文章,我打赌你已经知道它是如何工作的,这个答案已经比我预想的要长:-)。
所以,底线。互斥体很棒,C ++ 11 将它们标准化。但有时出于性能原因,您需要较低级别的基元(例如,经典的双重检查锁定模式 )。新标准提供了高级小工具,如互斥锁和条件变量,还提供了原子类型和各种内存屏障等低级小工具。因此,现在您可以完全使用标准指定的语言编写复杂的高性能并发例程,并且可以确定您的代码将在今天的系统和未来的系统上编译和运行。
虽然坦率地说,除非您是专家并且正在处理一些严重的低级代码,否则您应该坚持使用互斥锁和条件变量。这就是我打算做的事情。
有关这些内容的更多信息,请参阅此博客文章 。
我将简单地给出我理解的内存一致性模型(或简称内存模型)的类比。它的灵感来自 Leslie Lamport 的开创性论文“时间,时钟和分布式系统中的事件排序” 。这个比喻很贴切,具有根本意义,但对许多人来说可能有些过分。但是,我希望它提供一个心理图像(图形表示),便于推理内存一致性模型。
让我们在时空图中查看所有存储器位置的历史,其中水平轴表示地址空间(即,每个存储器位置由该轴上的点表示),垂直轴表示时间(我们将看到,一般来说,没有一个普遍的时间概念)。因此,每个存储器位置所保持的值的历史由该存储器地址处的垂直列表示。每个值更改都是由于其中一个线程将新值写入该位置。通过存储器映像 ,我们将表示特定线程 在特定时间可观察到的所有存储器位置的值的集合 / 组合。
直观(且限制性最强)的内存模型是顺序一致性(SC),其中多线程执行应该看起来像是每个组成线程的顺序执行的交错,就像线程在单核处理器上进行时间复用一样。
该全局存储器顺序可以从程序的一次运行到另一次运行而变化,并且可能事先不知道。 SC 的特征是地址空间 - 时间图中的水平切片集合表示同时性平面 (即,存储器图像)。在给定的平面上,其所有事件(或内存值)都是同时的。有一个绝对时间的概念,其中所有线程都同意哪些内存值是同时的。在 SC 中,在每个时刻,所有线程只共享一个内存映像。也就是说,在每个时刻,所有处理器都同意存储器映像(即存储器的聚合内容)。这不仅意味着所有线程都查看所有内存位置的相同值序列,而且所有处理器都观察到所有变量的值的相同组合 。这与所有线程以相同的总顺序观察所有内存操作(在所有内存位置上)相同。
在宽松的内存模型中,每个线程都会以自己的方式切换地址空间时间,唯一的限制是每个线程的切片不会相互交叉,因为所有线程必须就每个内存位置的历史达成一致(当然,不同线程的切片可以并且将会彼此交叉)。没有通用的方法来对其进行分割(没有特权的地址空间时间)。切片不必是平面的(或线性的)。它们可以是弯曲的,这可以使线程读取由另一个线程写入的值而不是它们被写入的顺序。 当被任何特定线程查看时 ,不同存储器位置的历史可以相对于彼此任意地滑动(或被拉伸) 。每个线程将具有不同的感知,即哪些事件(或等效地,存储器值)是同时的。与一个线程同时发生的事件(或内存值)集与另一个线程不同时发生。因此,在宽松的存储器模型中,所有线程仍然观察每个存储器位置的相同历史(即,值序列)。但是他们可能会观察到不同的记忆图像(即所有记忆位置的值的组合)。即使两个不同的存储器位置按顺序由相同的线程写入,也可以由其他线程以不同的顺序观察到两个新写入的值。
[来自维基百科的图片]
熟悉爱因斯坦狭义相对论的读者会注意到我所指的是什么。将 Minkowski 的单词翻译成内存模型领域:地址空间和时间是地址空间时间的阴影。在这种情况下,每个观察者(即线程)会将事件的阴影(即内存存储 / 加载)投影到他自己的世界线(即他的时间轴)和他自己的同时平面(他的地址空间轴)上。 。 C ++ 11 内存模型中的线程对应于在狭义相对论中相对移动的观察者 。顺序一致性对应于伽利略时空 (即,所有观察者都同意事件的一个绝对顺序和全局同时性意义)。
记忆模型和狭义相对论之间的相似性源于两者都定义了一组部分有序的事件,通常称为因果集。某些事件(即内存存储)可能会影响(但不受其他事件影响)。 C ++ 11 线程(或物理学中的观察者)只不过是一个链(即一个完全有序的集合)事件(例如,内存加载和存储到可能不同的地址)。
在相对论中,某些顺序被恢复到看似混乱的部分有序事件的图像,因为所有观察者都同意的唯一时间顺序是 “时间” 事件之间的排序(即,那些原则上可由任何粒子变慢的事件而不是真空中的光速)。只有时间相关的事件才是不变的。 物理学时间,Craig Callender 。
在 C ++ 11 内存模型中,使用类似的机制(获取 - 释放一致性模型)来建立这些本地因果关系 。
为了提供内存一致性的定义和放弃 SC 的动机,我将引用“内存一致性和缓存一致性入门”
对于共享内存机器,内存一致性模型定义了其内存系统的体系结构可见行为。单个处理器核心的正确性标准在 “ 一个正确结果 ” 和 “ 许多不正确的备选方案 ” 之间划分行为。这是因为处理器的体系结构要求线程的执行将给定的输入状态转换为单个明确定义的输出状态,即使在无序核心上也是如此。但是,共享内存一致性模型涉及多个线程的加载和存储,并且通常允许许多正确的执行,同时禁止许多(更多)不正确的执行。多次正确执行的可能性是由于 ISA 允许多个线程同时执行,通常有许多可能的来自不同线程的指令的合法交错。
轻松或弱内存一致性模型的动机是强大模型中的大多数内存排序是不必要的。如果一个线程更新十个数据项然后更新一个同步标志,程序员通常不关心数据项是否按照彼此的顺序更新,而只是在更新标志之前更新所有数据项(通常使用 FENCE 指令实现) )。轻松的模型试图捕获这种增加的订购灵活性,并仅保留程序员 “ 需要 ” 的订单,以获得更高的性能和 SC 的正确性。例如,在某些体系结构中,每个核心使用 FIFO 写入缓冲区来保存已提交(已淘汰)存储的结果,然后再将结果写入高速缓存。此优化可提高性能但违反 SC。写缓冲区隐藏了为存储未命中提供服务的延迟。因为商店很常见,能够避免大多数商店停滞是一个重要的好处。对于单核处理器,通过确保对地址 A 的加载将最新存储的值返回到 A,即使 A 的一个或多个存储位于写缓冲区中,也可以在体系结构上使写缓冲区不可见。这通常通过绕过 A 的最新存储的值到 A 的负载来完成,其中 “最近的” 由程序顺序确定,或者如果 A 的存储在写缓冲区中则停止 A 的加载。 。当使用多个内核时,每个内核都有自己的旁路写缓冲区。如果没有写缓冲区,硬件就是 SC,但是使用写缓冲区则不是,这使得写缓冲区在多核处理器中在架构上可见。
如果核心具有非 FIFO 写入缓冲区,允许存储以与其输入顺序不同的顺序离开,则可能发生存储 - 存储重新排序。如果第一个商店在高速缓存中未命中而第二个商店未命中,或者如果第二个商店可以与早期商店合并(即,在第一个商店之前),则可能发生这种情况。负载重新排序也可能发生在执行程序指令之外的指令的动态调度核心上。这可能与在另一个核心上重新排序存储的行为相同(你能想出两个线程之间的交错示例吗?)。使用以后的存储重新排序较早的加载(加载存储重新排序)可能会导致许多不正确的行为,例如在释放保护它的锁之后加载值(如果存储是解锁操作)。请注意,存储负载重新排序也可能由于在通常实现的 FIFO 写缓冲区中的本地旁路而产生,即使核心按程序顺序执行所有指令也是如此。
因为缓存一致性和内存一致性有时会混淆,所以引用这个引用也是有益的:
与一致性不同, 缓存一致性既不是软件可见的,也不是必需的。 Coherence 旨在使共享内存系统的缓存在功能上与单核系统中的缓存一样不可见。正确的一致性确保程序员无法通过分析加载和存储的结果来确定系统是否以及在何处具有缓存。这是因为正确的一致性确保缓存永远不会启用新的或不同的功能行为(程序员仍然可以使用定时信息推断可能的缓存结构)。缓存一致性协议的主要目的是为每个内存位置维护单写入多读取器(SWMR)不变量。一致性和一致性之间的一个重要区别是,在每个内存位置的基础上指定了一致性,而对于所有内存位置指定了一致性。
继续我们的心理图像,SWMR 不变量对应于物理要求,即任何一个位置最多只有一个粒子,但任何位置都可以有无限数量的观察者。
这是一个多年前的问题,但是非常受欢迎,值得一提的是学习 C ++ 11 内存模型的绝佳资源。我认为总结他的演讲是没有意义的,以便使这又一个完整的答案,但鉴于这是实际编写标准的人,我认为值得观看谈话。
Herb Sutter 有一个长达 3 个小时的关于 C ++ 11 内存模型的讨论,名为 “atomic <> Weapons”,可在 Channel9 网站上找到 - 第 1 部分和第 2 部分 。这个讲座非常技术性,涵盖以下主题:
谈话没有详细说明 API,而是关于推理,背景,幕后和幕后(您是否知道轻松的语义被添加到标准中只是因为 POWER 和 ARM 不能有效地支持同步加载?)。