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

Nawaz 1810 c++ multithreading memory-model language-lawyer c++11

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

这篇文章(引用Herb SutterGavin Clarke)说,

内存模型意味着C++代码现在有一个标准化的库可以调用,无论是谁编译器以及它运行的平台.有一种标准方法可以控制不同线程与处理器内存的对话方式.

"当你谈论在标准中的不同内核之间分割[代码]时,我们正在谈论内存模型.我们将优化它,而不会破坏人们将在代码中做出的以下假设," Sutter说.

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

C++程序员以前用于开发多线程应用程序,那么如果它是POSIX线程,Windows线程或C++ 11线程,它又如何重要呢?有什么好处?我想了解低级细节.

我也觉得C++ 11内存模型与C++ 11多线程支持有某种关系,因为我经常将这两者结合在一起.如果是,究竟是怎么回事?他们为什么要相关?

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

Nemo.. 2107

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

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;

线程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将它们标准化.但有时出于性能原因,您需要较低级别的基元(例如,经典的双重检查锁定模式).新标准提供了高级小工具,如互斥锁和条件变量,它还提供低级小工具,如原子类型和各种内存屏障.因此,现在您可以完全使用标准指定的语言编写复杂的高性能并发例程,并且可以确定您的代码将在今天的系统和未来的系统上编译和运行.

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

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

  • @Nawaz:是的!内存访问可以由编译器或CPU重新排序.考虑(例如)缓存和投机负载.系统内存被命中的顺序可能与您编码的内容完全不同.编译器和CPU将确保这样的重新排序不会破坏_single-threaded_代码.对于多线程代码,"内存模型"表征可能的重新排序,以及如果两个线程同时读/写相同位置会发生什么,以及如何控制两者.对于单线程代码,内存模型无关紧要. (46认同)
  • 很好的答案,但这真的是在寻找新原语的一些实际例子.另外,我认为没有原语的内存排序与前C++ 0x相同:没有保证. (34认同)
  • @Nawaz,@ Nemo - 一个小细节:新的内存模型在单线程代码中是相关的,因为它指定了某些表达式的未定义,例如`i = i ++`.*序列点*的旧概念已被丢弃; 新标准使用*sequenced-before*关系指定相同的东西,这只是更一般的线程间*发生在之前*概念的特例. (26认同)
  • @ AJG85:表示C++草案规范0X说,"具有静态存储的持续时间(3.7.1)或线程存储时限(3.7.2)的变量节3.6.2应零初始化(8.5)的任何其它初始化发生之前地点." 由于x,y在本例中是全局的,因此它们具有静态存储持续时间,因此我将相信零初始化. (17认同)
  • @John:我知道,但我自己还在学习这些原语:-).另外我认为他们保证字节访问是原子的(虽然没有排序)这就是为什么我用"char"作为我的例子...但我甚至不能100%肯定...如果你想建议任何好的"教程"引用我会将它们添加到我的答案中 (5认同)
  • @mlvljr:我很确定我说的是我的意思......虽然我承认如果我在某处使用"数据竞赛"这个词,这个答案会更好. (3认同)
  • @Bemipefe:不,编译器没有义务按照您编写的顺序翻译代码 - 只要整体效果相同,就可以重新排序操作.例如,它可能会这样做,因为重新排序允许它生成更快(或更小)的代码. (3认同)
  • @curiousguy:缓存是否保持一致依赖于CPU架构. (3认同)
  • @Nemo:很好的答案.我现在有一个疑问:当"线程2"中的输出为"37 0"时,你是不是暗示"线程1"将在*第一个语句之前执行第二个语句*?如果是这样,它会不会产生不合逻辑和不良结果?我的意思是,如果第二个语句要求首先执行的第一个语句在逻辑上是正确的呢? (2认同)
  • @Zindarod:使用“ memory_order_relaxed”时,编译器和CPU都可以自由地重新排序加载和存储。 (2认同)

Ahmed Nassar.. 335

我将简单地给出我理解的内存一致性模型(或简称内存模型)的类比.它的灵感来自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不变量对应于物理要求,即任何一个位置最多只有一个粒子,但任何位置都可以有无限数量的观察者.

  • 那么你应该得出结论宇宙是多核的吗? (63认同)
  • 对于具有狭义相对论的类比+1,我一直试图自己做同样的比喻.我经常看到程序员调查线程代码试图将行为解释为不同线程中的操作以特定顺序相互交错,我不得不告诉他们,不管是多处理器系统,不同<s之间的同时性概念>参考框架</ s>线程现在毫无意义.与狭义相对论相比是一种让他们尊重问题复杂性的好方法. (48认同)
  • @PeterK:完全正确:)这是物理学家布莱恩·格林的这张时间图片的非常好的可视化:https://www.youtube.com/watch?v = 4BjGWLJNPcA&t = 22m12s这是"时间的幻觉[全部纪录片] ]"分22秒12秒. (5认同)
  • 是我还是他从1D内存模型(水平轴)切换到2D内存模型(同时平面)。我觉得这有点令人困惑,但这也许是因为我不是母语人士……还是很有趣的读物。 (2认同)

