【问题标题】:In C++11 threads, what guarantees does a std::mutex have about memory visibility?在 C++11 线程中,std::mutex 对内存可见性有什么保证?
【发布时间】:2018-05-26 02:19:26
【问题描述】:

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

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

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

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

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

一些问题,例如this one 谈论内存栅栏和内存模型,但似乎没有来源在 CPU 缓存的上下文中解释这一点,这就是这个问题所要求的。

【问题讨论】:

  • 读懂C++11的内存模型,就明白了
  • @john01dav 请问你能在 2 年后回答这个问题吗?我仍在努力寻找这个答案。写入 1 个线程的互斥锁的值会在另一个线程的互斥锁中更新吗???请解释并回答

标签: c++ multithreading c++11 cpu-cache


【解决方案1】:

std::mutex 具有release-acquire 内存排序语义,因此线程 A 中所有 happened-before 从线程 A 的角度对临界区的原子写入必须对线程 B 可见,然后才能进入线程 B 中的临界区.

阅读http://en.cppreference.com/w/cpp/atomic/memory_order 以开始使用。另一个很好的资源是书C++ Concurrency in Action。话虽如此,在使用高级同步原语时,您应该能够忽略这些细节中的大部分,除非您好奇或想亲自动手。

【讨论】:

  • 从您链接的参考资料中:“在线程 A 的上下文中,在关键部分(发布之前)发生的所有事情都必须对正在执行的线程 B(获取之后)可见相同的关键部分。”您说关键部分之前,这表示任何更正确的想法?
  • @Lockyer 这是一个参考框架。重点是A的临界区会在B的临界区执行之前发生
【解决方案2】:

我想我明白你在说什么。这里有三件事在起作用。

  • C++11 标准描述了在语言级别发生的情况...锁定std::mutex 是一种同步操作。 C++ 标准没有描述如何它是如何工作的。就 C++ 标准而言,CPU 缓存并不存在。

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

  • CPU 本身管理缓存的方式是为 C++ 实现工作提供必要的语义。

如果你看一下原子,这可能更容易理解,它转化为更小的汇编代码的 sn-ps,但仍然提供同步。例如,在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,但它通常由操作隐含)。这绝对不是它在所有处理器上的工作方式,请参阅功率输出:

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 Protocol 之类的技术管理共享缓存,这是一种维护cache coherence 的技术。

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

我忽略了一个事实,即互斥锁通常需要某种内核级别的支持才能使线程进入睡眠状态或唤醒。

【讨论】:

  • std::memory_order_acquire.store() 没有意义,就像std::memory_order_release.load() 没有意义一样。使用它会导致未定义的行为(在这种情况下,显然 gcc 将其映射到 seq_cst 之类的东西而不是 barfing)。 clang 在这种情况下不会添加任何障碍,icc 无法告诉您订单无效。答案仍然普遍适用!改用seq_cst,示例应该可以工作。
  • @BeeOnRope:啊,那是一个愚蠢的复制粘贴。谢谢。
猜你喜欢
  • 2017-03-28
  • 1970-01-01
  • 1970-01-01
  • 2014-07-17
  • 1970-01-01
  • 2022-09-28
  • 1970-01-01
  • 1970-01-01
  • 2014-10-26
相关资源
最近更新 更多