我有一个多读写锁类,它维护了读、写以及待处理的读取和待处理的写入计数器。一个互斥量用于防止多个线程同时访问它们。
我的问题是:我们是否仍需将计数器声明为volatile,以便编译器在进行优化时不会出错。
或者编译器是否考虑到这些计数器被互斥量保护。
我知道互斥量是一种运行时机制,用于同步,而“volatile”关键字是一种编译时指示,告诉编译器在进行优化时要做正确的事情。
谢谢, -Jay。
我有一个多读写锁类,它维护了读、写以及待处理的读取和待处理的写入计数器。一个互斥量用于防止多个线程同时访问它们。
我的问题是:我们是否仍需将计数器声明为volatile,以便编译器在进行优化时不会出错。
或者编译器是否考虑到这些计数器被互斥量保护。
我知道互斥量是一种运行时机制,用于同步,而“volatile”关键字是一种编译时指示,告诉编译器在进行优化时要做正确的事情。
谢谢, -Jay。
这里有两个基本上无关的事情,它们经常被混淆。
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,但是那样你的一堆代码可能会开始失败。根据Herb Sutter所写的文章"使用临界区(优先使用锁)消除竞争"(http://www.ddj.com/cpp/201804238):
因此,对于重新排序转换要有效,它必须尊重程序的关键部分,并遵守临界部分的一个关键规则:代码不能移出临界部分。(始终可以将代码移入其中。)我们通过要求任何关键部分的开始和结束具有对称的单向栅栏语义来强制执行这个黄金法则,如图1中的箭头所示:
- 进入临界区是获取操作或隐式获取栅栏:代码永远不能越过栅栏向上移动,也就是说,从栅栏之后的原始位置移动到栅栏之前执行。但是,在源代码顺序中出现在栅栏之前的代码可以自由地越过栅栏向下执行。
- 退出临界区是释放操作或隐式释放栅栏:这只是相反的要求,即代码不能向下越过栅栏,只能向上。它保证任何看到最终释放写入的其他线程也会看到它之前的所有写入。
因此,为了让编译器为目标平台生成正确的代码,在进入和退出临界区时(临界区术语在这里是通用意义上的,不一定是由CRITICAL_SECTION
结构体保护的Windows特定概念),必须遵循正确的获取和释放语义。所以只要这些共享变量仅在受保护的临界区内访问,就不必将它们标记为易失性变量。
volatile用于告知优化器始终加载位置的当前值,而不是加载到寄存器并假定它不会改变。当使用双端口内存位置或可以实时从线程外部源更新的位置时,这是最有价值的。
互斥锁是运行时操作系统机制,编译器对此一无所知 - 因此优化器不会考虑它。它将防止多个线程同时访问计数器,但即使互斥锁有效,这些计数器的值仍然可能会发生变化。
因此,您将变量标记为volatile,因为它们可以在外部进行修改,而不是因为它们处于互斥锁保护中。
保持它们的volatile属性。
您仍然需要 "volatile" 关键字。
互斥锁可以防止计数器被并发访问。
"volatile" 告诉编译器实际使用计数器,而不是将其缓存到 CPU 寄存器中(这样不会被并发线程更新)。