C++11引入了标准化的内存模型,但这到底意味着什么?它将如何影响C++编程?

这篇文章(加文·克拉克引用赫伯·萨特的话)说,

内存模型意味着C++代码现在有一个标准化的图书馆可以调用无论编译器是谁制造的以及在哪个平台上运行。有一种标准的方法来控制不同的线程与处理器的内存。“当你谈论分裂时[代码]跨越不同的核心在标准中,我们谈论的是记忆模型。我们将在不破坏以下假设萨特说。

嗯,我可以在网上记住这段和类似的段落(因为我从出生起就有自己的记忆模型: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;

线程2可能输出什么?

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

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

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

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

其他回答

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

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

有趣的是,Microsoft C++编译器为volatile提供了获取/释放语义,这是一个C++扩展,用于解决C++中缺少内存模型的问题http://msdn.microsoft.com/en-us/library/12a04hfd(v=vs.80).aspx。然而,鉴于Windows仅在x86/x64上运行,这并不是什么大不了的(Intel和AMD内存模型使得在一种语言中实现获取/释放语义变得简单高效)。

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

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下,这甚至不是未定义的行为;这个问题本身是没有意义的,因为标准没有考虑任何所谓的“线程”。

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

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

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

如果您使用互斥锁来保护所有数据,您真的不必担心。互斥体始终提供足够的排序和可见性保证。

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

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

我将给出我理解内存一致性模型(简称内存模型)的类比。它的灵感来自莱斯利·兰波特的开创性论文《分布式系统中的时间、时钟和事件排序》。这个比喻很恰当,具有根本意义,但对许多人来说可能是过度的。然而,我希望它提供了一个有助于记忆一致性模型推理的心理图像(图像表示)。

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

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

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

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

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

[图片来自维基百科]

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

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

在相对论中,部分有序事件的混沌图景恢复了某种秩序,因为所有观测者都同意的唯一时间顺序是“类时间”事件之间的顺序(即,那些原则上可由任何比真空中光速慢的粒子连接的事件)。只有与时间相关的事件是按不变顺序排列的。《物理学时间》,克雷格·卡伦德。

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

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

对于共享内存机器,内存一致性模型定义了其内存系统的架构可见行为。单处理器内核的正确性标准将行为划分为“一个正确的结果”和“许多不正确的选择”。这是因为处理器的体系结构要求线程的执行将给定的输入状态转换为单个定义良好的输出状态,即使是在无序的内核上。然而,共享内存一致性模型涉及多个线程的加载和存储,通常允许许多正确的执行,而不允许许多(更多)错误的执行。多个正确执行的可能性是由于ISA允许多个线程同时执行,通常来自不同线程的指令之间存在许多合法的交错。宽松或弱内存一致性模型的动机是,强模型中的大多数内存排序是不必要的。如果一个线程更新了十个数据项,然后更新了一个同步标志,那么程序员通常不关心数据项是否按照彼此的顺序进行更新,而只关心所有数据项在标志更新之前进行更新(通常使用FENCE指令实现)。宽松的模型试图捕捉这种增加的排序灵活性,并只保留程序员“需要”的顺序,以获得更高的性能和SC的正确性。例如,在某些架构中,每个内核使用FIFO写入缓冲区来保存提交(失效)存储的结果,然后再将结果写入缓存。这种优化提高了性能,但违反了SC。写入缓冲区隐藏了服务存储未命中的延迟。由于存储是常见的,因此能够避免在大多数存储上出现延迟是一个重要的好处。对于单核处理器,通过确保加载到地址a将最新存储的值返回到a,即使写入缓冲区中有一个或多个存储到a,也可以使写入缓冲区在架构上不可见。这通常是通过将最新存储到A的值旁路到从A加载来完成的,其中“最新”由程序顺序决定,或者如果到A的存储在写入缓冲区中,则通过暂停加载A来完成。当使用多个内核时,每个内核都有自己的旁路写入缓冲区。没有写缓冲区,硬件是SC,但有写缓冲区就不是SC,这使得写缓冲区在多核处理器中在架构上是可见的。如果核心具有非FIFO写入缓冲区,允许存储以不同于其输入顺序的顺序离开,则可能会发生存储重新排序。如果第一个存储在缓存中未命中,而第二个命中,或者如果第二个存储可以与较早的存储合并(即,在第一个存储之前),则可能发生这种情况。加载-加载重新排序也可能发生在动态调度的内核上,这些内核按照程序顺序执行指令。这可以像重新排序另一个内核上的存储一样(你能想出一个两个线程之间的交错示例吗?)。用稍后的存储重新排序先前的加载(加载存储重新排序)可能会导致许多错误行为,例如在释放保护它的锁之后加载值(如果存储是解锁操作)。注意,由于通常实现的FIFO写入缓冲区中的本地旁路,甚至在内核按程序顺序执行所有指令的情况下,也可能出现存储加载重新排序。

由于缓存一致性和内存一致性有时会被混淆,因此也可以引用以下内容:

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

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

C和C++过去是由格式良好的程序的执行轨迹定义的。

现在,它们一半由程序的执行轨迹定义,一半由同步对象上的许多排序定义。

这意味着这些语言定义根本没有意义,因为没有逻辑方法来混合这两种方法。特别是,互斥体或原子变量的破坏没有得到很好的定义。