【问题标题】:Is the 'volatile' keyword still broken in C#?'volatile' 关键字在 C# 中是否仍然存在问题?
【发布时间】:2016-12-02 18:27:01
【问题描述】:

Joe Albahari 有一篇关于多线程的 great series,这是一本必读的文章,任何从事 C# 多线程的人都应该牢记于心。

然而,在第 4 部分中,他提到了 volatile 的问题:

请注意,应用 volatile 不会阻止写入后跟 从被交换中读取,这可以创建脑筋急转弯。乔·达菲 下面的例子很好地说明了这个问题:如果 Test1 和 Test2 在不同的线程上同时运行,有可能是 a 和 b 都以 0 的值结束(尽管使用了 volatile on x 和 y)

随后是 MSDN 文档不正确的说明:

MSDN 文档指出使用 volatile 关键字可确保 字段中始终存在最新的值。 这是不正确的,因为正如我们所见,写后读可以 重新排序。

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

volatile 关键字表示一个字段可能被 同时执行的多个线程。是的字段 声明的 volatile 不受编译器优化的影响 假设由单个线程访问。 这样可以确保最 字段中始终存在最新值

现在我仍然避免使用 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 供应商?

【问题讨论】:

  • 在您的示例中,围绕returnlock 的赋值语句有什么意义?
  • 这仍然具有误导性。 Volatile 提供了获取/释放内存语义,足以有效地实现许多算法。是的,它很难使用,但这并没有破坏它(c++ 的解决方案显然更优越,但他们的优势在于看到了 java 中 volatile 的问题)(并且任何认为内存屏障比 volatile 更容易的人都没有足够的具有 x86 以外架构的经验。尝试在没有多副本原子性的架构上使用内存屏障,看看你能走多远)
  • (msdn 的描述显然更糟。显然写 sn-p 的人根本不懂 volatile,只能希望永远不允许编写一行并发代码)
  • @Yarik 锁提供完整的内存防护前后。内存栅栏确保 CPU 缓存从内存中刷新。例如,我在 while(!Stopped) 中使用它来在单独的线程上进行后台服务工作。如果 Windows 服务停止,我将 Stopped 设置为 true,以便单独的线程跳出循环。如果没有锁定(因此围栏),属性的支持字段可能会一直缓存在 CPU 中,永远不会刷新,线程也永远不会停止。
  • 目前你的锁实际上并没有比volatile 做更多的事情,而且无论如何不能防止许多类的同步问题。其中大部分与两个线程的交错有关 - 例如,尝试同时从两个线程执行类似foo++ 的操作可能仍然具有未定义的结果,并且鉴于您的锁定示例,为什么应该更明显:每个加载和存储都有一个单独的锁,这意味着两个线程必须为每个操作谁先做斗争(这是否与您当前使用它的代码相关,目前尚不清楚)。

标签: c# multithreading volatile


【解决方案1】:

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

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

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

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 可能非常有用。

【讨论】:

  • 您是否打算将“a”设为易失性,或者“x”上的易失性是否确保对“a”的写入发生?
  • @Patrick 代码原样正确。内存排序保证比“不能缓存写入”严格得多,这也在这里起作用。大大简化:如果线程 B 看到对 volatile 变量 X 的更新,那么当线程 A 将值写入 x 时,可以保证看到之前在线程 A 上发生的所有写入。这允许我们使用单个 volatile bool 来发布其他数据。
  • volatile 真的只有获取/释放语义吗?还是顺序一致性?我会认为是后者,而两者并不相同。
  • @Mehrdad C# 规范确实保证了获取/发布,并且保证了顺序一致性。 (规范的第 10.5.3 节)。顺序一致性是一种昂贵的保证,所以他们不提供。
  • 哇,我不知道。谢谢!
【解决方案2】:

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

【讨论】:

  • 我同意。我认为重点应该是“声明为 volatile 的字段不受 编译器优化 假设由单个线程访问。这确保了最新的值存在于该字段中任何时候。”
  • @MikeSherrill'CatRecall':“编译器”是一个红鲱鱼。线程安全是一个远远超出编译器的问题。例如,在 CPU 中重新排序同样糟糕。
  • 每个编译器都必须了解 CPU 重新排序规则。如果指令 A 和 B 可以由 CPU 重新排序,但语言语义另有规定,则编译器必须引入某种类型的栅栏来防止重新排序,或者选择替代指令来实现相同的目标。因此,在 volatile 语义禁止重新排序的情况下,编译器不仅要避免重新排序本身,还要阻止 CPU 这样做。
  • @MSalters:当然可以,但我认为大多数人不会将 { 编译器可以阻止但没有 } 的 CPU 重新排序视为一种“编译器优化”。所以 Mehrdad 的观点是,这不仅是某些编译器优化不适用于某些领域的问题,而且是编译器必须采取措施禁用硬件优化的问题。
【解决方案3】:

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;}
    ...
  }
}

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

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

【讨论】:

  • “这基本上意味着变量没有被缓存”......叹息和另一个人延续这个神话:(不,这绝对不是 volatile 保证的,事实上 volatile 字段被完美地缓存了在 x86 和其他架构上很好。此外,Volatile 绝对确实限制了内存访问的重新排序方式,这对于使其有用至关重要。即使有一个合理的“未缓存”定义(没有),这对于绝对是所有算法。
  • @Voo 只是说我所学的,抱歉。这是更正确的吗?:“这意味着变量不受编译器优化的影响,假设从单个线程访问。[...] 没有 volatile,一个没有 volatile 的多处理器机器,编译器可能会做出关于单线程的假设 -线程访问,例如将值保存在寄存器中,这样可以防止其他处理器访问最新值。”
  • 这是正确的,但错过了 volatile 所做的基本部分。易失性保证在读取和写入时获取/释放语义。例如,您不能在 volatile 写入 after 之后重新排序写入(您可以在 volatile 写入之前重新排序它就好了)。如果您想开始了解整个事情,例如可以从this 开始。这是关于 JMM,但 CLR MM 在实践中非常相似。
【解决方案4】:

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

在 x86 上是这样的:

  • 使用内存而不是寄存器
  • 防止编译器优化,例如在无限循环的情况下

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

【讨论】:

    猜你喜欢
    • 2015-01-11
    • 1970-01-01
    • 2018-04-12
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    相关资源
    最近更新 更多