C++11引入了一个标准化的内存模型。它意味着什么?它将如何影响C++编程?

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

这篇文章(由加文·克拉克引用Herb Sutter)说,

内存模型意味着C++代码现在有一个标准化的图书馆不管编译器是谁做的以及它在哪个平台上运行。有一个标准的方法来控制不同的线程与处理器的内存。

"当你谈论分裂时[code]跨越不同的核心在标准中,我们谈论的是记忆模型。我们要优化它而不破坏以下假设人们会在代码中制作,“Sutter说。

好吧,我可以在网上找到这个和类似的段落(因为我从出生起就有自己的记忆模型:P),甚至可以发布作为对别人提出的问题的回答,但老实说,我不完全理解这一点。

C++程序员以前就习惯于开发多线程应用程序,那么POSIX线程、Windows线程或C++11线程又有什么关系呢?有什么好处?我想了解低级细节。

我也有这种感觉,C++11内存模型在某种程度上与C++11多线程支持有关,因为我经常看到这两者在一起。

我不知道多线程的内部是如何工作的,以及内存模型的一般含义。

279714 次浏览

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

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

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

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

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

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

C++规范并不参考任何特定的编译器、操作系统或CPU,它参考的是实际系统的泛化。在语言律师的世界里,程序员的工作是为抽象机器编写代码;编译器的工作是在具体机器上实现这些代码。通过严格按照规范编码,你可以确定你的代码将在任何具有合规C++编译器的系统上无需修改即可编译和运行,无论是今天还是50年后。

C++98/C++03规范中的抽象机器基本上是单线程的。因此,不可能编写出相对于规范而言“完全可移植”的多线程C++代码。规范甚至没有说明内存加载和存储的0号或可能发生加载和存储的1号,更不用说像互斥锁这样的事情了。

当然,你可以在实践中为特定的具体系统编写多线程代码——比如pthread或Windows。但是没有标准的方法可以为C++98/C++03编写多线程代码。

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

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

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

线程2可能会输出什么?

根据C++98/C++03,这甚至不是未定义的行为;问题本身是毫无意义,因为标准没有考虑任何称为“线程”的东西。

在C++11下,结果是未定义的行为,因为加载和存储一般不需要是原子的。这可能看起来没有太大的改进…就其本身而言,它不是。

C++11,你可以这样写:

           Globalatomic<int> x, y;
Thread 1                 Thread 2x.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中原子加载/存储的默认模式是强制执行顺序一致性。这只是意味着所有的加载和存储必须“好像”按照你在每个线程中编写它们的顺序发生,而线程之间的操作可以按照系统喜欢的方式交错。因此,原子的默认行为为加载和存储提供了原子性订购

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

           Globalatomic<int> x, y;
Thread 1                            Thread 2x.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越现代化,就越有可能比前面的示例更快。

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

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

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

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

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

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

有趣的是,C++的微软编译器已经获得/发布了易失性语义学,这是一个C++的扩展,用于处理C++http://msdn.microsoft.com/en-us/library/12a04hfd(v=vs.80). aspx中缺乏内存模型的问题。然而,鉴于Windows仅在x86/x64上运行,这并不多(英特尔和AMD内存模型使在语言中实现获取/发布语义学变得简单有效)。

我将给出我理解内存一致性模型(或简称内存模型)的类比。它的灵感来自Leslie Lamport的开创性论文“时间,时钟和分布式系统中事件的顺序”。这个类比是恰当的,具有根本的意义,但对许多人来说可能是多余的。然而,我希望它提供了一个心理图像(图形表示),有助于推理记忆一致性模型。

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

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

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

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

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

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

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

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

在相对论中,部分有序事件的看似混乱的画面中恢复了一些秩序,因为所有观察者都同意的唯一时间顺序是“类时间”事件之间的顺序(即,原则上任何比真空中光速慢的粒子都可以连接的事件)。只有与时间相关的事件是不变有序的。物理学中的时间,Craig Callender.

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

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

对于共享内存机器,内存一致性模型定义了其内存系统在架构上可见的行为。单个处理器内核的正确性标准将行为划分在“一个正确的结果”和“许多不正确的选择”之间。这是因为处理器的架构要求线程的执行将给定的输入状态转换为单个定义明确的输出状态,即使是在无序的内核上。然而,共享内存一致性模型关注多个线程的加载和存储,通常允许许多正确的处决,同时不允许许多(更多)不正确的线程。多次正确执行的可能性是由于ISA允许多个线程并发执行,通常有许多可能的不同线程指令的合法交织。

轻松<强>弱内存一致性模型的动机是,强模型中的大多数内存排序是不必要的。如果一个线程更新了10个数据项,然后更新了一个同步标志,程序员通常不在乎数据项是否相对于彼此更新,而只关心所有数据项在标志更新之前更新(通常使用FENCE指令实现)。松弛模型寻求捕捉这种增加的排序灵活性,并只保留程序员“要求”的顺序,以获得更高的性能和SC的正确性。例如,在某些体系结构中,每个内核使用先进先出写缓冲区在将结果写入缓存之前保存提交(已退休)存储的结果。这种优化提高了性能,但违反了SC。写缓冲区隐藏了服务存储未命中的延迟。因为存储是常见的,能够避免在大多数存储上停顿是一个重要的好处。对于单核处理器,可以通过确保对地址A的加载将最近存储的值返回给A来使写缓冲区在体系结构上不可见,即使A的一个或多个存储在写缓冲区中。这通常是通过绕过最近存储到A的值来完成的,其中“最近”由程序顺序确定,或者如果存储到A的存储在写缓冲区中,则通过暂停A的加载来完成。当使用多个内核时,每个内核都有自己的绕过写缓冲区。没有写缓冲区,硬件是SC,但有写缓冲区,则不是,这使得写缓冲区在多核处理器中架构可见。

