.NET中变量新鲜度保证(volatile vs. volatile read)

18

我已经阅读了许多关于volatile和VolatileRead(ReadAcquireFence)的相互矛盾的信息(来自msdn、SO等等)。

我理解它们的内存访问重新排序限制意义,但我仍然完全困惑于新鲜度保证,这对我非常重要。

msdn文档中关于volatile的说明提到:

(...) 这确保字段中始终存在最新的值。

msdn文档中关于volatile字段的说明提到:

对volatile字段进行的读取称为volatile读取。volatile读取具有“获取语义”;也就是说,在指令序列中之后发生的任何对内存的引用之前,它都得到了保证。

VolatileRead的.NET代码如下:

public static int VolatileRead(ref int address)
{
    int ret = address;
    MemoryBarrier(); // Call MemoryBarrier to ensure the proper semantic in a portable way.
    return ret;
}
根据 msdn MemoryBarrier 文档,内存屏障可以防止重排序,但这似乎对新鲜度没有影响 - 对吗?
那么怎样才能获得新鲜度保证呢?标记字段为 volatile 和使用 VolatileRead 和 VolatileWrite 语义访问之间是否有区别?我目前在我的性能关键代码中执行后者以保证新鲜度,但读取器有时会获取旧值。我想知道将状态标记为 volatile 是否会改变情况。
编辑1:
我想要实现的是,读取线程将获得共享变量(由多个写入器编写)最近的值的保证,理想情况下不会比上下文切换或其他可能推迟状态立即写入的操作更旧。
如果 volatile 或更高级别的构造(例如锁定)具有此保证(它们是否具有?),那么它们是如何实现的?
编辑2:
非常简洁的问题应该是 - 我如何获得尽可能新鲜的值保证?理想情况下,不需要锁定(因为不需要独占访问,而且存在高竞争的潜力)。
根据我在这里所学的,我想知道这是否可能是解决方案(已标注带有注释的行):
private SharedState _sharedState;
private SpinLock _spinLock = new SpinLock(false);

public void Update(SharedState newValue)
{
    bool lockTaken = false;
    _spinLock.Enter(ref lockTaken);

    _sharedState = newValue;

    if (lockTaken)
    {
        _spinLock.Exit();
    }
}

public SharedState GetFreshSharedState
{
    get
    {
        Thread.MemoryBarrier(); // <---- This is added to give readers freshness guarantee
        var value = _sharedState;
        Thread.MemoryBarrier();
        return value;
    }
}

为确保读写操作都被完整的屏障包裹(与锁代码一样),MemoryBarrier调用被添加了进来 - 正如在这里所示的'Memory barriers and locking'部分 - http://www.albahari.com/threading/part4.aspx#_The_volatile_keyword

这看起来正确吗或者有什么缺陷吗?

EDIT3:

感谢这里非常有趣的讨论,我学到了很多东西,实际上我已经能够提炼出关于这个主题的简化明确的问题。 这与原始问题非常不同,所以我在这里更愿意发布一个新的问题:Memory barrier vs Interlocked impact on memory caches coherency timing


1
你知道屏障可以防止重新排序,但不理解这如何意味着“新鲜度”。首先仔细定义一下你所说的“新鲜度”。 - Eric Lippert
1
更一般地说,说明你想要实现什么目标。易失性字段是非常低级的工具。你应该使用更高级别的工具来代替它。 - Eric Lippert
如果每秒有数亿次写入,则无论您做什么,您都无法获得最新值。 假设您进行读取操作。 读取线程执行两个指令,此时该值已经过时 - Eric Lippert
1
回答你的最后一个问题:锁确实有这个保证,它们如何保证是Jitter的实现细节;技术因CPU而异。通常,它们会在控制进入和离开锁体时都设置读取和写入栅栏。 - Eric Lippert
我还注意到,在一个线程量子为16毫秒的机器上,70毫秒少于五个量子。你怎么知道这70毫秒的延迟不是由于四个线程在读取共享内存和消耗该值的代码之间在处理器上运行? - Eric Lippert
显示剩余10条评论
2个回答

10
我认为这是一个好问题。但是,回答起来也很困难。我不确定我能够给你一个明确的答案。这并不是你的错。只是因为主题很复杂,需要知道可能无法枚举的细节。老实说,你似乎已经在这个主题上自学成才了。我也花了很多时间研究这个主题,但我仍然不能完全理解一切。尽管如此,我仍将在这里尝试回答。
那么,线程读取新值是什么意思呢?它是否意味着读取返回的值保证不会比100ms、50ms或1ms旧?或者它是否意味着该值是绝对最新的?或者它是否意味着如果两次读取连续发生,那么第二次读取将得到一个更新的值,假设在第一次读取后内存地址已更改?或者它是否意味着完全不同的东西?
如果你用时间间隔来考虑事情,你可能会让你的读者正确地工作很困难。相反,应该从链接读取起来考虑。为了说明我的观点,考虑如何使用任意复杂的逻辑来实现类似于交错操作的操作。
public static T InterlockedOperation<T>(ref T location, T operand)
{
  T initial, computed;
  do
  {
    initial = location;
    computed = op(initial, operand); // where op is replaced with a specific implementation
  } 
  while (Interlocked.CompareExchange(ref location, computed, initial) != initial);
  return computed;
}

