Interlocked.CompareExchange 是否也是一个 volatile 变量?

3
以下示例来自MSDN
public class ThreadSafe
{
    // Field totalValue contains a running total that can be updated
    // by multiple threads. It must be protected from unsynchronized 
    // access.
    private float totalValue = 0.0F;

    // The Total property returns the running total.
    public float Total { get { return totalValue; }}

    // AddToTotal safely adds a value to the running total.
    public float AddToTotal(float addend)
    {
        float initialValue, computedValue;
        do
        {
            // Save the current running total in a local variable.
            initialValue = totalValue;

            // Add the new value to the running total.
            computedValue = initialValue + addend;

            // CompareExchange compares totalValue to initialValue. If
            // they are not equal, then another thread has updated the
            // running total since this loop started. CompareExchange
            // does not update totalValue. CompareExchange returns the
            // contents of totalValue, which do not equal initialValue,
            // so the loop executes again.
        }
        while (initialValue != Interlocked.CompareExchange(ref totalValue, 
        computedValue, initialValue));
        // If no other thread updated the running total, then 
        // totalValue and initialValue are equal when CompareExchange
        // compares them, and computedValue is stored in totalValue.
        // CompareExchange returns the value that was in totalValue
        // before the update, which is equal to initialValue, so the 
        // loop ends.

        // The function returns computedValue, not totalValue, because
        // totalValue could be changed by another thread between
        // the time the loop ends and the function returns.
        return computedValue;
    }
}

应该将totalValue声明为volatile以获得最新的值吗?如果从CPU缓存获取了一个脏值,那么对Interlocked.CompareExchange的调用应该处理获取最新的值并导致循环再次尝试。使用volatile关键字可能会节省一个不必要的循环吗?

我想即使没有 volatile 关键字也不是绝对必要的,因为该方法有重载,可以接受不支持volatile关键字的数据类型,比如long。

3个回答

