互斥锁的存在是不是有助于摆脱 volatile 关键字?

Posted

技术标签:

【中文标题】互斥锁的存在是不是有助于摆脱 volatile 关键字?【英文标题】:does presence of mutex help getting rid of volatile key word ?互斥锁的存在是否有助于摆脱 volatile 关键字? 【发布时间】:2009-10-23 21:48:10 【问题描述】:

我有一个多 R/W 锁类,它保持读、写和挂起的读、挂起的写计数器。互斥体保护它们免受多个线程的影响。

我的问题是我们是否仍然需要将计数器声明为 volatile,以便编译器在进行优化时不会搞砸。

或者编译器是否考虑到计数器由互斥锁保护。

我知道互斥锁是一种用于同步的运行时机制,而“volatile”关键字是编译时间指示编译器在进行优化时做正确的事情。

问候, -杰。

【问题讨论】:

当然你需要 volatile 才能不将值放入寄存器。但我不知道内存栅栏。这取决于互斥锁的实现。如果使用 xchg 或 cas 实现自旋锁,则需要内存栅栏。 【参考方案1】:

这里有2个基本不相关的项目,总是混淆。

易变 线程、锁、内存屏障等

volatile 用于告诉 编译器 生成代码以从内存中读取变量,而不是从寄存器中读取。并且不要重新排序代码。一般来说,不要优化或走“捷径”。

内存屏障(由互斥体、锁等提供),正如 Herb Sutter 在另一个答案中所引用的那样,用于防止 CPU 重新排序读/写内存请求,无论编译器如何表示去做吧。即不要优化,不要走捷径 - 在 CPU 级别。

相似,但实际上非常不同。

在您的情况下,并且在大多数锁定情况下,不需要 volatile 的原因是因为为了锁定而进行了 函数调用。即:

影响优化的正常函数调用:

external void library_func(); // from some external library

global int x;

int f()

   x = 2;
   library_func();
   return x; // x is reloaded because it may have changed

除非编译器可以检查 library_func() 并确定它没有触及 x,否则它将在返回时重新读取 x。这甚至没有 volatile。

线程:

int f(SomeObject & obj)

   int temp1;
   int temp2;
   int temp3;

   int temp1 = obj.x;

   lock(obj.mutex); // really should use RAII
      temp2 = obj.x;
      temp3 = obj.x;
   unlock(obj.mutex);

   return temp;

在为 temp1 读取 obj.x 后,编译器将重新读取 temp2 的 obj.x - 不是因为锁的魔力 - 而是因为不确定 lock() 是否修改了 obj。您可能可以设置编译器标志来积极优化(无别名等),因此不会重新读取 x,但是您的一堆代码可能会开始失败。

对于 temp3,编译器(希望)不会重新读取 obj.x。 如果由于某种原因 obj.x 可能在 temp2 和 temp3 之间发生变化,那么您将使用 volatile(并且您的锁定将被破坏/无用)。

最后,如果你的 lock()/unlock() 函数被内联了,也许编译器可以评估代码并看到 obj.x 没有改变。但我在这里保证两件事之一: - 内联代码最终会调用一些操作系统级别的锁定函数(从而阻止评估)或 - 您调用一些编译器将识别的 asm 内存屏障指令(即包装在像 __InterlockedCompareExchange 这样的内联函数中),从而避免重新排序。

编辑:附注我忘了提到 - 对于 pthreads 的东西,一些编译器被标记为“POSIX compliant”,这意味着它们将识别 pthread_ 函数并且不会围绕它们进行糟糕的优化。即,即使 C++ 标准还没有提到线程,那些编译器会(至少是最低限度地)。

所以,简短的回答

你不需要 volatile。

【讨论】:

【参考方案2】:

来自 Herb Sutter 的文章“使用关键部分(最好是锁)来消除竞争”(http://www.ddj.com/cpp/201804238):

因此,为了使重新排序转换有效,它必须通过遵守临界区的一个关键规则来尊重程序的临界区:代码不能移出临界区。 (代码可以移入。)我们通过要求任何关键部分的开头和结尾都使用对称的单向栅栏语义来强制执行这条黄金法则,如图 1 中的箭头所示:

进入临界区是获取操作,或隐式获取栅栏:代码永远不能向上越过栅栏,即从栅栏后的原始位置移动到栅栏前执行。但是,按源代码顺序出现在栅栏之前的代码可以愉快地向下越过栅栏以便稍后执行。 退出临界区是释放操作,或隐式释放栅栏:这只是代码不能向下跨越栅栏,只能向上跨越栅栏的相反要求。它保证看到最终发布写入的任何其他线程也将看到它之前的所有写入。

因此,当进入和退出临界区时,编译器为目标平台生成正确的代码(术语临界区是在一般意义上使用的,不一定是在 Win32 意义上受 @987654322 保护的东西@结构 - 关键部分可以被其他同步对象保护)必须遵循正确的获取和释放语义。因此,您不必将共享变量标记为 volatile,只要它们仅在受保护的临界区中访问即可。

【讨论】:

【参考方案3】:

volatile 用于通知优化器始终加载位置的当前值,而不是将其加载到寄存器中并假设它不会改变。这在处理双端口内存位置或可以从线程外部源实时更新的位置时最有价值。

互斥锁是一种运行时操作系统机制,编译器对此一无所知——因此优化器不会考虑到这一点。它将阻止多个线程同时访问计数器,但即使互斥锁有效,这些计数器的值仍会发生变化。

因此,您将变量标记为 volatile 是因为它们可以在外部进行修改,而不是因为它们位于互斥锁内部。

让它们不稳定。

【讨论】:

【参考方案4】:

虽然这可能取决于您使用的线程库,但我的理解是任何体面的库都需要使用volatile

在 Pthreads for example 中,使用互斥锁将确保您的数据正确提交到内存。

编辑:我在此认可 tony's answer 比我自己的更好。

【讨论】:

感谢 steve 提供的具体示例。但是还有其他线程库(OpenThread、Boost 等)我不确定是否这样做。 当今大多数库来处理它,因为volatile 不保证在多处理器系统上的正确性。 顺便说一句,我几乎可以肯定 Boost 会处理这个问题。 (但仔细检查文档)【参考方案5】:

您仍然需要“volatile”关键字。

互斥锁阻止计数器同时访问。

"volatile" 告诉编译器实际使用计数器 而不是将其缓存到 CPU 寄存器中(这不会 由并发线程更新)。

【讨论】:

以上是关于互斥锁的存在是不是有助于摆脱 volatile 关键字?的主要内容,如果未能解决你的问题,请参考以下文章

volatile关键字

你不看绝对血亏的Volatile全方位解析!你还看不明白?

Java并发编程原理与实战四十二:锁与volatile的内存语义

锁的内存语义

volatile与synchronized的区别

互斥锁的发生顺序是不是与询问的顺序相同?