9

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

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

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

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

问候,-杰。

4

5 回答 5

16

这里有两个基本上不相关的项目,总是很混乱。

  • 易挥发的
  • 线程、锁、内存屏障等。

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

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

类似,但实际上非常不同的东西。

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

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

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。这甚至没有易失性。

穿线:

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 这样的内联函数中)从而避免重新排序。

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

所以,简短的回答

你不需要易失性。

于 2009-11-05T17:41:14.900 回答
14

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

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

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

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

于 2009-10-23T22:52:43.363 回答
5

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

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

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

让它们不稳定。

于 2009-10-23T22:06:27.867 回答
4

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

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

编辑: 我在此赞同托尼的回答比我自己的要好。

于 2009-10-23T22:51:19.523 回答
3

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

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

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

于 2009-10-23T21:55:20.913 回答