在上面的代码中,如果我们利用了第二次通过Interlocked.CompareExchange读取location时,如果内存地址在第一次读取后接收到写入,则保证返回一个新的值,那么我们就可以创建任何类似于互锁的操作。这是因为Interlocked.CompareExchange方法生成了一个内存屏障。如果值在读取之间发生了变化,则代码会在循环中反复旋转,直到location停止更改。这种模式不要求代码使用最新的最新鲜的值;只需要一个更新的值。这种区别非常关键。1 我看过很多无锁代码都是按照这个原则工作的。也就是说,操作通常被包装成循环,以便不断重试,直到成功为止。它不假设第一次尝试使用最新的值。它也不假设每次使用的值都是最新的。它只假设每次读取后的值是更新的
请重新考虑您的读者应该如何行事。尽量让他们对价值的年龄更加不可知。如果这根本不可能,所有写入必须被捕获和处理,那么您可能会被迫采用更加确定性的方法,例如将所有写入放入队列中,然后让读者逐个出队。我相信在这种情况下,ConcurrentQueue类会有所帮助。
如果您可以将“新鲜”的含义降低到只是“更新”,那么在每次读取后使用Thread.MemoryBarrier调用、使用Volatile.Read、使用volatile关键字等,将绝对保证一系列读取中的一个读取将返回比之前读取更新的值。

1ABA问题打开了一个新的潘多拉魔盒。


感谢Brian抽出时间来做这件事!只是为了快速定义我所说的“新鲜”:任何写入者的最后一个写入值(无论缓存等)。我越读关于屏障的文章,就越担心它们没有为此定义(它们是为了防止重新排序的类型)-但我可能被误导了。非常好的一点是指向Interlocked-从定义上保证获取最新的值。我得出结论,我的读取属性应该简单地调用__return Interlocked.CompareExchange(ref sharedState, null, null);_ - Jan
是的,Interlocked.CompareExchange 会返回最新的值。但是,在您的逻辑使用该值的时候,它可能已经不是最新的了。这就是我建议尝试让读者 less 依赖实际上是最新的值的意思。 - Brian Gideon
谢谢Brian - 你的问题目前是对我非常模糊问题的最佳回答。在标记之前,我想让讨论保持开放一两天。但是,我创建了一个更具体的问题(请参见编辑3)。关于时间和新鲜度要求 - 这主要涉及统计数据和“不幸”情况 - 缓存更新较慢+几次不幸的交换(+页面丢失或其他任何事情)和稍旧的值可能会在亿万分之一的情况下突然变成年龄较大的值 - 我需要尽可能地防止这种可能性。 - Jan

1

内存屏障可以提供这种保证。我们可以从屏障所保证的重排属性中推导出您所寻求的“新鲜度”属性。

通过“新鲜度”,您可能意味着读取返回最近写入的值。

假设我们有以下操作,每个操作在不同的线程上:

x = 1
x = 2
print(x)

没有volatile修饰,读操作可能会移动一个位置并返回1,如何才能打印出除2以外的值呢?然而,使用volatile可以防止重排序,写操作无法倒流。简而言之,volatile可以保证你看到最新的值。
严格来说,我需要区分volatile和内存屏障。后者提供了更强的保障。我简化了这个讨论,因为至少在x86/x64上,volatile是使用内存屏障实现的。

感谢您的贡献。如果线程A在晚上10:01写入1,线程B在晚上10:10写入2,那么您能保证线程C在晚上10:20执行读取操作时会得到值2吗?不是的。我没有在任何规范中看到这种保证 - 我只看到您将在正确的时间看到内存的正确快照的保证。 - Jan
另一方面,Thread.VolatileRead MSDN文档提到:“读取字段的值。该值是计算机上任何处理器最新写入的值,无论处理器缓存的数量或状态如何。”然而,我偶尔在我们的工具集中观察到矛盾的行为。我可以在网上找到许多相互矛盾的信息(例如上面链接的两个评论)。感到困惑 :| - Jan
@Jan 对的。除了这个问题,还可以提出一个新问题。 - usr
显示剩余4条评论

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