互斥锁的存在有助于消除volatile关键字吗?

10

我有一个多读写锁类,它维护了读、写以及待处理的读取和待处理的写入计数器。一个互斥量用于防止多个线程同时访问它们。

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

或者编译器是否考虑到这些计数器被互斥量保护。

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

谢谢, -Jay。


当然,你需要使用volatile来避免将值放入寄存器。但是我不确定内存栅栏。这取决于互斥锁的实现。如果使用xchg或cas实现自旋锁,则需要内存栅栏。 - Konstantin Burlachenko
5个回答

17

这里有两个基本上无关的事情,它们经常被混淆。

  • volatile
  • 线程、锁、内存屏障等

volatile用于告诉编译器从内存而不是寄存器读取变量。同时不要重新排列代码。一般来说,不进行优化或采取“捷径”。

内存屏障(由互斥量、锁等提供)是为了防止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 - 这不是因为锁的魔力,而是因为它不确定 lock() 是否修改了 obj。您可能可以设置编译器标志来进行积极优化(no-alias等),从而不会重新读取 x,但是那样你的一堆代码可能会开始失败。
对于temp3,编译器(希望如此)不会重新读取 obj.x。如果由于某种原因 obj.x 可能在 temp2 和 temp3 之间更改,则应使用 volatile(并且您的锁定将被破坏/无效)。
最后,如果您的 lock()/unlock() 函数以某种方式内联,也许编译器可以评估代码并发现 obj.x 不会被更改。但我保证以下两点: - 内联代码最终会调用一些 OS 级别的锁定函数(从而防止评估)或 - 您调用了一些包装在内联函数中的 asm 内存屏障指令(例如 __InterlockedCompareExchange),您的编译器将识别并因此避免重排。
编辑:P.S. 我忘了提到 - 对于 pthreads 的东西,某些编译器被标记为“POSIX 兼容”,这意味着它们将识别 pthread_ 函数并且不会在它们周围进行错误的优化。即使 C++ 标准尚未提及线程,但这些编译器也会(至少最低限度)。
简而言之,您不需要使用 volatile。

14

根据Herb Sutter所写的文章"使用临界区(优先使用锁)消除竞争"(http://www.ddj.com/cpp/201804238):

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

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

因此,为了让编译器为目标平台生成正确的代码,在进入和退出临界区时(临界区术语在这里是通用意义上的,不一定是由CRITICAL_SECTION结构体保护的Windows特定概念),必须遵循正确的获取和释放语义。所以只要这些共享变量仅在受保护的临界区内访问,就不必将它们标记为易失性变量。


5

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

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

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

保持它们的volatile属性。


4
尽管这可能取决于您使用的线程库,但我理解任何好的库都不会要求使用volatile。在Pthreads中,例如,使用互斥锁将确保您的数据正确提交到内存中。编辑:我在此认可托尼的答案比我的更好。

感谢Steve提供的具体示例。但是还有其他线程库(OpenThread、Boost等),我不确定它们是否能够实现这个功能。 - Jay D
1
大多数库今天都必须处理它,因为volatile在多处理器系统上不能保证正确性。 - Steve S
顺便说一句,我几乎可以确定Boost会处理这个问题。(但是请再检查一下文档) - Steve S

3

您仍然需要 "volatile" 关键字。

互斥锁可以防止计数器被并发访问。

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


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