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

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

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

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

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

我还感觉到,C++11内存模型与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内存模型使得在一种语言中实现获取/释放语义变得简单高效)。

其他回答

这意味着该标准现在定义了多线程,并定义了在多线程环境中发生的事情。当然,人们使用了不同的实现,但这就像是在问我们为什么要使用std::string,而我们都可以使用一个原生的string类。

当你谈论POSIX线程或Windows线程时,这是一种错觉,因为实际上你谈论的是x86线程,因为这是一个并发运行的硬件函数。C++0x内存模型提供了保证,无论您使用的是x86、ARM、MIPS还是其他任何您能想到的东西。

对于没有指定内存模型的语言,您正在为处理器体系结构指定的语言和内存模型编写代码。处理器可以选择为性能重新排序存储器访问。因此,如果您的程序存在数据竞争(数据竞争是指多个内核/超线程可以同时访问同一内存),那么您的程序不会跨平台,因为它依赖于处理器内存模型。您可以参考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++11内存模型的绝佳资源。我认为总结他的演讲是没有意义的,以便做出另一个完整的答案,但考虑到这是真正编写标准的人,我认为很值得观看演讲。

赫伯·萨特(Herb Sutter)就C++11内存模型进行了长达三个小时的演讲,题为“原子武器”(atomic<>Weapons),可在第9频道网站YouTube上获得-第1部分和第2部分。这场演讲技术性很强,涉及以下主题:

优化、竞赛和内存模型订购–内容:获取和发布订购–方式:互斥、原子和/或围栏对编译器和硬件的其他限制代码生成和性能:x86/x64、IA64、POWER、ARM放松原子学

演讲没有详细阐述API,而是讨论了推理、背景、幕后和幕后(你知道吗,放松的语义只是因为POWER和ARM不能有效地支持同步加载而被添加到标准中的吗?)。

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

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

以前,原子性将使用编译器内部函数或某些更高级别的库来完成。围栏将使用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++内存模型,在滚动自己的序列锁之前也应该非常小心。