eran.. 109

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

Herb Sutter有一个长达3个小时的关于C++ 11内存模型的讨论,名为"atomic <> Weapons",可在Channel9网站上找到 - 第1 部分第2部分.这个讲座非常技术性,涵盖以下主题:

  1. 优化,种族和记忆模型
  2. 订购 - 什么:获取和发布
  3. 订购 - 如何:互斥锁,原子和/或栅栏
  4. 编译器和硬件的其他限制
  5. 代码和性能:x86/x64,IA64,POWER,ARM
  6. 轻松的原子论

谈话没有详细说明API,而是关于推理,背景,幕后和幕后(您是否知道轻松的语义被添加到标准中只是因为POWER和ARM不能有效地支持同步加载?).

  • 这个话题确实太棒了,完全值得你花3个小时看它. (10认同)
  • @ZunTzu:在大多数视频播放器上,您可以将速度设置为原始速度的1.25,1.5甚至2倍. (5认同)
  • @eran你们碰巧有幻灯片吗?频道9谈话页面上的链接不起作用. (3认同)
  • @athos我没有他们,抱歉.尝试联系第9频道,我不认为删除是故意的(我的猜测是他们从Herb Sutter获得链接,按原样发布,然后他删除了文件;但这只是一个推测...). (2认同)

Puppy.. 74

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

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

  • Posix线程不限于x86.实际上,它们实现的第一个系统可能不是x86系统.Posix线程与系统无关,并且在所有Posix平台上都有效.它也不是真正的硬件属性,因为Posix线程也可以通过协作式多任务处理来实现.但当然,大多数线程问题仅出现在硬件线程实现上(有些甚至仅在多处理器/多核系统上). (26认同)

小智.. 55

对于未指定内存模型的语言,您将编写由处理器体系结构指定的语言内存模型的代码.处理器可以选择重新排序存储器访问以获得性能.因此,如果您的程序有数据竞争(数据竞争是多个核心/超线程可能同时访问同一内存),那么您的程序不是跨平台的,因为它依赖于处理器内存模型.您可以参考英特尔或AMD软件手册,了解处理器如何重新排序内存访问.

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

有趣的是,用于C++的Microsoft编译器具有针对volatile的获取/发布语义,这是一个C++扩展,用于处理C++中缺少内存模型的问题http://msdn.microsoft.com/en-us/library/12a04hfd(v=vs .80).aspx.但是,鉴于Windows仅在x86/x64上运行,这并不多说(英特尔和AMD内存模型可以轻松高效地在一种语言中实现获取/发布语义).

  • 即使写了答案,也不是这样. (3认同)
  • 的确,在编写答案时,Windows仅在x86 / x64上运行,但是Windows有时会在IA64,MIPS,Alpha AXP64,PowerPC和ARM上运行。如今,它可以在各种版本的ARM上运行,这在内存方面与x86完全不同,并且几乎没有什么可以容忍的。 (2认同)

ninjalj.. 26

如果您使用互斥锁来保护所有数据,那么您真的不必担心.互斥锁始终提供足够的订购和可见性保证.

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

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

  • 之前的问题是没有互斥体(就C++标准而言).所以你提供的唯一保证是由互斥制造商提供的,只要你没有移植代码就可以了(因为很难发现保证的微小变化).现在我们得到了标准提供的保证,这些保证应该可以在平台之间移植. (19认同)
  • 不幸的是,如果您的语言中没有一致的内存模型,使用简单的互斥锁来保护您的数据结构是不够的.有各种编译器优化在单线程上下文中有意义,但是当多个线程和cpu内核发挥作用时,内存访问和其他优化的重新排序可能会产生未定义的行为.有关更多信息,请参阅Hans Boehm撰写的"线程无法实现为库":http://citeseer.ist.psu.edu/viewdoc/download;jsessionid=8923A90891ADBA7C51A8164659100D24?doi=10.1.1.90.2412&rep=rep1&type=pdf (11认同)
  • @Martin:无论如何,有一件事是内存模型,另一件是在内存模型之上运行的原子和线程原语. (4认同)
  • 此外,我的观点主要是以前在语言级别大多没有内存模型,它恰好是底层CPU的内存模型.现在有一个内存模型,它是核心语言的一部分; OTOH,互斥体等可以作为库来完成. (4认同)
  • 对于试图*编写互斥库的人来说,这也可能是一个真正的问题.当CPU,内存控制器,内核,编译器和"C库"都由不同的团队实现时,他们中的一些人对于这些东西应该如何工作存在激烈的分歧,好吧,有时这些东西我们系统程序员必须做的就是为应用程序提供一个漂亮的外观并不令人愉快. (3认同)

归档时间:

查看次数:

208258 次

最近记录:

1 年,2 月 前