Interlocked.Exchange和Volatile.Write有什么区别?

30

Interlocked.ExchangeVolatile.Write有什么区别?两种方法都可以更新某个变量的值。能否有人总结一下何时使用它们?

特别是我需要更新数组中的double项,并且我希望另一个线程看到最新的值。哪种方式更好?Interlocked.Exchange(ref arr[3], myValue)还是 Volatile.Write(ref arr[3], info);,其中arr声明为double?


实际例子,我像这样声明了一个double数组:

private double[] _cachedProduct;

在一个线程中,我这样更新它:

_cachedProduct[instrumentId] = calcValue;
//...
are.Set();

在另一个线程中,我按照以下方式读取了这个数组:

while(true)
{
    are.WaitOne();
    //...
    result += _cachedProduct[instrumentId];
    //...
}
对我来说,它现在能够良好地工作。但是为了确保“无论如何都能工作”,似乎我应该添加Volatile.Write或Interlocked.Exchange。因为双倍更新不能保证是原子的
在回答这个问题时,我想看到VolatileInterlocked类的详细比较。为什么我们需要2个类?哪一个以及何时使用?
另一个例子来自于在生产项目中实现锁定机制的代码。
private int _guard = 0;

public bool Acquire() => Interlocked.CompareExchange(ref _guard, 1, 0) == 0;

public void Release1() => Interlocked.Exchange(ref _guard, 0);
public void Release2() => Volatile.Write(ref _guard, 0);

如果这个API的用户调用Release1Release2方法,这是否有任何实际区别?


