28

Interlocked.Exchange和 和有什么不一样Volatile.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.WriteInterlocked.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);

Release1如果此 API 的用户调用或方法,是否有任何实际区别Release2

4

2 回答 2

11

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

Volatile.Write 做同样的事情,但它还包括一个内存屏障操作。我认为微软在 DotNet 4.5 上添加了 Volatile.Write,因为 Windows 8 上支持 ARM 处理器。英特尔和 ARM 处理器在内存操作重新排序方面有所不同。

在 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/

在您的示例中,由于不能保证使用 double 的操作是原子的,我建议使用锁来访问它。请记住,在读取和设置值时,您必须对代码的两个部分都使用锁。

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

线程数和读/写频率会极大地改变您的锁定策略。

于 2012-09-19T11:59:51.627 回答
2

如果您不关心旧值,并且不需要完整的内存屏障(包括昂贵的 StoreLoad,即在以后加载之前耗尽存储缓冲区),请始终使用Volatile.Write.

Volatile.Write - 原子发布商店

Volatile.Write是一个具有“发布”语义的存储,AArch64 可以廉价地完成,而 x86 可以免费完成(嗯,与非原子存储相同的成本,当然除了与其他内核争用也试图写线)。它基本上等同于 C++ std::atomic<T> store(value, memory_order_release)

例如,在 a 的情况下doubleVolatile.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_cstRMW 重新排序以及relaxedAArch64 上的后续操作。不过,它仍然是原子 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 ishAArch64。(评论线程。)因此,如果 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] forstd ::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 变量,然后再存储。(与原子交换不同,这可能会错过双重解锁,但应该足以找到错误的用法,除非它们总是仅在两个解锁器之间的时间紧迫时才会发生。)

于 2022-02-05T21:27:27.523 回答