关于多线程:C++ 11引入了标准化内存模型。这是什么意思?它会如何影响C++程序设计?

C++11 introduced a standardized memory model. What does it mean? And how is it going to affect C++ programming?

C++ 11引入了标准化内存模型,但这究竟意味着什么呢?它会如何影响C++程序设计?

这篇文章(引用Herb Sutter的Gavin Clarke)说,

The memory model means that C++ code
now has a standardized library to call
regardless of who made the compiler
and on what platform it's running.
There's a standard way to control how
different threads talk to the
processor's memory.

"When you are talking about splitting
across different cores that's
in the standard, we are talking about
the memory model. We are going to
optimize it without breaking the
following assumptions people are going
to make in the code," Sutter said.

嗯,我可以记住这段话和网上提供的类似的段落(因为我从出生起就有自己的记忆模式:p),甚至可以发帖回答别人提出的问题,但老实说,我不完全理解这一点。

C++程序员以前开发多线程应用程序,如果它是POSIX线程,或者Windows线程,或者C++ 11线程,这又有什么关系?有什么好处?我想了解一些低级的细节。

我也有这样的感觉,C++ 11内存模型与C++ 11多线程支持有某种关系,因为我经常看到这两个在一起。如果是,具体情况如何?他们为什么要有关联?

因为我不知道多线程的内部工作原理,也不知道内存模型的一般含义,所以请帮助我理解这些概念。-)


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

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

C++ 98/C++ 03规范中的抽象机基本上是单线程的。因此,不可能编写多线程的C++代码,它是关于规范的"完全可移植的"。规范甚至不涉及内存负载和存储的原子性或负载和存储可能发生的顺序,更不用说像互斥体之类的东西。好的。

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

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

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

1
2
3
4
5
6
           Global
           int x, y;

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

线程2的输出可能是什么?好的。

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

在C++ 11下,结果是未定义的行为,因为负载和存储一般不需要是原子的。这似乎不是什么进步…就其本身而言,并非如此。好的。

但是用C++ 11,你可以写下:好的。

1
2
3
4
5
6
           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之前运行)。好的。

它不能打印的是EDCOX1(3),因为C++ 11中的原子负载/存储的默认模式是执行顺序一致性。这仅仅意味着所有的加载和存储必须"好像"按照您在每个线程中编写它们的顺序发生,而线程之间的操作可以按照系统喜欢的方式交错进行。因此,原子的默认行为为加载和存储提供原子性和顺序。好的。

现在,在现代的CPU上,确保顺序一致性可能是昂贵的。特别是,编译器可能会在每次访问之间发出全面的内存屏障。但是,如果您的算法可以容忍无序加载和存储;即,如果它需要原子性但不需要排序;即,如果它可以容忍37 0作为该程序的输出,那么您可以编写:好的。

1
2
3
4
5
6
           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越现代,这就越可能比前一个例子更快。好的。

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

1
2
3
4
5
6
           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;

这将使我们返回到已订购的加载和存储&ndash;,因此37 0不再是可能的输出&ndash;,但它以最小的开销实现了这一点。(在这个简单的示例中,结果与全面的顺序一致性相同;在更大的程序中,结果则不同。)好的。

当然,如果您想看到的唯一输出是0 037 17,您可以在原始代码周围包装一个互斥体。但如果你读了这么多,我打赌你已经知道它是如何工作的了,而且这个答案已经比我预期的长了。好的。

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

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

有关此内容的更多信息,请参阅此博客文章。好的。好啊。


我将给出我理解内存一致性模型(简称内存模型)的类比。它受到莱斯利·兰波特的开创性论文《分布式系统中的时间、时钟和事件顺序》的启发。这个比喻很贴切,具有根本意义,但对许多人来说可能是杀伤力过大。不过,我希望它能提供一种心理图像(一种图形化的表示),有助于对记忆一致性模型进行推理。好的。

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

引用"内存一致性和缓存一致性入门"好的。

The intuitive (and most restrictive) memory model is sequential consistency (SC) in which a multithreaded execution should look like an interleaving of the sequential executions of each constituent thread, as if the threads were time-multiplexed on a single-core processor.

Ok.

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

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

[维基百科图片]Picture from Wikipedia好的。

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

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

在相对论中,部分有序事件的某些顺序被恢复到看似混乱的画面中,因为所有观察者都同意的唯一时间顺序是"类时间"事件之间的顺序(即,原则上可被任何粒子连接的事件,其速度比真空中的光速慢)。只有与时间相关的事件是固定顺序的。物理时代,克雷格·卡伦德。好的。

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

为了提供内存一致性的定义和放弃SC的动机,我将引用"内存一致性和缓存一致性入门"。好的。< Buff行情>

