【问题标题】:When should the volatile keyword be used in C#?什么时候应该在 C# 中使用 volatile 关键字?
【发布时间】:2010-09-09 11:50:10
【问题描述】:

谁能很好地解释 C# 中的 volatile 关键字?它解决了哪些问题,没有解决哪些问题?在哪些情况下它会省去我对锁定的使用?

【问题讨论】:

  • 为什么要节省使用锁定?非竞争锁会为您的程序增加几纳秒。你真的不能承受几纳秒吗?

标签: c# multithreading


【解决方案1】:

CLR 喜欢优化指令,因此当您访问代码中的字段时,它可能并不总是访问该字段的当前值(它可能来自堆栈等)。将字段标记为volatile 可确保指令访问该字段的当前值。当您的程序中的并发线程或操作系统中运行的一些其他代码可以修改值(在非锁定场景中)时,这很有用。

你显然失去了一些优化,但它确实让代码更简单。

【讨论】:

    【解决方案2】:

    来自MSDN: volatile修饰符通常用于多线程访问而不使用lock语句序列化访问的字段。使用 volatile 修饰符可确保一个线程检索另一个线程写入的最新值。

    【讨论】:

      【解决方案3】:

      有时,编译器会优化一个字段并使用寄存器来存储它。如果线程 1 写入该字段并且另一个线程访问它,由于更新存储在寄存器(而不是内存)中,第二个线程将获得陈旧数据。

      您可以认为 volatile 关键字对编译器说“我希望您将此值存储在内存中”。这保证了第二个线程检索到最新的值。

      【讨论】:

        【解决方案4】:

        如果您想稍微了解一下 volatile 关键字的作用,请考虑以下程序(我使用的是 DevStudio 2005):

        #include <iostream>
        void main()
        {
          int j = 0;
          for (int i = 0 ; i < 100 ; ++i)
          {
            j += i;
          }
          for (volatile int i = 0 ; i < 100 ; ++i)
          {
            j += i;
          }
          std::cout << j;
        }
        

        使用标准优化(发布)编译器设置,编译器创建以下汇编器 (IA32):

        void main()
        {
        00401000  push        ecx  
          int j = 0;
        00401001  xor         ecx,ecx 
          for (int i = 0 ; i < 100 ; ++i)
        00401003  xor         eax,eax 
        00401005  mov         edx,1 
        0040100A  lea         ebx,[ebx] 
          {
            j += i;
        00401010  add         ecx,eax 
        00401012  add         eax,edx 
        00401014  cmp         eax,64h 
        00401017  jl          main+10h (401010h) 
          }
          for (volatile int i = 0 ; i < 100 ; ++i)
        00401019  mov         dword ptr [esp],0 
        00401020  mov         eax,dword ptr [esp] 
        00401023  cmp         eax,64h 
        00401026  jge         main+3Eh (40103Eh) 
        00401028  jmp         main+30h (401030h) 
        0040102A  lea         ebx,[ebx] 
          {
            j += i;
        00401030  add         ecx,dword ptr [esp] 
        00401033  add         dword ptr [esp],edx 
        00401036  mov         eax,dword ptr [esp] 
        00401039  cmp         eax,64h 
        0040103C  jl          main+30h (401030h) 
          }
          std::cout << j;
        0040103E  push        ecx  
        0040103F  mov         ecx,dword ptr [__imp_std::cout (40203Ch)] 
        00401045  call        dword ptr [__imp_std::basic_ostream<char,std::char_traits<char> >::operator<< (402038h)] 
        }
        0040104B  xor         eax,eax 
        0040104D  pop         ecx  
        0040104E  ret              
        

        查看输出,编译器决定使用 ecx 寄存器来存储 j 变量的值。对于非易失性循环(第一个),编译器已将 i 分配给 eax 寄存器。非常坦率的。不过有几个有趣的位 - lea ebx,[ebx] 指令实际上是多字节 nop 指令,因此循环跳转到 16 字节对齐的内存地址。另一种是使用 edx 来增加循环计数器,而不是使用 inc eax 指令。与 inc reg 指令相比,add reg,reg 指令在一些 IA32 内核上具有较低的延迟,但从来没有更高的延迟。

        现在是带有 volatile 循环计数器的循环。计数器存储在 [esp] 中,并且 volatile 关键字告诉编译器该值应始终从内存读取/写入内存,并且永远不要分配给寄存器。编译器甚至在更新计数器值时不将加载/递增/存储作为三个不同的步骤(加载 eax、inc eax、保存 eax),而是在单个指令中直接修改内存(添加 mem ,注册)。创建代码的方式确保循环计数器的值在单个 CPU 内核的上下文中始终是最新的。对数据的任何操作都不会导致损坏或数据丢失(因此不使用 load/inc/store,因为在 inc 期间值可能会发生变化,从而在存储中丢失)。由于只有在当前指令完成后才能服务中断,因此数据永远不会被破坏,即使内存未对齐。

        一旦您将第二个 CPU 引入系统,volatile 关键字将无法防止数据同时被另一个 CPU 更新。在上面的示例中,您需要未对齐数据才能获得潜在的损坏。如果不能以原子方式处理数据,则 volatile 关键字将无法防止潜在的损坏,例如,如果循环计数器的类型为 long long(64 位),则需要两个 32 位操作来更新值,在中间可能会发生中断并更改数据。

        因此,volatile 关键字仅适用于小于或等于本机寄存器大小的对齐数据,这样操作始终是原子的。

        volatile 关键字被设想用于 IO 操作,其中 IO 会不断变化但具有恒定地址,例如内存映射的 UART 设备,并且编译器不应继续重用从该地址读取的第一个值.

        如果您正在处理大数据或有多个 CPU,那么您将需要更高级别 (OS) 的锁定系统来正确处理数据访问。

        【讨论】:

        • 这是 C++ 但原理适用于 C#。
        • 他专门询问了C#,这个答案是关于C++的。 C# 中的 volatile 关键字的行为与 C++ 中的 volatile 关键字完全一样,这一点并不明显。
        【解决方案5】:

        我认为没有比Eric Lippert 更好的人来回答这个问题了(强调原文):

        在 C# 中,“volatile”不仅意味着“确保编译器和 jitter 不执行任何代码重新排序或注册缓存 对此变量的优化”。这也意味着“告诉处理器 做他们需要做的任何事情,以确保我正在阅读 最新值,即使这意味着停止其他处理器并使 它们将主内存与其缓存同步”。

        实际上,最后一点是谎言。 volatile 读取的真正语义 并且写入比我在这里概述的要复杂得多;在 事实上 他们实际上并不能保证每个处理器都会停止它 正在做和更新缓存到/从主内存。相反,他们提供 关于在读取之前和之后如何访问内存的较弱保证 可以观察到写入是相对于彼此排序的。 某些操作,例如创建新线程、进入锁或 使用 Interlocked 系列方法之一引入更强的 关于观察排序的保证。如果您想了解更多详情, 阅读 C# 4.0 规范的第 3.10 和 10.5.3 节。

        坦率地说,我不鼓励你创建一个不稳定的字段。易挥发的 字段表明你正在做一些非常疯狂的事情:你是 试图在两个不同的线程上读取和写入相同的值 没有把锁到位。锁保证内存读取或 修改里面的锁观察是一致的,锁保证 一次只有一个线程访问给定的内存块,因此 在。锁太慢的情况非常多 小,而且你会弄错代码的可能性 因为你不了解确切的内存模型非常大。一世 不要尝试编写任何低锁代码,除了最琐碎的代码 互锁操作的用法。我将“易失性”的用法留给 真正的专家。

        更多阅读请看:

        【讨论】:

        • 如果可以的话,我会投反对票。里面有很多有趣的信息,但并不能真正回答他的问题。他询问 volatile 关键字的用法,因为它与锁定有关。很长一段时间(在 2.0 RT 之前),如果字段实例在构造函数中有任何初始化代码,则必须使用 volatile 关键字才能正确地使静态字段线程安全(请参阅 AndrewTek 的回答)。有很多 1.1 RT 代码仍在生产环境中,维护它的开发人员应该知道为什么该关键字存在以及是否可以安全删除。
        • @PaulEaster 它可以用于双重检查锁定(通常在单例模式中)这一事实并不意味着它应该 .依赖 .NET 内存模型可能是一种不好的做法 - 您应该改用 ECMA 模型。例如,您可能希望有一天移植到单声道,它可能有不同的模型。我也明白不同的硬件架构可能会改变事情。有关详细信息,请参阅:stackoverflow.com/a/7230679/67824。如需更好的单例替代方案(适用于所有 .NET 版本),请参阅:csharpindepth.com/articles/general/singleton.aspx
        • 换句话说,该问题的正确答案是:如果您的代码在 2.0 运行时或更高版本中运行,则几乎不需要 volatile 关键字,如果不必要地使用,弊大于利。但在运行时的早期版本中,需要对静态字段进行正确的双重检查锁定。
        • 这是否意味着锁和 volatile 变量在以下意义上是互斥的:如果我在某个变量周围使用了锁,则无需再将该变量声明为 volatile?
        • @Giorgi 是的 - volatile 保证的内存屏障将通过锁存在
        【解决方案6】:

        多个线程可以访问一个变量。 最新更新将在变量上

        【讨论】:

          【解决方案7】:

          如果您使用的是 .NET 1.1,则在执行双重检查锁定时需要使用 volatile 关键字。为什么?因为在 .NET 2.0 之前,以下场景可能会导致第二个线程访问非空但未完全构造的对象:

          1. 线程 1 询问变量是否为空。 //if(this.foo == null)
          2. 线程 1 确定变量为空,因此进入锁。 //lock(this.bar)
          3. 线程 1 再次询问变量是否为空。 //if(this.foo == null)
          4. 线程 1 仍然确定变量为空,因此它调用构造函数并将值分配给变量。 //this.foo = new Foo();

          在 .NET 2.0 之前,可以在构造函数完成运行之前为 this.foo 分配新的 Foo 实例。在这种情况下,第二个线程可能会进入(在线程 1 调用 Foo 的构造函数期间)并遇到以下情况:

          1. 线程 2 询问变量是否为空。 //if(this.foo == null)
          2. 线程 2 确定变量不为空,因此尝试使用它。 //this.foo.MakeFoo()

          在 .NET 2.0 之前,您可以将 this.foo 声明为 volatile 以解决此问题。从 .NET 2.0 开始,您不再需要使用 volatile 关键字来完成双重检查锁定。

          Wikipedia 实际上有一篇关于 Double Checked Locking 的好文章,并简要介绍了这个主题: http://en.wikipedia.org/wiki/Double-checked_locking

          【讨论】:

          • 这正是我在遗留代码中看到的并且对此感到疑惑。这就是我开始更深入研究的原因。谢谢!
          • 我不明白线程 2 将如何为 foo 赋值?线程 1 不是锁定 this.bar,因此只有线程 1 能够在给定的时间点初始化 foo 吗?我的意思是,您确实在再次释放锁后检查该值,无论如何它应该具有来自线程 1 的新值。
          • @gilmishal 我的理解是不是Thread2会给foo赋值,而是Thread2会使用一个未完全初始化的foo,即使它不是null .
          • @clcto 我不知道为什么我会这样说——我想我认为它是一个单例,所以所有线程都会通过双重检查锁定以类似的方式访问对象——在这种情况下我不确定如何需要 volatile。
          【解决方案8】:

          编译器有时会更改代码中语句的顺序以优化它。通常这在单线程环境中不是问题,但在多线程环境中可能是问题。请参见以下示例:

           private static int _flag = 0;
           private static int _value = 0;
          
           var t1 = Task.Run(() =>
           {
               _value = 10; /* compiler could switch these lines */
               _flag = 5;
           });
          
           var t2 = Task.Run(() =>
           {
               if (_flag == 5)
               {
                   Console.WriteLine("Value: {0}", _value);
               }
           });
          

          如果您运行 t1 和 t2,您会期望没有输出或“值:10”作为结果。可能是编译器在 t1 函数内切换行。如果 t2 然后执行,可能是 _flag 的值为 5,但 _value 的值为 0。所以预期的逻辑可能会被破坏。

          要解决此问题,您可以使用可应用于该字段的 volatile 关键字。此语句禁用编译器优化,因此您可以在代码中强制执行正确的顺序。

          private static volatile int _flag = 0;
          

          volatile 仅在确实需要时才应使用,因为它会禁用某些编译器优化,会损害性能。并非所有 .NET 语言都支持它(Visual Basic 不支持它),因此它阻碍了语言的互操作性。

          【讨论】:

          • 你的例子真的很糟糕。基于 t1 的代码首先编写的事实,程序员永远不应该对 t2 任务中的 _flag 的值有任何期望。先写!=先执行。编译器是否在 t1 中切换这两行并不重要。即使编译器没有切换这些语句,您在 else 分支中的 Console.WriteLne 仍可能执行,即使在 _flag 上使用了 volatile 关键字。
          • @jakotheshadows,你是对的,我已经编辑了我的答案。我的主要想法是表明当我们同时运行 t1 和 t2 时,预期的逻辑可能会被打破
          【解决方案9】:

          所以总结一下,这个问题的正确答案是: 如果您的代码在 2.0 运行时或更高版本中运行,则几乎从不需要 volatile 关键字,如果不必要地使用,弊大于利。 IE。永远不要使用它。但是在运行时的早期版本中,需要对静态字段进行正确的双重检查锁定。特别是类具有静态类初始化代码的静态字段。

          【讨论】:

            【解决方案10】:

            我发现Joydip Kanjilal 的这篇文章很有帮助!

            When you mark an object or a variable as volatile, it becomes a candidate for volatile reads and writes. It should be noted that in C# all memory writes are volatile irrespective of whether you are writing data to a volatile or a non-volatile object. However, the ambiguity happens when you are reading data. When you are reading data that is non-volatile, the executing thread may or may not always get the latest value. If the object is volatile, the thread always gets the most up-to-date value

            我把它留在这里以供参考

            【讨论】:

              【解决方案11】:

              只需查看volatile keyword 的官方页面即可看到典型用法示例。

              public class Worker
              {
                  public void DoWork()
                  {
                      bool work = false;
                      while (!_shouldStop)
                      {
                          work = !work; // simulate some work
                      }
                      Console.WriteLine("Worker thread: terminating gracefully.");
                  }
                  public void RequestStop()
                  {
                      _shouldStop = true;
                  }
                  
                  private volatile bool _shouldStop;
              }
              

              在 _shouldStop 声明中添加 volatile 修饰符后,您将始终获得相同的结果。但是,如果 _shouldStop 成员上没有该修饰符,则行为是不可预测的。

              所以这绝对不是什么彻头彻尾的疯狂

              存在负责 CPU 缓存一致性的 Cache coherence

              如果 CPU 使用 strong memory model (as x86)

              因此,对 volatile 字段的读取和写入不需要 x86 上的特殊指令:普通读取和写入(例如,使用 MOV 指令)就足够了。

              来自 C# 5.0 规范(第 10.5.3 章)的示例

              using System;
              using System.Threading;
              class Test
              {
                  public static int result;   
                  public static volatile bool finished;
                  static void Thread2() {
                      result = 143;    
                      finished = true; 
                  }
                  static void Main() {
              
                      finished = false;
                      new Thread(new ThreadStart(Thread2)).Start();
              
                      for (;;) {
                          if (finished) {
                              Console.WriteLine("result = {0}", result);
                              return;
                          }
                      }
                  }
              }
              

              产生输出:result = 143

              如果完成的字段没有被声明为易失性,那么在存储完成后存储结果对主线程可见是允许的,因此主线程可以从该字段中读取值 0结果。

              易变行为取决于平台,因此您应始终考虑在需要时使用volatile,以确保它满足您的需求。

              即使volatile 也无法阻止(各种)重新排序 (C# - The C# Memory Model in Theory and Practice, Part 2)

              尽管对 A 的写入是易失的,而对 A_Won 的读取也是易失的,但栅栏都是单向的,实际上允许这种重新排序。

              所以我相信如果您想知道何时使用volatile(对比lock 对比Interlocked),您应该熟悉内存栅栏(完整、一半)和同步需求。然后,您自己就可以得到宝贵的答案。

              【讨论】:

                猜你喜欢
                • 1970-01-01
                • 2023-03-08
                • 2011-01-21
                • 2011-02-10
                • 1970-01-01
                • 1970-01-01
                • 2011-03-30
                • 2011-06-30
                相关资源
                最近更新 更多