C ++ 11引入了标准化的内存模型。 这是什么意思? 那么如何影响C ++编程呢?

C ++ 11引入了标准化的内存模型,但究竟是什么意思呢? 那么如何影响C ++编程呢?

香草萨特在这里说,

内存模型意味着C ++代码现在有一个标准化的库来调用,不pipe编译器是谁做的,在哪个平台上运行。 有一个标准的方法来控制不同的线程如何与处理器的内存交谈。

Sutter说:“当你谈论的是跨标准的不同核心进行拆分时,我们正在谈论内存模型。我们将会优化它,而不会破坏人们在代码中所做的下列假设。

那么,我可以记住这个在线类似的段落(因为我已经有自己的记忆模型,自诞生以来:P),甚至可以回答别人提出的问题,但说实话,我不完全明白这一点。

所以,我基本上想知道的是,C ++程序员甚至在之前就开发了multithreading应用程序,所以如果是POSIX线程,Windows线程或者C ++ 11线程,它又有什么关系呢? 有什么好处? 我想了解低级细节。

我也感觉到C ++ 11内存模型与C ++ 11multithreading支持有某种联系,因为我经常将这两者结合在一起。 如果是这样,究竟是如何? 他们为什么要相关?

因为我不知道multithreading的内部工作原理,以及一般的内存模型意味着什么,所以请帮助我理解这些概念。 🙂

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

C ++规范没有引用任何特定的编译器,操作系统或CPU。 它引用了一个抽象机器 ,这是一个实际系统的概括。 在语言律师的世界里,程序员的工作就是为抽象机器编写代码; 编译器的工作就是在具体的机器上实现这个代码。 通过对规范进行严格的编码,无论是今天还是现在的50年,您都可以确信,您的代码将在没有任何修改的情况下编译和运行,而无需在符合C ++编译器的任何系统上进行修改。

C ++ 98 / C ++ 03规范中的抽​​象机器基本上是单线程的。 所以不可能写出关于规范的“完全便携”的multithreadingC ++代码。 规范甚至没有提到内存加载和存储的primefaces性 ,或者加载和存储的顺序 ,没有关于互斥体的事情。

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

C ++ 11中的抽象机器是multithreadingdevise的。 它也有一个明确的记忆模型 ; 也就是说,说到编译器在访问内存时可能会做什么也可能不会做什么。

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

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

什么可能线程2输出?

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

在C ++ 11下,结果是未定义的行为,因为加载和存储通常不需要是primefaces的。 这看起来似乎没有太大的改善…而且本身并不是。

但是用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中primefaces加载/存储的默认模式是强制执行顺序一致性 。 这只是意味着所有的加载和存储都必须按照您在每个线程中写入的顺序“好像”发生,而线程之间的操作可以交错,但系统喜欢。 所以primefaces的默认行为既提供了primefaces性 ,也提供了加载和存储的顺序

现在,在现代CPU上,确保顺序一致性可能是昂贵的。 特别是,编译器可能在这里的每个访问之间发出全面的内存屏障。 但是,如果你的algorithm可以容忍乱序加载和存储; 即如果它需要primefaces性而不是命令; 即如果它可以容忍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 037 17 ,你可以在原始代码周围包装一个互斥体。 但是,如果你已经读了这么多,我敢打赌你已经知道这是如何工作的,这个答案已经比我想要的更长了:-)。

所以,底线。 互斥体非常好,C ++ 11将它们标准化。 但有时出于性能的原因,你需要更低级别的基元(例如,经典的双重检查locking模式 )。 新标准提供了像互斥锁和条件variables这样的高级小工具,还提供了像primefacestypes和各种内存屏障等低级小工具。 因此,现在您可以完全使用标准指定的语言编写复杂的高性能并发例程,并且您可以确定您的代码将在今天的系统和未来的系统上编译和运行。

虽然坦率地说,除非你是一个专家,并且正在研究一些严重的底层代码,你应该坚持互斥和条件variables。 这就是我打算做的。

欲了解更多关于这个东西,请参阅这篇博文 。

我将给出一个我理解内存一致性模型(或简称内存模型)的类比。 它受到Leslie Lamport的开创性论文“时间,时钟和分布式系统中的事件sorting”的启发。 这个比喻是恰当的,具有根本的意义,但对许多人来说可能是矫枉过正的。 但是,我希望它提供了一个心理图像(一个graphics表示),有助于推理关于内存一致性模型。

让我们来看一个空间 – 时间图中的所有存储位置的历史,其中水平轴表示地址空间(即,每个存储位置由该轴上的一个点表示),而垂直轴表示时间(我们将看到,总的来说,没有普遍的时间观念)。 因此,每个存储器位置所保存的值的历史由该存储器地址处的垂直列表示。 每个值的变化都是由于其中一个线程向该位置写入新的值。 通过内存映像 ,我们将指特定线程 在特定时间可观察到的所有内存位置值的集合/组合。

从“内存一致性和caching一致性入门”引用

直观的(也是最具限制性的)内存模型是顺序一致性(SC),其中multithreading执行看起来像是每个组成线程的顺序执行的交错,好像这些线程在单核处理器上是时间复用的。

