这个 MSDN CompareExchange 示例如何不需要易失性读取?

Posted

技术标签:

【中文标题】这个 MSDN CompareExchange 示例如何不需要易失性读取?【英文标题】:How does this MSDN CompareExchange sample not need a volatile read? 【发布时间】:2011-09-26 14:33:08 【问题描述】:

我一直在寻找一个使用Interlocked 支持任意值递增的线程安全计数器实现,并直接从Interlocked.CompareExchange 文档中找到了这个示例(为简单起见稍作更改):

private int totalValue = 0;

public int AddToTotal(int addend)

    int initialValue, computedValue;
    do
    
        // How can we get away with not using a volatile read of totalValue here?
        // Shouldn't we use CompareExchange(ref TotalValue, 0, 0)
        // or Thread.VolatileRead
        // or declare totalValue to be volatile?           
        initialValue = totalValue;

        computedValue = initialValue + addend;

     while (initialValue != Interlocked.CompareExchange(
        ref totalValue, computedValue, initialValue));

    return computedValue;


 public int Total
 
    // This looks *really* dodgy too, but isn't 
    // the target of my question.
    get  return totalValue; 
 

我知道这段代码试图做什么,但我不确定在分配给添加到的临时变量时如何不使用共享变量的易失性读取。

initialValue 是否有可能在整个循环中保持一个陈旧的值,使函数永远不会返回?还是CompareExchange 中的内存屏障(?)消除了这种可能性?任何见解将不胜感激。

编辑:我应该澄清一下,我理解如果CompareExchange 导致totalValue后续读取在最后 CompareExchange 调用,那么这段代码就可以了。但这能保证吗?

【问题讨论】:

是的,这可能会在内存模型较弱的处理器上烧毁内核一段时间。没有说这是高效代码,只是说它是正确代码。保证退出循环,最终线程调度器有内存屏障。 @HansPassant 经过这么长时间,我得出的结论是代码在技术上并不正确。担心的是,由于引入了读取,CompareExchange 的最后一个参数可能包含不一致的值。我已经编辑了我的答案来解释。 【参考方案1】:

托管的Interlocked.CompareExchange 直接映射到Win32 API 中的InterlockedCompareExchange(还有一个64 bit version)。

正如您在函数签名中看到的,本机 API 要求目标是 volatile 的,尽管托管 API 不需要,但 Joe Duffy 在其出色的著作 Concurrent Programming on Windows 中推荐使用 volatile。

【讨论】:

值得注意的是,本机代码中的 volatile 和 C# 中的 volatile 具有相当不同的含义。【参考方案2】:

如果我们读取一个陈旧的值,那么 CompareExchange 将不会执行交换 - 我们基本上是在说,“只有当该值确实是我们计算的基础时才执行操作。”只要在 some 点我们得到正确的值,就可以了。如果我们一直读取相同的陈旧值,那将是一个问题,因此CompareExchange 从未 通过了检查,但我强烈怀疑CompareExchange 内存屏障意味着至少在经过一段时间之后在循环中,我们将读取一个最新的值。不过,最糟糕的情况可能是永远循环——重要的是我们不可能以不正确的方式更新变量。

(是的,我认为你是对的,Total 属性很狡猾。)

编辑:换句话说:

CompareExchange(ref totalValue, computedValue, initialValue)

意思是:“如果当前状态真的是initialValue,那么我的计算是有效的,你应该把它设置为computedValue。”

当前状态可能有错误至少有两个原因:

initialValue = totalValue; 分配使用了具有不同旧值的陈旧读取 totalValue那个分配之后发生了一些变化

我们根本不需要以不同的方式处理这些情况 - 所以只要在某个点我们将开始看到最新值,就可以进行“廉价”读取...而且我相信 CompareExchange 中涉及的内存屏障将确保当我们循环时,我们看到的陈旧值只会与之前的 CompareExchange 调用一样陈旧。

编辑:澄清一下,我认为样本是正确的当且仅当CompareExchange 构成相对于totalValue 的内存屏障。如果没有——如果我们继续循环时仍然可以读取totalValue 的任意旧值——那么代码确实被破坏了,并且可能永远不会终止。

【讨论】:

