C 11 引入了标准化的内存模型。这是什么意思?它将如何影响 C 编程?

新手上路,请多包涵

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

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

内存模型意味着 C++ 代码现在有一个标准化的库可以调用,而不管编译器是谁制作的,也不管它在什么平台上运行。有一种标准方法可以控制不同线程如何与处理器的内存通信。

“当您谈论在标准中的不同内核之间拆分 [代码] 时,我们正在谈论内存模型。我们将对其进行优化,而不会破坏人们将在代码中做出的以下假设,” 萨特 说。

好吧,我可以 记住 这个和网上可用的类似段落(因为我从出生就拥有自己的记忆模型:P),甚至可以发布作为其他人提出的问题的答案,但老实说,我不完全理解这个。

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

我也觉得 C++11 内存模型在某种程度上与 C++11 多线程支持有关,因为我经常看到这两者在一起。如果是,具体是怎样的?为什么它们应该相关?

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

原文由 Nawaz 发布,翻译遵循 CC BY-SA 4.0 许可协议

阅读 413
2 个回答

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

C++ 规范没有提及任何特定的编译器、操作系统或 CPU。它引用了一个 _抽象机器_,它是实际系统的概括。在语言律师的世界里,程序员的工作是为抽象机器编写代码;编译器的工作是在具体机器上实现该代码。通过严格按照规范进行编码,您可以确定您的代码无需修改即可在任何具有兼容 C++ 编译器的系统上编译和运行,无论是现在还是 50 年后。

C++98/C++03规范中的抽象机基本上是单线程的。因此,就规范而言,不可能编写“完全可移植”的多线程 C++ 代码。该规范甚至没有说明内存加载和存储的 原子性 或加载和存储可能发生的 _顺序_,更不用说诸如互斥锁之类的事情了。

当然,您可以在实践中为特定的具体系统(如 pthread 或 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 下,这甚至不是 Undefined Behavior;这个问题本身是 没有意义 的,因为该标准没有考虑任何称为“线程”的东西。

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

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

现在,在现代 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 037 17 ,您可以在原始代码周围包装一个互斥锁。但是如果你已经读到这里,我敢打赌你已经知道它是如何工作的,而且这个答案已经比我预期的要长:-)。

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

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

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

原文由 Nemo 发布,翻译遵循 CC BY-SA 4.0 许可协议

上述答案涉及 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
}

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

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

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

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

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

原文由 Mike Spear 发布,翻译遵循 CC BY-SA 4.0 许可协议

撰写回答
你尚未登录,登录后可以
  • 和开发者交流问题的细节
  • 关注并接收问题和回答的更新提醒
  • 参与内容的编辑和改进,让解决方法与时俱进
推荐问题