编译器障碍的目的是什么?

8
以下内容摘自《Windows并发编程》,第10章第528~529页,一份C++模板双重检查实现。
T getValue(){
    if (!m_pValue){
        EnterCriticalSection(&m_crst);
        if (! m_pValue){
            T pValue = m_pFactory();
            _WriteBarrier();
            m_pValue = pValue;                  
        }
        LeaveCriticalSection(&m_crst);
    }
      _ReadBarrier();
  return m_pValue;
}

正如作者所述:
在实例化对象之后,在将指针写入m_pValue字段之前,会发现一个_WriteBarrier。这是为了确保在对象的初始化中的写操作永远不会延迟到对m_pValue本身的写操作之后。
由于_WriteBarrier是编译屏障,如果编译器知道LeaveCriticalSection的语义,我认为它并没有什么用处。编译器可能会省略写入pValue的过程,但永远不会进行这样的优化:将赋值操作移动到函数调用之前,否则就会违反程序语义。我相信LeaveCriticalSection有隐式硬件栅栏。因此,在分配给m_pValue之前进行任何写入都将被同步。
另一方面,如果编译器不知道LeaveCriticalSection的语义,那么在所有平台上都需要_WriteBarrier,以防止编译器将赋值操作移出临界区。
至于_ReadBarrier,作者说:类似地,在返回m_value之前,我们需要一个_ReadBarrier,以便在调用getValue之后不会重新排序加载操作。
首先,如果此函数包含在库中,且没有源代码可用,那么编译器如何知道是否存在编译屏障?
其次,如果需要,它将被放置在错误的位置。我认为我们需要在EnterCriticalSection之后正确放置它以表示获取栅栏。与我上面写的类似,这取决于编译器是否理解EnterCriticalSection的语义。
作者还说:
然而,我也要指出,在X86、Intel64和AMD64处理器上都不需要任何栅栏。遗憾的是,像IA64这样的弱处理器使事情变得复杂了。
正如我上面分析的,如果我们在某些平台上需要这些屏障,那么我们在所有平台上都需要它们,因为这些屏障是编译屏障,只是确保编译器可以进行正确的优化,以防它们不理解某些函数的语义。
请纠正我如果我错了。
另一个问题是,是否有参考资料可以指出msvc和gcc了解哪些函数的同步语义?

更新 1:根据答案(m_pValue将在临界区外被访问),并从这里运行示例代码,我认为:

  1. 我认为作者在这里所指的是硬件屏障而不是编译屏障,请参见来自MSDN的以下引用。
  2. 我相信硬件屏障也具有隐式的编译屏障(禁用编译优化),但反之则不然(参见这里,使用CPU屏障不会看到任何重新排序,但反之则不然)

屏障不是屏障。应该注意的是,屏障会影响缓存中的所有内容。屏障只影响单个缓存行。

除非绝对必要,否则不应添加屏障。要使用屏障,可以选择一个_Interlocked内置函数之一。

作为作者所写:“在X86 Intel64和AMD64处理器上都不需要栅栏”,这是因为这些平台只允许存储-加载重排。
仍然有一个问题,编译器是否理解对进入/离开临界区的调用语义?如果不理解,则可能像以下答案一样进行优化,从而导致不良行为。
谢谢。

如果它在库中,那么它已经被编译了。库是链接进来的。 - Philip Stuyck
2个回答

2

tl;dr:
这个工厂调用可能会经历几个步骤,这些步骤可能会在分配给m_pValue之后移动。在工厂调用完成之前,表达式!m_pValue将返回false,在第二个线程中提供不完整的返回值。

Explanation:

编译器可能会忽略对pValue的写入,但永远不会优化使赋值在函数调用之前移动,否则它将违反程序语义。

不一定。考虑T为int*,并且工厂方法创建一个新的int并将其初始化为42。

int* pValue = new int(42);
m_pValue = pValue;         
//m_pValue now points to anewly allocated int with value 42.

对于编译器来说,new表达式包含多个步骤,可以在另一个步骤之前移动。它的语义是分配、初始化,然后将地址赋给pValue

int* pTmp = new int;
*pTmp = 42;
int* pValue = *pTmp;

在顺序程序中,如果将某些命令移动到其他命令之后,语义不会改变。特别是赋值可以在内存分配和第一次访问(即其中一个指针的第一次解引用)之间自由移动,包括在new表达式之后分配指针值之后进行移动:
int* pTmp = new int;
int* pValue = *pTmp;
m_pValue = pValue;  
*pTmp = 42;
//m_pValue now points to a newly allocated int with value 42.