1
@TheodorZoulias:我不会期望有任何区别。硬件内存屏障是否可以使原子操作的可见性更快,同时提供必要的保证?解释了一个常见的误解,即内存屏障可能有助于处理器间延迟。(Interlocked.Exchange必须等待存储缓冲区排空才能变得可见,至少在x86上是这样。如果它可以编译为ARM,就像C++中的memory_order_relaxed交换一样,而不需要与周围代码进行排序(我会感到惊讶;我认为Interlocked意味着一个屏障),那么情况可能会有所不同。 - Peter Cordes
1
@PeterCordes 感谢您提供的链接,非常有启发性!关于 Interlocked 和 fences,在这个答案中,列出了 Interlocked 类方法作为机制,“通常被认为会产生隐式屏障”。在Joseph Albahari的在线书籍中也有同样的陈述:“以下方法隐式生成全屏障:[...]所有 Interlocked 类的方法。” 我确信我也在微软的文档中看到过这个描述。 - Theodor Zoulias
1
@TheodorZoulias:Interlocked 的唯一可能优势是它具有较弱的内存屏障,因此在某些机器上可能更便宜。但这似乎并不是事实。因此,对于纯写入操作,始终只使用 Volatile.Write;如果编译器实现其排序语义的最便宜方式是 x86 xchg,那就这样做。(如果它意味着 seq_cst 排序/一个完整的屏障,而不仅仅是释放语义)。但如果只是“释放”,那么在 x86 和 AArch64 上,它显然比 Interlocked.Exchange 更好,没有任何折衷。由于缺少读取,其他 ISA 上也可能如此。 - Peter Cordes
1
@TheodorZoulias:是的,我认为这是正确的;如果Interlocked.Exchange必须成为完整的屏障,除了可能是糟糕的实现选择外,写操作不可能更加昂贵。但即使是这样的情况也不太可能出现,因为它仅具有发布语义,而不是完整的屏障,例如C++ seq_cst以创建SC,如果加载也是Volatile.Read。我并不特别关心声誉,但如果这是人们想知道的问题,我想将其发布为答案可能会有用。在某些时候会这样做,也许一旦我发现Volatile.Write对于x86(-64)和AArch64实际编译的方式就好了。 - Peter Cordes
1
@TheodorZoulias:好的,我写了一些内容并发布了。如果你或其他人有兴趣检查Volatile.Write在x64或ARM64上的编译方式,那么仍然可以从一些研究中受益,并且当我更清醒时,也许需要校对一下是否留下了未完成的句子。:P - Peter Cordes
显示剩余10条评论
2个回答

11

Interlocked.Exchange使用一个处理器指令,保证原子操作。

Volatile.Write也是如此,但它还包括内存屏障操作。 我认为Microsoft在DotNet 4.5中添加了Volatile.Write是因为支持Windows 8上的ARM处理器。Intel和ARM处理器在内存操作重新排序方面存在差异。

在Intel上,您有保证内存访问操作将按照发出的顺序执行,或者至少写入操作不会被重新排序。

来自Intel® 64和IA-32体系结构软件开发人员手册第8章:

8.2.2 P6和更高版本处理器系列中的内存排序Intel Core 2 Duo、Intel Atom、Intel Core Duo、Pentium 4和P6家族 处理器还使用处理器排序的内存排序模型,可以进一步定义为“写入与存储缓冲区转发有序”。 这个模型可以描述如下。

在ARM上,您没有这种保证,因此需要内存屏障。 可以在ARM博客中找到解释:http://blogs.arm.com/software-enablement/594-memory-access-ordering-part-3-memory-access-ordering-in-the-arm-architecture/

在您的示例中,由于双倍操作不保证是原子的,我建议使用锁来访问它。请记住,在读取和设置值时都必须使用该锁。

一个更完整的示例更好地回答您的问题,因为不清楚在设置这些值后会发生什么。对于向量,如果您有比写入器更多的读取器,请考虑使用ReaderWriterLockSlim对象:http://msdn.microsoft.com/en-us/library/system.threading.readerwriterlockslim.aspx

线程数和读/写频率可以显著改变您的锁定策略。


我不想使用锁,因为在一些x64系统上,“双重更新”很可能是自动的,所以我不想引入不必要的额外延迟。这段代码非常时间敏感,所以我真的想赢得几微秒的额外时间。 - Oleg Vazhnev
嗯,Volatile.Write 保证了原子性。MSDN 上没有提到这一点。为什么你建议使用 lock?为什么不使用 Interlock.ExchageVolatile - Oleg Vazhnev
我使用了Volatile类的描述来回答问题。关于atomic和volatile之间的区别的好文章可以在这里找到:http://blogs.msdn.com/b/ericlippert/archive/2011/05/26/atomicity-volatility-and-immutability-are-different-part-one.aspx?PageIndex=2我查看了C#语言定义4.0。在第5.5节中,他们没有更新文档以包括double作为原子操作。你问了一个保证它总是有效的方法,所以锁定就是这样的保证。在你的例子中,这将取决于哪个线程递增instrumentId。 - nmenezes
请注意,在.NET中,您始终依赖CLR及Microsoft在执行之前的翻译方式。所以,正如您所说,64位机器可以具有原子双操作。但是,由Microsoft编写的语言规范明确说明它们不保证双精度数。 - nmenezes
1
你说Volatile.Write包括一个(完整的?)内存屏障,但Interlocked.Exchange则不包括。我在研究答案时找到的所有信息都表明这是相反的。 - Peter Cordes

8
如果你不关心旧值,并且不需要一个完整的内存屏障(包括一个昂贵的 StoreLoad,即在后续加载之前清空存储缓冲区),那么始终使用 Volatile.Write。
Volatile.Write - 原子释放存储
Volatile.Write 是具有 "release" 语义的存储操作,AArch64 可以以低廉的代价实现,x86 则可以免费实现(与非原子存储的成本相同,除了当然要考虑其他尝试写入线路的核心之间的争用)。它基本上相当于 C++ 的 std::atomic store(value, memory_order_release)。
例如,在 double 的情况下,x86(包括 32 位和 x86-64)的 Volatile.Write 可能会直接从 XMM 寄存器编译为 SSE2 8 字节存储,如 movsd [mem],xmm0,因为 x86 存储已经具有与 MS 文档指定的 Volatile.Write 相同的排序。并且假设 double 是自然对齐的(任何 C# 运行时都会这样做,对吗?)它是保证原子性的。 (在所有 x86-64 CPU 和 P5 Pentium 以来的 32 位上。)
较老的 Thread.VolatileWrite 方法实际上使用一个完整的屏障,而不仅仅是可以在一个方向上重新排序的释放操作。这使得它不比 Interlocked.Exchange 更便宜,或者在非 x86 上也不会有太多优势。但是 Volatile.Write/Read 没有过度强大实现的问题,一些软件可能会依赖于此。它们不必清空存储缓冲区,只需确保所有先前的存储(和加载)在此之时都可见即可。
Interlocked.Exchange - 原子 RMW 加完整屏障(至少 acq/rel)
这是 x86 的一个包装器,用来包装 xchg 指令,即使机器代码省略了它,也会像有 lock 前缀一样运行。这意味着原子 RMW 和 "全" 障碍作为它的一部分(就像 x86 的 mfence)。
总的来说,我认为 Interlocked 类方法最初是为带有 lock 前缀的 x86 指令编写的包装器;在 x86 上,不可能做一个既不是完全障碍又是原子 RMW 的操作。还有 MS C++ 函数使用这些名称,因此这个历史源于 C#。
目前 MS 网站上 Interlocked 方法的文档(MemoryBarrier 除外)甚至都没有提及这些方法是完全障碍,即使在不需要原子 RMW 操作的非 x86 ISA 上也是如此。
我不确定完整屏障是否是语言规范的实现细节,但目前肯定是这样。如果不需要这个,Intelocked.Exchange 就不是效率的好选择。
这篇回答引用了 ECMA-335 规范,该规范指出 Interlocked 操作执行隐式获取/释放操作。如果这就像 C++ 的 acq_rel,那么这是相当强的排序,因为它是一个原子 RMW,其中加载和存储有些相互关联,并且各自阻止了一个方向上的重新排序。(但参见“对于排序的目的,原子读取修改写入是一个操作还是两个操作?”-在 AArch64 上,可以观察到 seq_cst RMW 重新排序,但仍然是一个原子 RMW 操作。)
@Theodor Zoulias 在网上找到多个源代码,称 C# Interlocked 方法意味着完全的屏障。例如,Joseph Albahari 的在线书:“以下隐式生成全障碍:[...] Interlocked 类的所有方法”。并且在 Stack Overflow 上,“内存障碍生成器”包括其所有 Interlocked 类方法。这两者可能只是编录实际当前行为,而不是语言规范要求的行为。
我认为现在有很多代码依赖于它,如果Interlocked方法从像C++的std::memory_order_seq_cst更改为像MS文档中没有关于内存顺序的relaxed,那么就会出现问题。(除非文档其他地方有涉及)。
我自己不使用C#,所以我不能轻易地通过SharpLab来编写JITted asm示例进行检查,但是MSVC将其_InterlockedIncrement指令编译为包含AArch64的dmb ish。(评论线程。)因此,如果MS编译器对C#代码执行相同操作,则似乎超出了ECMA语言规范保证的获取/释放,并添加了一个完整的障碍。
顺便说一句,有些人只使用术语“原子”来描述RMW操作,而不是原子负载或原子存储。 MS的文档说Interlocked类“为被多个线程共享的变量提供原子操作。”,但该类不提供纯存储或纯加载,这很奇怪。
(除了Read([U]Int64),预计用于公开带有desired=expected的32位x86 lock cmpxchg8b,因此您可以用自己替换值或加载旧值。无论哪种方式,它都会脏化缓存行(因此与其他线程的读取一样争用),并且是一个完整的障碍,因此您通常不会以这种方式在32位asm中读取64位整数。现代32位代码可以使用SSE2 movq xmm0,[mem] / movd eax,xmm0 / pextrd edx,xmm0,1或类似方法,像G++和MSVC为std::atomic<uint64_t>做的那样;这更好,可以扩展到多个线程并行读取同一值而不相互竞争。)
(ISO C++做得对,其中std::atomic<T>具有load和store方法,以及exchange、fetch_add等。但是,ISO C++定义了关于普通非原子对象的未同步读写或写入+写入发生的情况。类似C#这样的内存安全语言必须定义更多。)

线程间延迟

Volatile.Write是否存在某些隐藏的缺点,例如更新内存“不如”Interlocked.Exchange及时?我不会期望有任何区别。额外的内存排序只是使当前线程中后续的内容等待直到存储提交到L1d缓存之后才进行。(CPU已尽可能快地完成此操作,以便为后续存储腾出存储缓冲区)。请参阅有关硬件内存屏障是否使原子操作的可见性更快的问题。在x86上肯定不会有任何区别;我不知道在弱序ISA上是否会有任何不同,在那里,松散的原子RMW可以在等待存储缓冲区排干之前进行加载+存储,并且可能会“跳过队列”。但是Interlocked.Exchange不执行松散的RMW,它更像是C++ memory_order_seq_cst。
在第一个示例中,对于.Set()和.WaitOne()在单独变量上的情况,这已经提供了足够的同步,使得对于double类型的普通非原子赋值保证完全可见于该读取程序,Volatile.Write和Interlocked.Exchange都是完全无意义的。对于释放锁,是的,您只需要一个纯粹的存储操作,特别是在X86上,这不需要任何屏障指令。如果要检测双重解锁(对已解锁的锁进行解锁),请在存储之前首先加载自旋锁变量。(这可能会错过双重解锁,不像原子交换,但应足以找到有缺陷的用法,除非它们总是在两个解锁程序之间产生紧密的时间间隔)。

1
哇!感谢Peter提供这么详细的回答。对我作为一名软件开发人员来说,第一句话就是我想知道的全部内容了 :-) 关于关键字 Thread.VolatileXXX 会导致全栅栏的问题,据我所知,正是因为API中先前的错误,才引入了 Volatile.XXX 方法进行纠正。新的API并不会产生全栅栏。 - Theodor Zoulias
1
@TheodorZoulias:这就是为什么我把它放在了顶部 :P 其余的答案是为了支持这个推理,以及回答其他用例的一般问题差异。 - Peter Cordes
嗨,Peter Cordes!我刚在Volatile类的文档中注意到这个备注:“在多处理器系统上,……volatile写操作不能保证写入的值会立即对其他处理器可见。”这是否引起了您对Interlocked.Exchange在多处理器系统上可能比Volatile.Write更快地发布新值的担忧? - Theodor Zoulias
1
@TheodorZoulias:不,Interlocked.Exchange 也不会立即对其他线程可见。没有任何东西可以做到这一点。有关“volatile”的注释可能是为了提醒您,在同一线程中的后续加载之前,它甚至没有被排序,因为它只具有释放语义,而不是完整的屏障或顺序一致性。 - Peter Cordes
@TheodorZoulias:对我来说听起来很正常;C#语言标准不保证线程间延迟,只保证顺序。只是硬件的最佳努力,假设有一个高质量的C#实现。你选择运行程序的硬件决定了最坏情况下的延迟可能是多少。(假设你关心的写入线程得到运行;这取决于操作系统的线程调度公平性和实时保证或缺乏保证)。在现代快速CPU上,最坏情况比1微秒更糟糕的情况可能不会意外发生。 - Peter Cordes
显示剩余3条评论

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