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

Cha*_*ang 6 c++ concurrency multithreading atomic memory-barriers

以下摘自Concurrent Programming on windows,第10章第528~529页,一个c++模板Double check实现

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;
}
Run Code Online (Sandbox Code Playgroud)

正如作者所说:

在实例化对象之后,但在 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 处理器都不需要栅栏”,这是因为这些平台只允许存储加载重新排序。

还有一个问题,编译是否理解调用 Enter/Leave 临界区的语义?如果没有,那么它可能会按照以下答案进行优化,这将导致不良行为。

谢谢

Arn*_*rtz 2

tl;dr:
工厂调用很可能采取几个步骤,这些步骤可以在分配给 后移动m_pValue。该表达式!m_pValue将在工厂调用完成之前返回 false,从而在第二个线程中给出不完整的返回值。

解释:

编译可能会省略对 pValue 的写入,但切勿优化以在函数调用之前移动赋值,否则会违反程序语义。

不必要。将 T 视为int*,工厂方法创建一个新的 int 并将其初始化为 42。

int* pValue = new int(42);
m_pValue = pValue;         
//m_pValue now points to anewly allocated int with value 42.
Run Code Online (Sandbox Code Playgroud)

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

int* pTmp = new int;
*pTmp = 42;
int* pValue = *pTmp;
Run Code Online (Sandbox Code Playgroud)

在顺序程序中,如果某些命令移到其他命令之后,语义不会改变。特别是,赋值可以在内存分配和第一次访问之间自由移动,即第一次取消引用其中一个指针,包括在 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.
Run Code Online (Sandbox Code Playgroud)

编译器可能会这样做来优化大部分临时指针:

m_pValue = new int;  
*m_pValue = 42;
//m_pValue now points to a newly allocated int with value 42.
Run Code Online (Sandbox Code Playgroud)

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

我相信 LeaveCriticalSection 有隐式硬件围栏。因此,在分配给 m_pValue 之前的任何写入都将被同步。

不会。栅栏位于 m_pValue 赋值之后,但编译器仍然可以在栅栏和 m_pValue 之间移动整数赋值:

m_pValue = new int;  
*m_pValue = 42;
LeaveCriticalSection();
Run Code Online (Sandbox Code Playgroud)

但这已经太晚了,因为 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();  |
Run Code Online (Sandbox Code Playgroud)

  • 我对硬件重新排序的看法是错误的。http://msdn.microsoft.com/en-us/library/f20w0x5e(v=vs.100).aspx 清楚地表明 _ReadBarrier 仅阻止编译器重新排序,而不阻止硬件重新排序。通常,由于这个原因,仅编译器的屏障是无用的,除非涉及同一线程上的线程和信号处理程序之间的竞争。因此,原始示例实际上在具有弱有序内存一致性的硬件(例如 Itanium 或 Arm)上被破坏了。 (2认同)