什么时候应该在C#中使用volatile关键字?

Dor*_*oby 290 c# multithreading

任何人都可以在C#中对volatile关键字提供一个很好的解释吗?它解决了哪些问题,哪些问题没有解决?在哪些情况下它会节省我使用锁定?

Oha*_*der 266

我不认为有一个更好的人来回答这个问题,而不是Eric Lippert(强调原文):

在C#中,"volatile"不仅意味着"确保编译器和抖动不对此变量执行任何代码重新排序或寄存器缓存优化".它还意味着"告诉处理器做他们需要做的任何事情,以确保我正在读取最新的值,即使这意味着停止其他处理器并使它们与主存储器的缓存同步".

实际上,最后一点是谎言.易失性读写的真正语义比我在此概述的要复杂得多; 实际上,它们实际上并不能保证每个处理器都停止正在进行的操作并更新主存储器的缓存.相反,它们提供了关于如何观察读取和写入之前和之后的存储器访问相对于彼此进行排序的较弱保证.某些操作(如创建新线程,输入锁定或使用Interlocked系列方法)可以为观察排序提供更强有力的保证.如果您想了解更多详细信息,请阅读C#4.0规范的第3.10和10.5.3节.

坦率地说,我不鼓励你做一个不稳定的领域.易失性字段表明你正在做一些彻头彻尾的疯狂:你试图在两个不同的线程上读取和写入相同的值,而不是锁定到位.锁定保证锁内部读取或修改的内存一致,锁定保证一次只有一个线程访问给定的内存块,依此类推.锁定速度太慢的情况非常少,并且由于您不了解确切的内存模型而导致代码错误的可能性非常大.除了Interlocked操作最琐碎的用法之外,我不会尝试编写任何低锁代码.我将"挥发性"的用法留给了真正的专家.

进一步阅读请参阅:

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

Ski*_*izz 56

如果您想了解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;
}
Run Code Online (Sandbox Code Playgroud)

使用标准优化(发布)编译器设置,编译器创建以下汇编程序(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              
Run Code Online (Sandbox Code Playgroud)

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

现在循环使用volatile循环计数器.计数器存储在[esp]中,volatile关键字告诉编译器应始终从内存中读取/写入值,并且永远不会将其分配给寄存器.在更新计数器值时,编译器甚至不会执行加载/增量/存储作为三个不同的步骤(加载eax,inc eax,save eax),而是直接在单个指令中修改内存(添加内存) ,REG).创建代码的方式可确保循环计数器的值始终在单个CPU内核的上下文中保持最新.对数据的操作不会导致损坏或数据丢失(因此不使用load/inc/store,因为值可能会在inc期间发生变化,从而在商店中丢失).由于只有当前指令完成后才能处理中断,

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

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

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

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

  • 不完全是:http://www.ddj.com/hpc-high-performance-computing/212701484 (8认同)
  • Eric Lippert写道,C++中的volatile只能阻止编译器执行某些优化,而在C#中,volatile会在其他内核/处理器之间进行一些通信,以确保读取最新值. (4认同)
  • 他专门问了关于C#的问题,而这个答案是关于C++的。C# 中的 volatile 关键字与 C++ 中的 volatile 关键字的行为完全不一样,这一点并不明显。 (4认同)
  • 这是C++,但原理也适用于C#。 (3认同)

小智 38

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

  1. 线程1询问变量是否为空.//if(this.foo == null)
  2. 线程1确定变量为null,因此输入一个锁.//lock(this.bar)
  3. 如果变量为null,则线程1询问AGAIN.//if(this.foo == null)
  4. 线程1仍然确定变量为null,因此它调用构造函数并将值赋给变量.//this.foo = new Foo();

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

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

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

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

  • 这正是我在遗留代码中看到的并且对此感到疑惑.这就是我开始深入研究的原因.谢谢! (2认同)

Ben*_*oit 23

有时,编译器将优化字段并使用寄存器来存储它.如果线程1对字段进行写操作而另一个线程访问它,则由于更新存储在寄存器(而不是存储器)中,因此第二个线程将获得过时的数据.

您可以将volatile关键字视为编译器"我希望您将此值存储在内存中".这可以保证第二个线程检索最新值.


Dr.*_*Bob 21

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


Jos*_*gle 13

CLR喜欢优化指令,因此当您访问代码中的字段时,它可能无法始终访问字段的当前值(可能来自堆栈等).将字段标记为volatile确保指令访问字段的当前值.当程序中的并发线程或操作系统中运行的某些其他代码可以修改(在非锁定方案中)时,这非常有用.

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


Chr*_*ula 5

我发现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

我只是把它留在这里以供参考


Uch*_*cho 5

Simply looking into the official page for volatile keyword you can see an example of typical usage.

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;
}
Run Code Online (Sandbox Code Playgroud)

With the volatile modifier added to the declaration of _shouldStop in place, you'll always get the same results. However, without that modifier on the _shouldStop member, the behavior is unpredictable.

So this is definitely not something downright crazy.

There exists Cache coherence that is responsible for CPU caches consistency.

Also if CPU employs strong memory model (as x86)

As a result, reads and writes of volatile fields require no special instructions on the x86: Ordinary reads and writes (for example, using the MOV instruction) are sufficient.

Example from C# 5.0 specification (chapter 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;
            }
        }
    }
}
Run Code Online (Sandbox Code Playgroud)

produces the output: result = 143

If the field finished had not been declared volatile, then it would be permissible for the store to result to be visible to the main thread after the store to finished, and hence for the main thread to read the value 0 from the field result.

Volatile behavior is platform dependent so you should always consider using volatile when needed by case to be sure it satisfies your needs.

Even volatile could not prevent (all kind of) reordering (C# - The C# Memory Model in Theory and Practice, Part 2)

Even though the write to A is volatile and the read from A_Won is also volatile, the fences are both one-directional, and in fact allow this reordering.

So I believe if you want to know when to use volatile (vs lock vs Interlocked) you should get familiar with memory fences (full, half) and needs of a synchronization. Then you get your precious answer yourself for your good.