编译器可能会这样做,以优化大部分临时指针的使用:
m_pValue = new int;  
*m_pValue = 42;
//m_pValue now points to a newly allocated int with value 42.

这是一个 顺序 程序的正确语义。

我认为 LeaveCriticalSection 具有隐式的硬件栅栏。因此,在分配给 m_pValue 之前进行的任何写入都将被同步。

不是。栅栏在分配给 m_pValue 后,但编译器仍然可以在栅栏之间移动整数赋值:

m_pValue = new int;  
*m_pValue = 42;
LeaveCriticalSection();

现在已经太晚了,因为Thread2不需要进入CriticalSection:

Thread 1:                | Thread 2:
                         |
m_pValue = new int;      | 
                         | if (!m_pValue){     //already false
                         | }
                         | return m_pValue;
                         | /*use *m_pValue */
*m_pValue = 42;          |
LeaveCriticalSection();  |

对,这就是我错过的。那么读屏障的目的是什么? - Chang
说实话,我不确定。 我没有看到调用该函数的线程的挂起内存读取。 (请参阅http://msdn.microsoft.com/en-us/library/z055s48f%28v=vs.80%29.aspx) - Arne Mertz
读取屏障的作用是防止硬件重排序。考虑C++代码getValue()->f。在弱序硬件中,有可能在调用getValue()之前就对f进行了猜测性加载,如果getValue()走了它的隐式“else”路径,那么如果没有读取屏障,硬件就没有理由重新加载f。 - Arch D. Robison
@ArchD.Robison,听起来很合理。但是_ReadBarrier是编译屏障,它如何防止处理器乱序执行?编译屏障是否也意味着隐式硬件栅栏? - Chang
2
我对硬件重新排序有误解。http://msdn.microsoft.com/en-us/library/f20w0x5e(v=vs.100).aspx清楚地表明_ReadBarrier仅可防止编译器重新排序,而不能防止硬件重新排序。一般来说,仅编译器的屏障因此是无用的,除了在涉及线程和信号处理程序之间的竞争情况下。因此,在具有弱顺序内存一致性的硬件上(例如Itanium或Arm),原始示例实际上已经损坏。 - Arch D. Robison

2

_ReadBarrier和_WriteBarrier

Joe Duffy认为,_ReadBarrier和_WriteBarrier编译器内置函数都是编译器和处理器级别的栅栏。在《Windows并发编程》第515页中,他写道:

一组编译器内置函数可以在VC++中强制实现编译器和处理器级别的栅栏:_ReadWriteBarrier发出完整的栅栏,_ReadBarrier发出只读栅栏,_WriteBarrier发出只写栅栏。

作者依靠_ReadBarrier和_WriteBarrier编译器内置函数来防止编译器和硬件重排序。

MSDN文档对于_ReadWriteBarrier编译器内置函数并不支持这种假设,即编译器内置函数会影响硬件级别。Visual Studio 2010和Visual Studio 2008的MSDN文档明确否认编译器内置函数适用于硬件级别:

_ReadBarrier、_WriteBarrier和_ReadWriteBarrier编译器内置函数仅防止编译器重排序。要防止CPU对读写操作进行重排序,请使用MemoryBarrier宏。

Visual Studio 2005和Visual Studio .NET 2003的MSDN文档没有这样的说明。它没有说明内部函数是否适用于硬件级别。
如果_ReadBarrier和_WriteBarrier确实没有强制执行硬件屏障,那么代码是错误的。
关于术语“栅栏”
Joe Duffy在他的书中使用术语“fence”来表示硬件和内存栅栏。在第511页上,他写道:
“栅栏通常也被称为障碍。英特尔似乎更喜欢“栅栏”术语,而AMD则更喜欢“障碍”。我也更喜欢“栅栏”,所以在本书中我使用了这个术语。”
硬件栅栏
我相信硬件栅栏也具有隐式编译栅栏(禁用编译优化)。
{{link1:同步和多处理器问题}}文章确认硬件栅栏也会影响编译器:
“这些指令(内存栅栏)还确保编译器禁用任何可能重新排序跨越栅栏的内存操作的优化。”
然而,MemoryBarrier 宏 的 MSDN 文档表明编译器重排序并非总是被阻止:

创建硬件内存屏障(fence),防止 CPU 重新排序读写操作。它也可能防止编译器重新排序读写操作。

实际上,如果编译器可以在硬件屏障周围重新排序内存操作,我不理解如何使用硬件屏障。我们将无法确定屏障是否处于正确的位置。

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