如果你不关心旧值,并且不需要一个完整的内存屏障(包括一个昂贵的 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上,这不需要任何屏障指令。如果要检测双重解锁(对已解锁的锁进行解锁),请在存储之前首先加载自旋锁变量。(这可能会错过双重解锁,不像原子交换,但应足以找到有缺陷的用法,除非它们总是在两个解锁程序之间产生紧密的时间间隔)。
memory_order_relaxed
交换一样,而不需要与周围代码进行排序(我会感到惊讶;我认为Interlocked意味着一个屏障),那么情况可能会有所不同。 - Peter CordesInterlocked
和 fences,在这个答案中,列出了Interlocked
类方法作为机制,“通常被认为会产生隐式屏障”。在Joseph Albahari的在线书籍中也有同样的陈述:“以下方法隐式生成全屏障:[...]所有Interlocked
类的方法。” 我确信我也在微软的文档中看到过这个描述。 - Theodor ZouliasVolatile.Write
;如果编译器实现其排序语义的最便宜方式是 x86xchg
,那就这样做。(如果它意味着 seq_cst 排序/一个完整的屏障,而不仅仅是释放语义)。但如果只是“释放”,那么在 x86 和 AArch64 上,它显然比 Interlocked.Exchange 更好,没有任何折衷。由于缺少读取,其他 ISA 上也可能如此。 - Peter Cordes