我需要使用内存屏障来保护共享资源吗?

3
在多生产者和多消费者的情况下。如果生产者正在写入 int a,而消费者正在从 int a 读取,那么我需要在 int a 周围设置内存屏障吗?
我们都知道:共享资源应该始终受到保护,否则标准无法保证其正常工作。
然而,在缓存一致性架构中,可见性是自动确保的,8、16、32和64位变量的原子性“MOV”操作也是有保证的。
因此,为什么要保护 int a 呢?

可见性不是自动保证的;例如,编译器可能仅将(int a)的值保存在寄存器中,这种情况下对该值的更改永远不会进入任何缓存,更不用说不同核心或CPU上的缓存了。 - Jeremy Friesner
@Jeremy 这篇文章似乎持不同意见 http://www.1024cores.net/home/lock-free-algorithms/so-what-is-a-memory-model-and-how-to-cook-it/visibility - Kam
仔细阅读@JeremyFriesner的评论。该链接在某种程度上是正确的,但在他指出的情况下并不适用。如果数据被写入/从内存位置读取,则缓存控制器将确保一致性。然而,如果它被分配给寄存器,则缓存控制器与其无关--缓存控制器仅处理内存,而不是寄存器。 - Jerry Coffin
@Jerry 你的意思是如果“线程1”正在写入“int a”,而“线程2”尝试读取“int a”,那么“线程2”可能无法看到“线程1”写入的内容? - Kam
天啊,我怎么不知道这个…… - Kam
同时读取和写入该值可能需要多个CPU指令,这使其不是原子操作。 - seand
2个回答

7

在C++11或更新版本中,您不需要(显式地)使用互斥锁或内存屏障来保护变量。

您可以使用std::atomic创建一个原子变量。对该变量的更改保证会传播到各个线程。

std::atomic<int> a;

// thread 1:
a = 1;

// thread 2 (later):
std::cout << a;    // shows `a` has the value 1.

当然,这并不是全部--比如说,并不能保证std::cout是原子的,所以如果你尝试从多个线程写入,很可能需要进行保护。
然后由编译器/标准库来确定处理原子性要求的最佳方法。在确保高速缓存一致性的典型架构上,它可能意味着仅仅“不要在寄存器中分配这个变量”。它可以强制使用内存屏障,但只有在真正需要的系统上才可能这样做。
在实际的C++实现中,在像以前的C++11之前的方式自己实现原子操作一样使用volatile的地方(即所有的实现),对于不同线程的可见性不需要屏障,只需要针对其他变量的操作进行排序。大多数ISAs确实需要特殊的指令或屏障才能支持默认的memory_order_seq_cst
另一方面,为原子变量显式指定内存顺序(特别是acquirerelease)可能会让你优化代码。默认情况下,原子使用顺序排序,基本上就像在访问之前和之后有屏障--但在很多情况下,你只需要其中一个,而不是两个。在这些情况下,显式指定内存顺序可以让你放松到实际需要的最小限度,从而允许编译器改进优化。
(并非所有的ISAs甚至都需要为seq_cst单独使用屏障指令;尤其是AArch64的stlrldar之间有特殊的交互作用,防止了seq_cst存储与稍后的seq_cst加载重新排序,除了acquire和release排序之外,它弱于C++内存模型允许的,同时仍然遵守它。但更弱的顺序,如memory_order_acquirerelaxed,可以避免即使不需要时也阻止重新排序。)

原子操作基本上就像在访问前后插入了屏障一样。你的意思是因为memory_order_seq_cststd::atomic的默认排序方式吗?如果你想要更便宜的操作,你需要使用a.store(1, std::memory_order_release) - Peter Cordes
如果你谈论的是非“原子”变量,编译器屏障不会创建原子性。有时你会很幸运,但是在64位计算机上,哪些类型在gnu C和gnu C++中自然是原子的?--意思是它们具有原子读取和原子写入,展示了AArch64上*u64_ptr = 0xdeadbeefdeadbeef;被分成两半的反例。另请参见谁害怕大坏优化编译器?关于使用屏障和volatile来滚动自己的原子操作(在只需使用GCC或clang编译的Linux内核中)。 - Peter Cordes
编译器屏障(如asm("" ::: "memory");)可以创建线程间的可见性,但不能保证原子性。C++11中的fences(如atomic_thread_fence(memory_order_release);)通常至少包含一个编译器屏障作为实现细节,并且在大多数实现中可能会使普通的int可见。(但就标准合规性而言,如果没有同步,它并不能避免数据竞争UB。) - Peter Cordes
好的,使用弱于SC操作与使用显式屏障非常不同。我认为大多数人会像我一样理解它,即谈论atomic_thread_fence(release)。具体来说,谈论的措辞是:“在这些情况下,显式内存屏障可以让您删除不需要的屏障。”-看起来您的意思是显式内存顺序;只有一个单词的差别(在3个地方),但是非常重要! - Peter Cordes
1
@PeterCordes:我做了一些编辑。我不确定现在的措辞是否令人满意,但是我现在没有更多时间可以花费在它上面了(大约3个小时后要进行工作演示)。 - Jerry Coffin
显示剩余7条评论

5

然而,在高速缓存一致性体系结构中,能够自动确保可见性,并且8、16、32和64位变量MOV操作的原子性是有保障的。

如果您不严格遵守C++规范要求以避免数据竞争,编译器无需按照您所期望的方式使代码运行。例如:

int a = 0, b = 0; // shared variables, initialized to zero

a = 1;
b = 1;

假设你在完全缓存一致的架构上操作。在这样的硬件上,似乎由于 ab 之前被写入,没有任何线程能够看到 b 的值为 1,除非 a 也具有该值。
但事实并非如此。如果您未严格遵守 C++ 内存模型中避免数据竞争的要求,例如在任何地方都未插入正确的同步原语进行变量读取,则您的程序实际上可能会观察到 ba 之前被写入的情况。原因在于您引入了 "未定义行为",而 C++ 实现没有义务执行任何对您有意义的操作。
实际上可能正在发生的是,即使硬件非常努力地让所有写操作按照执行写入操作的机器指令的顺序进行,编译器 也可以重新排序写操作。你需要整个工具链合作,仅强大的缓存一致性等硬件合作是不够的。
如果您想了解 C++ 内存模型和在 C++ 中编写可移植、并发代码的详细信息,书籍 C++ Concurrency in Action 是一个很好的参考来源。

相关:为什么在x86上对自然对齐变量的整数赋值是原子的? - 正如你所说,你需要std::atomic<int>来利用硬件的能力。你可以使用std::memory_order_relaxed来获得廉价的加载/存储而没有屏障(或者在x86上获取和释放也是“免费”的),但不会优化到寄存器中。 - Peter Cordes

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接