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

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

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

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

C++程序员以前就开发过多线程应用程序,那么,是POSIX线程、Windows线程还是C++11线程又有什么关系呢?有什么好处?我想了解底层细节。

我还感觉到,C++11内存模型与C++11多线程支持有某种关系,正如我经常看到的那样。如果是,具体如何?为什么它们应该是相关的?

我不知道多线程的内部工作原理,也不知道内存模型的一般含义。


当前回答

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

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

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

其他回答

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

atomic<uint64_t> seq; // seqlock representation
int 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 valid
        if (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
}

虽然最初看起来不直观,但data1和data2需要是原子的<>。如果它们不是原子的,那么可以在写入它们的同时(在reader()中)读取它们。根据C++内存模型,即使reader()从未实际使用数据,这也是一场竞赛。此外,如果它们不是原子的,那么编译器可以缓存寄存器中每个值的第一次读取。显然你不想这样。。。您希望在reader()中的while循环的每次迭代中重新读取。

使它们成为原子<>并使用memory_order_relaxed访问它们也是不够的。原因是seq的读取(在reader()中)只具有获取语义。简单地说,如果X和Y是内存访问,X在Y之前,X不是获取或释放,Y是获取,那么编译器可以在X之前对Y进行重新排序。如果Y是seq的第二次读取,而X是数据的读取,这样的重新排序会破坏锁的实现。

本文给出了一些解决方案。今天性能最好的可能是在第二次读取seqlock之前使用带有memory_order_relaxed的atomic_thread_fence。在本文中,它是图6。我没有在这里重复代码,因为任何读过这篇文章的人都应该读这篇文章。它比这篇文章更准确、更完整。

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

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