如果内核有一个非FIFO写缓冲区,该缓冲区允许存储以与它们输入的顺序不同的顺序离开,则可能会发生存储-存储重新排序。如果第一个存储在缓存中丢失,而第二个命中,或者第二个存储可以与更早的存储合并(即,在第一个存储之前),则可能会发生这种情况。加载-加载重新排序也可能发生在动态调度的内核上,这些内核以程序顺序执行指令。这可能与在另一个内核上重新排序存储相同(你能提供一个两个线程之间交错的示例吗?)。将较早的加载与较晚的存储重新排序(加载-存储重新排序)可能会导致许多不正确的行为,例如在释放保护它的锁后加载一个值(如果存储是解锁操作)。请注意,即使使用按程序顺序执行所有指令的内核,也可能由于通常实现的FIFO写缓冲区中的本地旁路而导致存储-加载重新排序。

因为高速缓存一致性和内存一致性有时会混淆,所以也有以下引用:

与一致性不同,高速缓存一致性对软件既不可见,也不是必需的。一致性旨在使共享内存系统的缓存像单核系统中的缓存一样在功能上不可见。正确的一致性确保程序员无法通过分析加载和存储的结果来确定系统是否以及在哪里有缓存。这是因为正确的一致性确保缓存永远不会启用新的或不同的功能强大行为(程序员仍然可以使用时机信息推断可能的缓存结构)。高速缓存一致性协议的主要目的是维护每个内存位置的单写多读(SWMR)不变性。一致性和一致性之间的一个重要区别是一致性是在基于每个内存位置上指定的,而一致性是在<强>所有内存位置上指定的。

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

这是一个多年前的问题,但由于非常受欢迎,值得一提的是学习C++11内存模型的绝佳资源。我认为没有必要总结他的演讲,以使这是另一个完整的答案,但考虑到这是真正编写标准的人,我认为很值得观看这个演讲。

Herb Sutter有三个小时关于C++11内存模型的演讲,题为“原子<>武器”,可在Channel9网站YouTube-第一部分第2上获得。演讲非常技术化,涵盖以下主题:

  1. 优化、竞赛和内存模型
  2. 订购-什么:获取和发布
  3. 排序-如何:互斥体、原子和/或栅栏
  4. 对编译器和硬件的其他限制
  5. 代码生成和性能:x86/x64、IA64、POWER、ARM
  6. 放松原子

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

C和C++过去由格式良好的程序的执行跟踪定义。

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

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

上述答案触及了C++内存模型的最基本方面。在实践中,std::atomic<>的大多数使用“只是工作”,至少直到程序员过度优化(例如,试图放松太多东西)。

有一个地方的错误仍然很常见:序列锁。关于https://www.hpl.hp.com/techreports/2012/HPL-2012-68.pdf中的挑战,有一个很好且易于阅读的讨论。序列锁很有吸引力,因为读者避免了写入锁字。以下代码基于上述技术报告的图1,它突出了在C++中实现序列锁时的挑战:

atomic<uint64_t> seq; // seqlock representationint data1, data2;     // this data will be protected by seq
T reader() {int r1, r2;unsigned seq0, seq1;while (true) {seq0 = seq;r1 = data1; // INCORRECT! Data Race!r2 = data2; // INCORRECT!seq1 = seq;
// if the lock didn't change while I was reading, and// the lock wasn't held while I was reading, then my// reads should be validif (seq0 == seq1 && !(seq0 & 1))break;}use(r1, r2);}
void writer(int new_data1, int new_data2) {unsigned seq0 = seq;while (true) {if ((!(seq0 & 1)) && seq.compare_exchange_weak(seq0, seq0 + 1))break; // atomically moving the lock from even to odd is an acquire}data1 = new_data1;data2 = new_data2;seq = seq0 + 2; // release the lock by increasing its value to even}

起初看起来很不直观,data1data2需要是atomic<>。如果它们不是原子的,那么它们可以在写入的同时(在writer()中)被读取(在reader()中)。根据C++内存模型,这是一场竞赛即使#3从未真正使用过数据。此外,如果它们不是原子的,那么编译器可以将每个值的第一次读取缓存在寄存器中。显然你不希望这样……你想在reader()while循环的每次迭代中重新读取。

使它们atomic<>并用memory_order_relaxed访问它们也是不够的。这样做的原因是seq的读取(在reader()中)只有收购语义学。简单来说,如果X和Y是内存访问,X在Y之前,X不是获取或释放,Y是获取,那么编译器可以在X之前重新排序Y。如果Y是seq的第二次读取,X是数据的读取,这样的重新排序将打破锁的实现。

这篇论文给出了一些解决方案。今天性能最好的可能是使用atomic_thread_fencememory_order_relaxed之前进行seqlock的二次读取的那个。在论文中,它是图6。我在这里不复制代码,因为任何读过这么多的人都应该阅读这篇论文。它比这篇文章更精确和完整。

最后一个问题是,将data变量设为原子变量可能是不自然的。如果你不能在代码中这样做,那么你需要非常小心,因为从非原子到原子的转换只对原始类型是合法的。C++20应该添加atomic_ref<>,这将使这个问题更容易解决。

总结一下:即使您认为您了解C++内存模型,在滚动自己的序列锁之前也应该非常小心。