6

以下摘自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;
}

正如作者所说:

_WriteBarrier 是在实例化对象之后但在 m_pValue 字段中写入指向它的指针之前找到的。这是确保对象初始化中的写入永远不会延迟超过对 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 栅栏不会看到任何重新排序,反之亦然)

Barrier 不是栅栏。应该注意的是,Barrier 会影响缓存中的所有内容。栅栏影响单个高速缓存行。

除非绝对必要,否则不应添加障碍。要使用栅栏,您可以选择 _Interlocked 内部函数之一。

正如作者所写:“ X86 Intel64 和 AMD64 处理器都不需要栅栏”,这是因为这些平台只允许存储加载重新排序。

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

谢谢

4

2 回答 2

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.

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

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

在顺序程序中,如果某些命令在其他命令之后移动,语义不会改变。特别是分配可以在内存分配和第一次访问之间自由移动,即第一次取消引用其中一个指针,包括在新表达式之后分配指针值之后:

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();  |
于 2013-06-07T07:01:53.820 回答
2

_ReadBarrier 和 _WriteBarrier

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

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

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

_ReadWriteBarrier 编译器内部函数的 MSDN 文档不支持编译器内部函数影响硬件级别的假设。Visual Studio 2010 和 Visual Studio 2008 的 MSDN 文档明确否认编译器内在函数适用于硬件级别:

_ReadBarrier、_WriteBarrier 和 _ReadWriteBarrier 编译器内在函数仅防止编译器重新排序。要防止 CPU 重新排序读取和写入操作,请使用 MemoryBarrier 宏。

Visual Studio 2005 和 Visual Studio .NET 2003 的 MSDN 文档没有这样的注释。它没有说明内在函数是否适用于硬件级别。

如果 _ReadBarrier 和 _WriteBarrier 确实不强制硬件围栏,则代码不正确。

关于“围栏”一词

Joe Duffy 在他的书中使用术语栅栏来表示硬件和内存栅栏。在第 511 页,他写道:

栅栏也常被称为屏障。英特尔似乎更喜欢“围栏”这个术语,而 AMD 更喜欢“屏障”。我也更喜欢“栅栏”,所以这就是我在本书中使用的。

五金围栏

我相信硬件围栏也有隐式编译障碍(禁用编译优化)

同步和多处理器问题文章确认硬件障碍也会影响编译器:

这些指令(内存屏障)还确保编译器禁用任何可能跨屏障重新排序内存操作的优化。

但是,MemoryBarrier 宏的 MSDN 文档建议编译器重新排序并不总是被阻止:

创建一个硬件内存屏障(栅栏),防止 CPU 重新排序读取和写入操作。它还可能阻止编译器重新排序读取和写入操作。

实际上,如果编译器可以围绕它重新排序内存操作,我不明白如何使用硬件围栏。我们不确定栅栏的位置是否正确。

于 2014-03-09T13:00:20.550 回答