【问题标题】:Should Interlocked.CompareExchange also a volatile variable?Interlocked.CompareExchange 也应该是一个 volatile 变量吗?
【发布时间】:2021-06-03 21:49:11
【问题描述】:

以下示例来自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 关键字并不是 100% 必要的,因为该方法具有采用不支持 volatile 关键字的 long 等数据类型的重载。

【问题讨论】:

    标签: c# cpu-architecture volatile lock-free compare-and-swap


    【解决方案1】:

    您可以通过研究ImmutableInterlocked.Update 方法的source code 获得一些见解:

    /// <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 参数进行易失性读取。我认为有两个原因:

    1. 这个方法有点扭曲,因为它避免了Interlocked.CompareExchange 操作,以防新值恰好与已经存储的值相同。
    2. transformer 委托具有未知的计算复杂性,因此在可能陈旧的值上调用它可能比初始 Volatile.Read 的成本高得多。

    【讨论】:

    • Volatile.Read 如何在您尝试 CAS 时降低该值已过时的可能性?正如我在回答中指出的那样,我认为这个问题的前提是错误的。
    • 我怀疑 Volatile.Read 在这里很有用,可以确保 transformer 仅在某个点上实际可见(至少在本地)的值上调用,而不是“撕裂”读取,如果它是一种广泛的类型。 (例如,如果转换可能对超出范围的值造成不良影响)。您关于跳过 CAS 的原因(1)当然证明了这一点;如果虚假值转换为自身,您将永远无法到达 CAS。也许还有一些我没有想到的有用效果,但我认为你的理由(2)没有道理。
    • @PeterCordes 根据我对内存工作原理的肤浅和幼稚的理解,(2) 是有道理的。您似乎对这个主题有更深入的了解。 ?
    • CPU 缓存是一致的。正常读取可以从寄存器中获取值,而不是从内存中获取值,因此我猜可能会有些过时。但只有当它内联到之前存储到这个变量的代码中时,才会使用非易失性写入,否则编译器不会认为它仍然知道该值并会发出加载指令。 (与 Volatile.Read 相同,但(在非 x86 上)之后没有障碍。)
    • 并且只有当代码足够慢以至于存储有一些有意义的机会离开存储缓冲区然后被覆盖时,但代码足够少,值仍然可以在寄存器中. (例如,慢速函数调用,将此值保存在调用保留寄存器中)。但是,如果该值是共享的,则您不太可能对其进行非易失性 write,因此整个情况不太可能。普通读取(编译为 asm 加载)或易失性读取将看到相同的值,但普通可以避免障碍。
    【解决方案2】:

    没关系,因为Interlocked.CompareExchange 插入了内存屏障。

    initialValue = totalValue;
    

    此时,totalValue 可以是任何值。缓存中的陈旧值,刚刚被替换,谁知道。虽然volatile 会阻止读取缓存值,但该值可能会在读取后立即失效,因此 volatile 无法解决任何问题。

    Interlocked.CompareExchange(ref totalValue, computedValue, initialValue)
    

    此时,我们有确保totalValue 是最新的内存屏障。如果它等于initialValue,那么我们也知道在我们开始计算时初始值不是陈旧的。如果不相等,我们再试一次,因为我们已经发出了内存屏障,所以我们不会冒险在下一次迭代中获得相同的陈旧值。

    编辑: 我发现不太可能有任何性能差异。如果没有争用,则价值过时的理由很少。如果存在高争用时间,则需要循环来控制时间。

    【讨论】:

    • 缓存中的旧值 - 没有这样的东西。请参阅我的答案(当您发布您的答案时我几乎完成了:P)和software.rajivprab.com/2018/04/29/…。另外,我认为你没有抓住重点。这个问题是关于性能,而不是正确性。
    • @peter cordes 据我所知,.Net 内存模型中没有任何东西可以保证缓存是一致的。链接的文章讨论了 x86/x64 处理器,但这无关紧要。
    • OP 询问了 性能,因此可以安全地假设他们正在使用存在 .Net 实现的真实商业 CPU。因此,它们具有一致的数据缓存。 (对于所有非 x86 系统也是如此,至少在真正的操作系统在不同的内核上运行多个线程的情况下。有一些混合 ARM 板与微控制器 + DSP 共享内存但不连贯缓存,但操作系统不会在微控制器 + DSP 内核上运行同一进程的线程,正是出于这个原因。)
    • 您的答案所提出的关键正确点是,在读取和 CAS 之间有时间让值变得陈旧。特别是如果只读使行处于“共享”状态(因此 CAS 在等待 MESI 独占所有权之前无法完成,即 RFO 读取所有权),则有很多时间让来自另一个核心的存储变得可见.是的,加载后的获取障碍不会使情况变得更好;没有理由期望它会降低 CAS 重试的机会。
    【解决方案3】:

    不,volatile 根本没有帮助,当然也不是因为这个原因。它只会给第一次读取“获取”语义而不是有效地放松,但任何一种方式都会编译为运行加载指令的类似 asm。

    如果您从 CPU 缓存中获取脏值

    CPU 缓存是连贯的,因此您从 CPU 缓存中读取的任何内容都是该行当前全局认可的值。 “脏”只是意味着它与 DRAM 内容不匹配,并且如果/当被驱逐时必须被写回。加载值也可以从存储缓冲区转发,对于该线程最近存储的尚未全局可见的值,但这很好,互锁方法是完全障碍,导致等待存储缓冲区也耗尽。

    如果您的意思是陈旧,那么不,这是不可能的,像 MESI 这样的缓存一致性协议可以防止这种情况。这就是为什么如果高速缓存行已由该内核拥有(MESI 已修改或独占状态),则 CAS 之类的互锁操作不会非常慢。请参阅Myths Programmers Believe about CPU Caches,其中谈到了 Java volatile,我认为它类似于 C# volatile。

    This C++11 answer 还解释了一些关于缓存一致性和 asm 的内容。 (请注意,C++11 volatile 与 C# 显着不同,并不意味着任何线程安全或排序,但仍然意味着 asm 必须执行加载或存储,而不是优化到寄存器中。)


    在非 x86 上,在您甚至 尝试 CAS 之前在初始读取之后运行额外的屏障指令(以赋予那些获取语义)只会减慢速度。 (在包括 x86-64 在内的 x86 上,易失性读取编译为与普通读取相同的 asm,但它会阻止编译时重新排序)。

    如果当前线程只是通过非互锁的= 赋值写了一些东西,则不能将易失性读取优化为仅使用寄存器中的值。这也无济于事;如果我们只是存储了一些东西并在寄存器中记住了我们存储的内容,那么从存储缓冲区进行存储转发的加载在道德上等同于仅使用寄存器值。

    大多数无锁原子的良好用例是在争用较低的情况下,因此通常事情可以成功,而无需硬件等待很长时间来访问/拥有缓存行。因此,您希望尽可能快地制作无争议的案例。避免使用volatile,即使在竞争激烈的情况下可以从中受益,我认为无论如何都没有。

    如果你曾经做过任何简单的存储(使用= 分配,而不是互锁 RMW),volatile 也会对这些产生影响。如果 C# volatile 提供像 C++ memory_order_seq_cst 这样的语义,这可能意味着在此线程中的后续内存操作可以运行之前等待存储缓冲区耗尽。在 那种 的情况下,如果您不需要订购 wrt,那么您会大大减慢涉及商店的代码。其他负载/存储。如果您在此 CAS 代码之前进行了这样的存储,是的,您将等到该存储(以及所有以前的存储)在全球范围内可见才能尝试重新加载它。这将意味着 CPU 正在等待的重新加载 + CAS 很可能不必旋转,因为 CPU 将拥有该行的所有权,但我认为你会有效地从作为一部分的完整屏障中获得类似的行为一个联锁的 CAS。

    【讨论】:

      猜你喜欢
      • 1970-01-01
      • 2011-10-15
      • 1970-01-01
      • 1970-01-01
      • 2013-02-27
      • 1970-01-01
      • 2014-09-18
      • 1970-01-01
      • 1970-01-01
      相关资源
      最近更新 更多