'volatile' 关键字在 C# 中是否仍然损坏?

Is the 'volatile' keyword still broken in C#?

Joe Albahari 有一本关于多线程的 great series,这是必读的,任何使用 C# 多线程的人都应该牢记。

但是在第 4 部分中他提到了 volatile 的问题:

Notice that applying volatile doesn’t prevent a write followed by a read from being swapped, and this can create brainteasers. Joe Duffy illustrates the problem well with the following example: if Test1 and Test2 run simultaneously on different threads, it’s possible for a and b to both end up with a value of 0 (despite the use of volatile on both x and y)

后跟 MSDN 文档不正确的注释:

The MSDN documentation states that use of the volatile keyword ensures that the most up-to-date value is present in the field at all times. This is incorrect, since as we’ve seen, a write followed by a read can be reordered.

我检查了 MSDN documentation,它最后一次更改是在 2015 年,但仍然列出:

The volatile keyword indicates that a field might be modified by multiple threads that are executing at the same time. Fields that are declared volatile are not subject to compiler optimizations that assume access by a single thread. This ensures that the most up-to-date value is present in the field at all times.

现在我仍然避免使用 volatile,转而使用更冗长的方法来防止线程使用过时的数据:

private int foo;
private object fooLock = new object();
public int Foo {
    get { lock(fooLock) return foo; }
    set { lock(fooLock) foo = value; }
}

关于多线程的部分是2011年写的,现在这个论点还成立吗?是否仍应不惜一切代价避免 volatile 以支持锁或全内存栅栏,以防止引入非常难以产生的错误,如前所述,这些错误甚至取决于 运行 所在的 CPU 供应商?

当我阅读 MSDN 文档时,我相信它是在说如果您在变量上看到 volatile,您不必担心编译器优化会搞砸该值,因为它们会重新排序操作。它并不是说您可以避免因您自己的代码以错误的顺序在不同的线程上执行操作而导致的错误。 (虽然不可否认,评论对此并不清楚。)

Volatile 在其当前实现中 损坏,尽管流行的博客帖子声称有这样的事情。然而,它的指定很糟糕,并且在字段上使用修饰符来指定内存排序的想法并不是很好(将 Java/C# 中的 volatile 与 C++ 的原子规范进行比较,后者有足够的时间从早期的错误中吸取教训)。另一方面,MSDN 文章显然是由与并发无关的人撰写的,完全是伪造的。唯一理智的选择是完全忽略它。

Volatile 在访问字段时保证 acquire/release 语义,并且只能应用于允许原子读写 的类型.不多也不少。这足以有效地实现许多无锁算法,例如 non-blocking hashmaps

一个非常简单的示例是使用 volatile 变量发布数据。由于 x 上的 volatile,无法触发以下代码段中的断言:

private int a;
private volatile bool x;

public void Publish()
{
    a = 1;
    x = true;
}

public void Read()
{
    if (x)
    {
        // if we observe x == true, we will always see the preceding write to a
        Debug.Assert(a == 1); 
    }
}

Volatile 不容易使用,在大多数情况下你最好使用一些更高层次的概念,但是当性能很重要或者你正在实现一些低层次的数据结构时,volatile 会非常有用。

volatile 是非常有限的保证。这意味着该变量不受假设从单个线程访问的编译器优化的影响。这意味着如果你从一个线程写入一个变量,然后从另一个线程读取它,另一个线程肯定有最新的值。如果没有 volatile,一个没有 volatile 的多处理器机器,编译器可能会对单线程访问做出假设,例如通过将值保存在寄存器中,这会阻止其他处理器访问最新值。

如您提到的代码示例所示,它并不能保护您避免对不同块中的方法进行重新排序。实际上 volatile 使每个 个人访问 volatile 变量的原子性。它不保证此类访问组的原子性。

如果你只想确保你的 属性 有一个最新的单一值,你应该能够只使用 volatile.

如果您尝试执行多个并行操作,就好像它们是原子的一样,问题就会出现。如果你必须强制几个操作一起成为原子操作,你需要锁定整个操作。再次考虑这个例子,但是使用锁:

class DoLocksReallySaveYouHere
{
  int x, y;
  object xlock = new object(), ylock = new object();

  void Test1()        // Executed on one thread
  {
    lock(xlock) {x = 1;}
    lock(ylock) {int a = y;}
    ...
  }

  void Test2()        // Executed on another thread
  {
    lock(ylock) {y = 1;}
    lock(xlock) {int b = x;}
    ...
  }
}

锁的原因可能会导致一些同步,这可能会阻止 ab 的值为 0(我没有测试过这个)。但是,由于 xy 都是独立锁定的,因此 ab 仍可能不确定地以值 0 结束。

所以在包装单个变量的修改的情况下,使用 volatile 应该是安全的,使用 lock 并不会真正更安全。如果你需要原子地执行多个操作,你需要在整个原子块周围使用一个lock,否则调度仍然会导致非确定性行为。

以下是 C# 中 volatile 的一些有用反汇编:https://sharplab.io/#gist:625b1181356b543157780baf860c9173

在 x86 上大约是:

  • 使用内存代替寄存器
  • 防止编译器优化,例如无限循环

当我只想告诉编译器一个字段可能会从许多不同的线程更新并且我不需要互锁操作提供的额外功能时,我使用 volatile。