全局记忆顺序可以从程序的一个运行到另一个不同,并且可能不会事先知道。 SC的特征是地址空间 – 时间图中代表同时性平面 (即存储器图像)的一组水平片段。 在给定的平面上,其所有事件(或记忆值)是同时发生的。 有绝对时间的概念,其中所有的线程都同意哪些内存值是同时的。 在SC中,在每个时刻,只有一个内存映像被所有线程共享。 也就是说,在每一个时刻,所有的处理器都同意内存映像(即内存的聚合内容)。 这不仅意味着所有线程都查看所有内存位置的相同值序列,而且所有处理器都观察到所有variables的相同组合 。 这与所有线程的所有内存操作(在所有内存位置上)的观察顺序相同。

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

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

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

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

在相对论中,有些秩序恢复到部分有序事件的看似混乱的图景,因为所有观察者都认同的唯一时间顺序是“时间性”事件之间的sorting(也就是说,那些原则上可以被任何粒子走得慢的事件比光在真空中的速度)。 只有时间般的相关事件是不变的命令。 物理时间,克雷格·卡伦德 。

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

为了提供内存一致性的定义和放弃SC的动机,我将引用“内存一致性和caching一致性入门”

对于共享内存机器来说,内存一致性模型定义了内存系统的体系结构可见行为。 单个处理器核心的正确性标准在“ 一个正确的结果 ”和“ 很多不正确的select ”之间划分行为。 这是因为处理器的体系结构要求线程的执行将给定的input状态转换成单一明确定义的输出状态,即使是在无序的内核上。 然而,共享内存一致性模型涉及multithreading的加载和存储,并且通常允许许多正确的执行,而不允许许多(更多)不正确的执行。 多重正确执行的可能性是由于ISA允许多个线程同时执行,通常来自不同线程的许多可能的合法交错的指令。

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

如果一个内核有一个非FIFO写入缓冲区,商店可能会发生重新sorting,这个缓冲区允许商店以不同于它们进入的顺序的顺序离开。 如果第一个商店在高速caching中未命中,而第二个命中或者第二个商店可以与先前的商店合并(即在第一个商店之前),则可能发生这种情况。 负载重新sorting也可能发生在dynamic调度的内核上,这些内核执行的指令不是程序顺序。 这可以像对另一个核心上的商店重新sorting一样(你能想出两个线程之间的示例交错?)。 使用稍后的存储(加载存储重新sorting)重新sorting较早的加载可能会导致许多不正确的行为,例如在释放锁保护它(如果存储是解锁操作)之后加载一个值。 请注意,由于在通常实现的FIFO写入缓冲区中的本地旁路,即使使用按程序顺序执行所有指令的内核,也可能会导致存储装载重新sorting。

由于caching一致性和内存一致性有时会混淆,所以也有这样的引用是有益的:

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

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

这意味着标准现在定义了multithreading,它定义了在multithreading环境中发生的事情。 当然,人们使用不同的实现,但是这就像问我们为什么我们应该有一个std::string当我们都可以使用一个home-rolled string类。

当你在谈论POSIX线程或Windows线程时,实际上你正在讨论x86线程,这是一种幻想,因为它是一个并发运行的硬件function。 无论您使用的是x86还是ARM,还是MIPS或其他任何您可以想到的,C ++ 0x内存模型都可以保证。

这是一个多年的问题,但是非常stream行,值得一提的是学习C ++ 11内存模型的一个很好的资源。 为了使这个又一个完整的答案,我觉得没有什么意义,但是鉴于这个人是谁写的标准,我觉得值得一看。

Herb Sutter有三个小时的长篇大论,讲述了Channel9网站的第一部分和第二部分的C ++ 11内存模型,标题为“atomic”,“Weapons”。 这次谈话非常具有技术性,涵盖以下主题:

  1. 优化,竞赛和内存模型
  2. 订购 – 什么:收购和发布
  3. sorting – 如何:互斥体,primefaces和/或栅栏
  4. 编译器和硬件的其他限制
  5. 代码Gen和性能:x86 / x64,IA64,POWER,ARM
  6. 轻松的primefaces

这个演讲没有详细说明API,而是在推理,背景,幕后和幕后(你知道轻松的语义只是因为POWER和ARM不能有效支持同步加载而被添加到标准中)。

对于未指定内存模型的语言,您正在编写由处理器体系结构指定的语言内存模型的代码。 处理器可能会select重新sorting内存访问的性能。 所以, 如果你的程序有数据竞争(数据竞争是多核心/超线程同时访问同一内存的可能性),那么你的程序就不是跨平台的,因为它依赖于处理器内存模型。 您可以参考Intel或AMD软件手册来了解处理器如何重新sorting内存访问。

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

有趣的是,C ++的Microsoft编译器已经获取/释放volatile的语义,这是一个C ++扩展,用于处理C ++中缺less内存模型的问题http://msdn.microsoft.com/en-us/library/12a04hfd(v=vs .80).aspx 。 但是,鉴于Windows只能在x86 / x64上运行,这并不是说很多(英特尔和AMD内存模型使得在语言中实现获取/发布语义变得简单而高效)。

如果你使用互斥体来保护你所有的数据,你真的不需要担心。 互斥体一直提供足够的订购和可视性保证。

现在,如果您使用primefaces或无锁algorithm,则需要考虑内存模型。 内存模型准确地描述了primefaces提供sorting和可视性保证的时间,并为手工编码保证提供了可移植栅栏。

以前,primefaces将使用编译器内在函数或更高级别的库来完成。 使用特定于CPU的指令(内存屏障)可以完成围栏。

Interesting Posts