@Ani:但它们是联系在一起的。只有在 initialValue = totalValue; 分配“正确”并且之后该值没有更改时,CompareExchange 才会采取行动。将编辑... 我不确定CompareExchange 是否保证在所有平台上都生成内存屏障;它只是做它需要做的任何事情来获得最新的阅读。 Jon 的编辑所描述的行为当然是我在 x86 上所期望的,但我认为其他一些平台可能会以某种方式实现CompareExchange,但不会更新后续读取,在这种情况下无限循环是可能的。 @LukeH:同意。基本上,当且仅当我对CompareExchange 的假设正确时,代码才有效。 @Jon:所以你是说 Interlocked.CompareExchange 将“更新”后续“廉价”读取同一线程上的字段,直到(最坏的情况)这一点CompareExchange 调用?我们如何做出这样的假设? @Ani,我不知道你的问题的答案,但我认为 这个对话 会无限循环。 :-) 这就是我的建议:提出一系列具有您所关注的不良行为的读写操作。然后分析在给定 .NET 内存模型的情况下是否可以进行该读取和写入序列。更一般地说:如果您担心(并且您完全有理由担心低锁定代码!),请使用锁定。【参考方案3】:

编辑:

这么长时间后有人给了我一个赞成票,所以我重新阅读了问题和答案,发现了一个问题。

我要么不知道引入读物,要么没有想到它。假设 Interlocked.CompareExchange 没有引入任何障碍(因为 它没有在任何地方记录),编译器可以将您的 AddToTotal 方法转换为以下损坏的版本,其中最后两个参数是 @987654323 @ 可以看到不同的 totalValue 值!

public int AddToTotal(int addend)

    int initialValue;
    do
            
        initialValue = totalValue;
     while (initialValue != Interlocked.CompareExchange(
        ref totalValue, totalValue + addend, totalValue));

    return initialValue + addend;

因此,您可以使用Volatile.Read。在 x86 上,Volatile.Read 无论如何只是一个标准读取(它只是防止编译器重新排序),所以没有理由不这样做。那么编译器应该能做的最坏的事情是:

public int AddToTotal(int addend)

    int initialValue;
    do
    
        initialValue = Volatile.Read (ref totalValue);
     while (initialValue != Interlocked.CompareExchange(
        ref totalValue, initialValue + addend, initialValue));

    return initialValue + addend;

很遗憾,Eric Lippert once claimed volatile read doesn't guarantee protection against introduced reads。我真的希望他是错的,因为这意味着很多低锁代码几乎不可能在 C# 中正确编写。他本人确实在某处提到他不认为自己是低级同步方面的专家,所以我只是假设他的陈述是不正确的,并希望最好。


原答案:

与流行的误解相反,获取/释放语义并不能确保从共享内存中获取新值,它们只会影响具有获取/释放语义的其他内存操作的顺序.每个内存访问必须至少与上次获取读取一样新,并且最多与下一次发布写入一样陈旧。 (类似于内存屏障。)

在这段代码中,您只需要担心一个共享变量:totalValue。 CompareExchange 是一个原子 RMW 操作这一事实足以确保它所操作的变量将得到更新。这是因为原子 RMW 操作必须确保所有处理器都同意变量的最新值是什么。

关于你提到的其他Total属性,它是否正确取决于它的要求。几点:

int 保证是原子的,因此您将始终获得有效值(从这个意义上说,您显示的代码可以被视为“正确”,如果只有 some 有效,可能旧值是必需的) 如果在没有获取语义的情况下读取(Volatile.Read 或读取volatile int)意味着在它之后写入的所有内存操作实际上可能发生在之前(读取操作较旧的值并且写入在它们应该之前对其他处理器可见) 如果不使用原子 RMW 操作来读取(如 Interlocked.CompareExchange(ref x, 0, 0)),接收到的值可能不是某些其他处理器视为最新值的值 如果需要关于其他内存操作的最新值和排序,Interlocked.CompareExchange应该工作(底层 WinAPI 的InterlockedCompareExchange 使用完整的屏障,不太确定 C# 或 . Net 规范),但如果您想确定,您可以在读取后添加显式内存屏障

【讨论】:

关于在 Interlocked.CompareExchange 之后添加显式屏障的快速说明。几乎所有内存屏障对性能的影响都是处理器必须等待共享内存完成操作。因此,在另一个内存屏障之后(或之前)添加一个内存屏障应该会对性能产生较低的影响——第一个屏障已经完成了大部分工作,而第二个没有内存操作等待。

以上是关于这个 MSDN CompareExchange 示例如何不需要易失性读取?的主要内容,如果未能解决你的问题,请参考以下文章

Interlocked.CompareExchange 单线程等效代码

为啥 Interlocked.CompareExchange<T> 只支持引用类型?

Interlocked.CompareExchange 指令重新编码初始值

Interlocked.Exchange<T> 比 Interlocked.CompareExchange<T> 慢吗?

Winsock 编码问题

Java byte[] 转C# byte[]