对于共享内存机,内存一致性模型定义了其内存系统在体系结构上可见的行为。在"一个正确的结果"和"许多不正确的选择"之间,单个处理器核心分区行为的正确性标准。这是因为处理器的体系结构要求线程的执行将给定的输入状态转换为单个定义良好的输出状态,即使在无序的核心上也是如此。但是,共享内存一致性模型关心多个线程的加载和存储,通常允许许多正确的执行,而不允许许多(更多)不正确的执行。多个正确执行的可能性是由于ISA允许多个线程并发执行,通常会有许多来自不同线程的指令的合法交错。好的。

放松或弱内存一致性模型的动机是,强模型中的大多数内存顺序都是不必要的。如果一个线程更新了十个数据项,然后又更新了一个同步标志,程序员通常不关心数据项是否按照彼此的顺序更新,但只关心所有数据项在标志更新之前都被更新(通常使用围栏指令实现)。宽松的模型试图捕获这种增加的订购灵活性,并且只保留程序员"需要"的订单,以获得更高的SC性能和正确性。例如,在某些体系结构中,每个核心使用FIFO写缓冲区来保存提交(失效)存储的结果,然后再将结果写入CACH锿。此优化提高了性能,但违反了SC。写缓冲区隐藏了服务存储未命中的延迟。因为商店很常见,所以避免在大多数商店拖延是一个重要的好处。对于单核处理器,通过确保地址A的加载将最新存储的值返回到,即使写入缓冲区中有一个或多个存储,也可以使写入缓冲区在体系结构上不可见。这通常是通过将最新存储的值绕过到从A加载的值来完成的,其中"最新"由程序顺序决定,或者通过将存储的加载暂停到写入缓冲区中。当使用多个内核时,每个内核都有自己的旁路写缓冲区。没有写缓冲区,硬件是sc,但是有写缓冲区,硬件不是,这使得写缓冲区在多核处理器中在体系结构上可见。好的。

如果核心具有非FIFO写入缓冲区,允许存储以不同于输入顺序的顺序离开,则可能会发生存储重新排序。如果第一个存储在第二个命中时在缓存中未命中,或者第二个存储可以与早期存储合并(即,在第一个存储之前),则可能会发生这种情况。加载-加载-重新排序也可能发生在动态调度的核心上,这些核心执行的指令不符合程序顺序。它的行为与重新排序另一个核心上的存储相同(您能想出一个在两个线程之间交错的例子吗?).重新排序早期加载和后期存储(加载存储重新排序)可能会导致许多错误行为,例如在释放保护值的锁后加载值(如果存储是解锁操作)。请注意,由于通常实现的FIFO写缓冲区中的本地绕过,存储负载重新排序也可能发生,即使核心按程序顺序执行所有指令。好的。< /块引用>

因为缓存一致性和内存一致性有时会混淆,所以也要引用这句话:好的。

Unlike consistency, cache coherence is neither visible to software nor required. Coherence seeks to make the caches of a shared-memory system as functionally invisible as the caches in a single-core system. Correct coherence ensures that a programmer cannot determine whether and where a system has caches by analyzing the results of loads and stores. This is because correct coherence ensures that the caches never enable new or different functional behavior (programmers may still be able to infer likely cache structure using timing information). The main purpose of cache coherence protocols is maintaining the single-writer-multiple-readers (SWMR) invariant for every memory location.
An important distinction between coherence and consistency is that coherence is specified on a per-memory location basis, whereas consistency is specified with respect to all memory locations.

Ok.

继续我们的精神图景,swmr不变量符合物理要求,即在任何一个位置最多有一个粒子,但在任何位置都可以有无限数量的观察者。好的。好啊。


这是一个多年的问题,但非常流行,值得一提的是学习C++ 11内存模型的奇妙资源。我认为总结他的演讲毫无意义,因为这是另一个完整的答案,但考虑到这是一个真正编写标准的人,我认为这是值得一看的演讲。

Habor萨特有一个长达三小时的关于C++ 11内存模型的讨论,题目是"原子<武器",可在ChaleNe9站点——第1部分和第2部分获得。这次谈话技术性很强,涉及以下主题:

  • 优化、竞赛和内存模型
  • 订购-内容:获取和发布
  • 排序-方式:静音、原子和/或围栏
  • 对编译器和硬件的其他限制
  • 代码生成和性能:x86/x64、IA64、电源、ARM
  • 松弛原子
  • 讨论并没有详细说明API,而是关于推理、背景、引擎盖下和幕后(您是否知道在标准中添加了宽松的语义,仅仅是因为电源和ARM不能有效地支持同步负载?).


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

    当您谈论POSIX线程或Windows线程时,这有点像您实际上谈论的x86线程,因为它是一个并发运行的硬件函数。C++0x内存模型可以保证,无论你是X86还是ARM,或者MIPS,或者其他任何你能想到的。


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

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

    有趣的是,微软编译器为C++提供了获取/释放语义的方法,这是一个C++扩展,用来处理C++ HTTP://MSDN中内存模型的不足。微软。COM/EN,U/LabVIEL/12A04HFD(V= VS.80)。但是,考虑到Windows仅在x86/x64上运行,这并没有多大意义(Intel和AMD内存模型使得在一种语言中实现获取/发布语义变得简单和高效)。


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

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

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