如果您不关心旧值,并且不需要完整的内存屏障(包括昂贵的 StoreLoad,即在以后加载之前耗尽存储缓冲区),请始终使用Volatile.Write
.
Volatile.Write
- 原子发布商店
Volatile.Write
是一个具有“发布”语义的存储,AArch64 可以廉价地完成,而 x86 可以免费完成(嗯,与非原子存储相同的成本,当然除了与其他内核争用也试图写线)。它基本上等同于 C++ std::atomic<T>
store(value, memory_order_release)
。
例如,在 a 的情况下double
,Volatile.Write
对于 x86(包括 32 位和 x86-64)可以直接从 XMM 寄存器编译为 SSE2 8 字节存储,例如movsd [mem], xmm0
,因为 x86 存储已经具有与MS 文档指定的Volatile.Write
一样多的排序为. 并且假设它double
是自然对齐的(任何 C# 运行时都会这样做,对吗?)它也保证是 atomic。(在所有 x86-64 CPU 上,以及自 P5 Pentium 以来的 32 位。)
实际上,较旧的Thread.VolatileWrite
方法使用完整的屏障,而不仅仅是可以在一个方向上重新排序的释放操作。这使得它不比 Interlocked.Exchange 便宜,或者在非 x86 上也不便宜。但是Volatile.Write
/Read
不存在某些软件可能依赖的过于强大的实现的问题。他们不必耗尽存储缓冲区,只需确保在此之前所有早期存储(和加载)都可见。
Interlocked.Exchange
- 原子 RMW 加全屏障(至少 acq/rel)
这是 x86 指令的包装器,即使机器代码省略了xchg
它,它也好像它有一个前缀。lock
这意味着原子 RMW,以及作为其中一部分的“完整”屏障(例如 x86mfence
)。
一般来说,我认为 Interlocked 类方法起源于带有lock
前缀的 x86 指令的包装器;在 x86 上,不可能做一个不是完全屏障的原子 RMW。也有具有这些名称的 MS C++ 函数,因此这段历史早于 C#。
MS 网站上的 Interlocked 方法(MemoryBarrier 除外)的当前文档甚至没有提及这些方法是一个完整的障碍,即使在原子 RMW 操作不需要的非 x86 ISA 上也是如此。
我不确定完整的障碍是否是实现细节而不是语言规范的一部分,但目前确实如此。Intelocked.Exchange
如果您不需要它,那么这对于效率来说是一个糟糕的选择。
这个答案引用了 ECMA-335 规范,说互锁操作执行隐式获取/释放操作。 如果这就像 C++ acq_rel
,那是相当强的排序,因为它是一个原子 RMW,负载和存储有点捆绑在一起,并且每个都防止在一个方向上重新排序。(但请参阅为了排序,原子读取-修改-写入一个或两个操作? -在 C++ 语义允许的范围内,可以观察到seq_cst
RMW 重新排序以及relaxed
AArch64 上的后续操作。不过,它仍然是原子 RMW .)
@Theodor Zoulias 在网上找到了多个消息来源,称 C# 互锁方法意味着完整的栅栏/屏障。例如,Joseph Albahari 的在线书籍:“以下隐式生成完整的围栏:[...]Interlocked
类上的所有方法”。在 Stack Overflow 上,内存屏障生成器在其列表中包含所有Interlocked
类方法。这两者可能只是对当前的实际行为进行编目,而不是语言规范所要求的。
我假设现在有很多代码依赖于它,如果 Interlocked 方法从像 C++std::memory_order_seq_cst
变为relaxed
像文档所暗示的那样,对内存排序只字不提,就会中断。到周围的代码。(除非文档中的其他地方对此进行了介绍。)
我自己不使用 C#,所以我不能轻易地在 SharpLab 上用 JITted asm 编写一个示例来检查,但是MSVC 编译它的_InterlockedIncrement
内在函数以包含dmb ish
AArch64。(评论线程。)因此,如果 MS 编译器对 C# 代码做同样的事情,似乎 MS 编译器甚至超出了 ECMA 语言规范所保证的获取/发布,并添加了一个完整的障碍。
顺便说一句,有些人根本只使用术语“原子”来描述 RMW 操作,而不是原子加载或原子存储。MS 的文档说Interlocked
该类“为多个线程共享的变量提供原子操作”。但是该类不提供纯存储或纯加载,这很奇怪。
(除了Read([U]Int64)
,大概是打算用 desired=expected 公开 32 位 x86 lock cmpxchg8b
,因此您要么用自己替换一个值,要么加载旧值。无论哪种方式,它都会弄脏缓存行(因此与其他线程的读取竞争,就像任何其他 Interlocked RMW 操作)并且是一个完整的障碍,因此您通常不会在 32 位 asm 中以这种方式读取 64 位整数。现代 32 位代码只能使用 SSE2 movq xmm0, [mem]
/ movd eax, xmm0 /
pextrd edx, xmm0, 1 or similar, [like G++ and MSVC do][11] for
std ::atomic<uint64_t>`;这要好得多,并且可以扩展到多个线程并行读取相同的值而不会相互竞争。)
(ISO C++ 做到了这一点,其中std::atomic<T>
有 load 和 store 方法,以及 exchange、fetch_add 等。但是 ISO C++ 从字面上没有定义普通非原子对象的非同步读+写或写+写会发生什么。A像 C# 这样的内存安全语言必须定义更多。)
线程间延迟
Volatile.Write 是否有可能存在一些隐藏的缺点,例如更新内存“不那么即时”(如果这有意义的话)而不是 Interlocked.Exchange?
我不希望有任何区别。 额外的内存排序只会使当前线程中的后续内容等到存储提交到 L1d 缓存之后。 它不会让这种情况更快发生,因为 CPU 已经尽可能快地做到了。(在存储缓冲区中为以后的存储腾出空间。)请参阅硬件内存屏障除了提供必要的保证之外,还能更快地看到原子操作吗?更多。
当然不是在 x86 上;IDK 如果在弱排序 ISA 上情况可能有所不同,其中放松的原子 RMW 可以加载+存储而无需等待存储缓冲区耗尽,并且可能“跳队列”。但是 Interlocked.Exchange 并没有做一个轻松的 RMW,它更像是 C++ memory_order_seq_cst
。
问题中的示例:
在第一个示例中,使用.Set()
和.WaitOne()
在单独的变量上,它已经提供了足够的同步,从而保证对 a 的普通非原子分配double
对该读者完全可见。 Volatile.Write
并且Interlocked.Exchange
两者都完全没有意义。
对于释放锁,是的,您只需要一个纯存储,尤其是在 x86 上,它不需要任何屏障指令。如果要检测双重解锁(解锁已经解锁的锁),请先加载 spinlock 变量,然后再存储。(与原子交换不同,这可能会错过双重解锁,但应该足以找到错误的用法,除非它们总是仅在两个解锁器之间的时间紧迫时才会发生。)