5
不,volatile 对这个问题不会有任何帮助,也绝对不是出于这个原因。它只会给第一次读取赋予“获取”语义,而不是有效的松散语义,但无论哪种方式都将编译为运行一个加载指令的类似汇编的代码。
如果您从 CPU 缓存中获取了一个脏值
CPU 缓存是协同的,所以从 CPU 缓存中读取的任何内容都是当前全球同意的该行的值。 "脏" 只是意味着它与 DRAM 内容不匹配,并且如果/当被驱逐时必须写回。 一个加载值也可以从存储缓冲器转发,对于此线程最近存储的尚未全局可见的值,但这没关系,互锁方法是完整屏障,会等待存储缓冲器排空。
如果您的意思是陈旧的,则不可能,缓存一致性协议如 MESI 防止了这种情况。 这就是为什么像 CAS 这样的互锁事物如果缓存行已经由此核心拥有(MESI 修改或独占状态)并不会特别慢的原因。请参阅有关 Java volatile 的文章,例如: Myths Programmers Believe about CPU Caches,我认为这类似于 C# volatile。
C++11 答案 也解释了一些关于缓存一致性和汇编的内容。(请注意,C++11 中的volatile与 C# 显着不同,并且不意味着任何线程安全或排序,但仍然意味着 asm 必须进行加载或存储,而不是优化成寄存器。)
在非 x86 系统上,在尝试 CAS 之前运行初始读取后的额外屏障指令(以赋予那些获取语义)只会减慢事情。(在包括 x86 和 x86-64 的 x86 上,易失性读取将编译为与普通读取相同的汇编,除了它防止编译时重新排序。)
如果当前线程仅通过非互锁的 = 分配写入了某些内容,则无法将易失性读取优化为仅使用寄存器中的值。那也没什么帮助;如果我们刚才存储了一些东西并且在寄存器中记住了我们存储的内容,那么从存储缓冲区进行存储转发的加载在道德上等同于只使用寄存器值。
大多数无锁原子操作的好用例是当争用较低时,因此通常情况下可以成功而无需硬件长时间等待访问/拥有缓存行。 因此,您希望尽可能快地处理无争议的情况。 即使在高度争用的情况下也要避免使用volatile ,我认为在这种情况下也不会有任何收益。
如果你曾经做过任何简单的存储操作(使用 =,而不是交错的 RMW),volatile 对这些操作也会产生影响。如果 C# 的 volatile 语义类似于 C++ 的 memory_order_seq_cst,那么这可能意味着需要等待存储缓冲区排空,才能运行此线程中后续的内存操作。在这种情况下,如果你不需要与其他加载/存储相关的有序性,那么涉及存储操作的代码将会变得非常缓慢。如果在进行此 CAS 代码之前执行了这样的存储操作,那么是的,你必须等待直到该存储操作(以及所有先前的存储操作)在全局范围内可见,才能尝试重新加载它。 这意味着 CPU 拥有该行,那么接下来就不太可能需要自旋来重新加载和进行 CAS,但我认为通过 Interlocked CAS 的全屏障所获得的行为将会类似。

2

通过研究ImmutableInterlocked.Update方法的源代码,您可以获得一些见解:

/// <summary>
/// Mutates a value in-place with optimistic locking transaction semantics
/// via a specified transformation function.
/// The transformation is retried as many times as necessary to win the
/// optimistic locking race.
/// </summary>
public static bool Update<T>(ref T location, Func<T, T> transformer)
    where T : class
{
    Requires.NotNull(transformer, "transformer");

    bool successful;
    T oldValue = Volatile.Read(ref location);
    do
    {
        T newValue = transformer(oldValue);
        if (ReferenceEquals(oldValue, newValue))
        {
            // No change was actually required.
            return false;
        }

        T interlockedResult = Interlocked.CompareExchange(ref location,
            newValue, oldValue);
        successful = ReferenceEquals(oldValue, interlockedResult);
        oldValue = interlockedResult; // we already have a volatile read
            // that we can reuse for the next loop
    }
    while (!successful);
    return true;
}

你可以看到,该方法在 location 参数上进行了一个 volatile 读取。我认为有两个原因:
  1. 该方法通过避免 Interlocked.CompareExchange 操作来添加一个小的变化,以防新值恰好与已存储值相同。
  2. transformer 委托具有未知的计算复杂度,因此在可能过期的值上调用它可能比初始 Volatile.Read 的成本更高。

Volatile.Read 如何减少在尝试 CAS 时数值过期的可能性?我认为问题的前提是错误的,正如我在我的回答中指出的那样。 - Peter Cordes
我怀疑在这里使用Volatile.Read可以确保transformer仅在实际可见的值(至少在本地)上调用,而不是“撕裂”读取(如果它是宽类型)。 (例如,如果转换可以对超出范围的值执行某些错误操作)。您关于跳过CAS的原因(1)当然证明了这一点;如果伪造的值转换为自身,则永远无法达到CAS。也许还有一些有用的效果我没有考虑到,但我认为您的原因(2)没有意义。 - Peter Cordes
@PeterCordes,根据我对内存工作原理的浅显和幼稚理解,(2)是有意义的。看起来你对这个话题有更深入的了解。 - Theodor Zoulias
CPU缓存是一致的。普通读取可以从寄存器中获取值,根本不需要从内存中读取,因此可能会有点过时。但只有在这个变量被一个非易失性写入存储到代码中时,才会出现这种情况,否则编译器不会认为它仍然知道该值,并会发出加载指令。(与Volatile.Read相同,但(在非x86上)没有障碍。) - Peter Cordes
只有当代码足够慢以至于存储器缓冲区中的值可能已经被覆盖,但又足够小以至于该值仍然可以在寄存器中保留(例如,一个慢速函数调用,将该值保存在调用保留寄存器中)时,才需要考虑这种情况。但是,如果该值是共享的,你不太可能对其进行非易失性写入操作,因此整个情况是不太可能发生的。普通读取(编译为汇编加载)或易失性读取将看到相同的值,但普通读取可以避免障碍。 - Peter Cordes

1

由于Interlocked.CompareExchange插入内存屏障,所以无关紧要。

initialValue = totalValue;

此时 totalValue 可以是任何值。可能是缓存中的旧值,也可能是刚替换的新值,谁知道呢。虽然使用 volatile 可以防止读取缓存的值,但在读取后该值可能会变得陈旧,因此 volatile 并不能解决任何问题。
Interlocked.CompareExchange(ref totalValue, computedValue, initialValue)

此时,我们有内存屏障来确保totalValue是最新的。如果它等于initialValue,那么我们也知道在开始计算时initialValue不会过期。如果它不相等,则重试,由于我们已经发出了内存屏障,因此我们不会冒着在下一次迭代中获得相同过期值的风险。
编辑: 我认为很难出现任何性能差异。如果没有争用,那么该值过期的可能性很小。如果存在高度争用,则需要循环的时间将占主导地位。

缓存中的旧值-并不存在这样的事情。请查看我的答案(当您发布您的回答时,我已经快要完成我的答案了:P),以及https://software.rajivprab.com/2018/04/29/myths-programmers-believe-about-cpu-caches/. 另外,我认为您没有抓住重点。这个问题是关于性能,而不是正确性。 - Peter Cordes
据我所知,.Net内存模型中没有任何保证缓存一致性的内容。该链接文章讨论了x86/x64处理器,但这与本文无关。 - JonasH
OP询问了性能问题,因此可以安全地假设他们正在使用一个存在.NET实现的真实商用CPU。因此,他们拥有一致的数据缓存。(对于所有非x86系统也是如此,至少那些真正运行多个线程跨不同核心的实际操作系统。还有一些混合ARM板,带有微控制器+DSP共享内存但不共享缓存,但操作系统不会在微控制器+DSP核心上运行同一进程的线程,这正是其中之一的原因。) - Peter Cordes
你的回答中所指出的关键正确点是,在读取和CAS之间存在值变得陈旧的时间。特别是如果读取只获取“共享”状态下的行(因此CAS无法完成,直到它等待MESI独占所有权,即RFO读取所有权),那么其他核心的存储器可见性就有很多时间。是的,在加载后进行获取屏障也不会使情况变得更好;没有理由期望它会减少CAS重试的机会。 - Peter Cordes

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