在C++11线程中,std::mutex对于内存可见性有什么保证?

11

我目前正在学习C++11线程API,发现各种资源都没有提供一个重要的信息:如何处理CPU缓存。现代CPU为每个内核提供缓存(意味着不同的线程可能使用不同的缓存)。这意味着一个线程可以将一个值写入内存,但另一个线程可能看不到它,即使它看到了第一个线程所做的其他更改。

当然,任何好的线程API都提供了解决这个问题的方法。然而,在C++的线程API中,这并不清楚。我知道,例如,std::mutex会以某种方式保护内存,但不清楚它是什么:它是否清除整个CPU缓存,只清除从当前线程的缓存中访问的互斥体内部对象,还是其他的操作?

此外,显然,只读访问不需要互斥体,但如果仅线程1不断写入内存以修改对象,那么其他线程是否会看到该对象的过时版本,因此需要进行某种形式的缓存清除?

原子类型是否简单地绕过缓存并使用单个CPU指令从主内存中读取值?它们是否对访问的其他内存位置做出任何保证?

在CPU缓存的上下文中,C++11线程API中的内存访问是如何工作的?

有些问题,例如这个问题讨论了内存屏障和内存模型,但没有任何来源在CPU缓存的背景下解释这个问题。


2
可能是Does std::mutex create a fence?的重复问题。 - Preet Kukreti
阅读关于C++11内存模型的内容,它会变得更加清晰。 - C.M.
@john01dav,请问您能否在2年后回答这个问题吗?我仍然在努力寻找这个答案。一个线程锁定的互斥锁中写入的值会更新到另一个线程的互斥锁中吗?请解释并回答。 - Diljeet
2个回答

6

std::mutex 具有 release-acquire 内存顺序语义,因此在线程 A 中发生的所有事情,在从线程 A 的角度看到的关键部分的原子写入之前必须对线程 B 可见,然后线程 B 才能进入关键部分。

请阅读http://en.cppreference.com/w/cpp/atomic/memory_order以开始学习。另一个好的资源是书籍C++ Concurrency in Action。尽管如此,当使用高级同步原语时,除非您好奇或想要深入了解,否则应该能够忽略大多数这些细节。


从您提供的参考链接中可以看到:“在线程A的关键部分(释放之前)发生的所有事情都必须对执行相同关键部分的线程B(获取之后)可见。”您说“在”关键部分之前,这句话则是“在线程A的关键部分(释放之前)发生的所有事情”,您认为哪个更正确呢? - Lockyer
@Lockyer 这是一个参考框架的问题。关键点在于 A 的临界区将会在 B 的临界区执行之前发生。 - Preet Kukreti

5
我想我理解你的意思了。这里有三个因素。
  • C++11标准描述了在语言层面上发生的事情...锁定std::mutex是一种同步操作。C++标准没有描述它是如何工作的。就C++标准而言,CPU缓存不存在。

  • C++实现在某个时刻将一些机器码放入您的应用程序中,以实现互斥锁。创建此实现的工程师必须考虑C++11规范和架构规范。

  • CPU本身以这样的方式管理缓存,以提供C++实现所需的语义。

如果您查看原子操作,可能更容易理解,因为它们转换为更小的汇编代码片段,但仍提供同步。例如,请在GodBolt上尝试以下操作:

#include <atomic>

std::atomic<int> value;

int acquire() {
    return value.store(std::memory_order_acquire);
}

void release() {
    value.store(0, std::memory_order_release);
}

您可以查看汇编代码:

acquire():
  mov eax, DWORD PTR value[rip]
  ret
release():
  mov DWORD PTR value[rip], 0
  ret
value:
  .zero 4

在x86上,没有必要做什么,CPU已经提供了所需的内存排序语义(尽管可以使用显式的mfence,它通常由操作隐含)。但并非所有处理器都是如此,参见Power输出:
acquire():
.LCF0:
0: addis 2,12,.TOC.-.LCF0@ha
  addi 2,2,.TOC.-.LCF0@l
  addis 3,2,.LANCHOR0@toc@ha # gpr load fusion, type int
  lwz 3,.LANCHOR0@toc@l(3)
  cmpw 7,3,3
  bne- 7,$+4
  isync
  extsw 3,3
  blr
  .long 0
  .byte 0,9,0,0,0,0,0,0
release():
.LCF1:
0: addis 2,12,.TOC.-.LCF1@ha
  addi 2,2,.TOC.-.LCF1@l
  lwsync
  li 9,0
  addis 10,2,.LANCHOR0@toc@ha
  stw 9,.LANCHOR0@toc@l(10)
  blr
  .long 0
  .byte 0,9,0,0,0,0,0,0
value:
  .zero 4

这里明确指出了isync指令,因为Power内存模型在没有它们的情况下提供的保证更少。

然而,这只是将问题推到了一个更低的级别。CPU本身使用类似MESI协议的技术来管理共享缓存,这是一种维护缓存一致性的技术。

在MESI协议中,当一个核心修改一个缓存块时,它必须从其他缓存中刷新该块。其他核心将该块标记为无效,并在必要时将其内容写入主存储器。这很低效,但是必要的。因此,您不希望尝试将一堆常用的互斥锁或原子变量塞在一个小的内存区域中,因为您可能会导致多个核心争夺相同的缓存块。维基百科文章非常全面,比我写在这里的内容更详细。

我忽略的一些内容是,互斥锁通常需要某种内核级别的支持,以便线程可以睡眠或唤醒。


1
std::memory_order_acquire 对于 .store() 来说没有意义,就像 std::memory_order_release 对于 .load() 也没有意义一样。使用它会导致未定义的行为(显然,gcc 在这种情况下将其映射到类似于 seq_cst 的东西,而不是抛出错误)。在这种情况下,clang 不添加任何屏障,而 icc 则会失败并告诉您顺序无效。答案仍然普遍适用!请尝试使用 seq_cst,例如应该可以正常工作。 - BeeOnRope
@BeeOnRope:啊,那是一个愚蠢的复制粘贴。谢谢。 - Dietrich Epp

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