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

368
有人能给出一个关于C#中volatile关键字的好解释吗?它解决了哪些问题,又没有解决哪些问题?在哪些情况下使用它可以避免使用锁定?

8
为什么您想要减少使用锁呢?不需要竞争的锁只会给程序增加几个纳秒的时间。您真的不能承受这几个纳秒吗? - Eric Lippert
13个回答

309

我认为没有比Eric Lippert(原文重点)更适合回答这个问题的人了:

在C#中,“volatile”不仅意味着“确保编译器和jit不对此变量执行任何代码重新排序或寄存器缓存优化”,还意味着“告诉处理器尽其所能确保我正在读取最新值,即使这意味着停止其他处理器并使它们将主内存与其缓存同步”。实际上,最后一部分是错误的。易失性读写的真正语义比我在这里概述的要复杂得多;实际上,它们并没有确保每个处理器都停止正在进行的操作并更新缓存到/从主内存。相反,它们提供了关于在读写之前和之后的内存访问如何按顺序观察彼此的较弱保证。某些操作(例如创建新线程、进入锁定或使用Interlocked系列方法之一)引入了更强的有关排序观察的保证。如果您想了解更多细节,请阅读C# 4.0规范的3.10和10.5.3节。坦率地说,我不建议您制作易失性字段。易失性字段表明您正在做一些非常疯狂的事情:您正在尝试在两个不同的线程上读取和写入相同的值,而没有放置锁定。锁定保证在锁定内读取或修改的内存被观察为一致,锁定保证只有一个线程同时访问给定的内存块等等。需要使用锁定的情况非常少,而您由于不理解精确的内存模型而出错的概率非常大。除了最简单的Interlocked操作之外,我不尝试编写任何低锁定代码。我将“易失性”使用留给真正的专家。

进一步阅读请参见:


47
如果可以的话,我会给它点个踩。其中有很多有趣的信息,但并没有真正回答他的问题。他在询问与锁定相关的volatile关键字的用法。相当长一段时间(在2.0 RT之前),如果静态字段实例在构造函数中有任何初始化代码,那么使用volatile关键字是必要的才能正确地使其线程安全(请参见AndrewTek的答案)。仍有许多1.1 RT代码正在生产环境中使用,维护它的开发人员应该知道为什么需要这个关键字,以及是否可以安全地去掉它。 - Paul Easter
3
@PaulEaster,它可以用于双重检查锁定(通常在单例模式中),但并不意味着它应该这样做。依赖.NET内存模型可能是一个不良的实践 - 您应该依赖ECMA模型。例如,您可能希望有一天将其移植到mono上,而mono具有不同的内存模型。我也了解到,不同的硬件架构可能会改变事情。欲了解更多信息,请参见:https://dev59.com/uWw05IYBdhLWcg3wfh2g#7230679。对于更好的单例替代方案(适用于所有.NET版本),请参见:http://csharpindepth.com/articles/general/singleton.aspx。 - Ohad Schneider
9
换句话说,对于这个问题来说,正确的答案是:如果你的代码运行在2.0版本或更高版本的运行时上,那么 volatile 关键字几乎不再需要了,如果无谓地使用该关键字反而会带来负面影响。但是在早期版本的运行时中,对于静态字段的双重检查锁定来说,使用 volatile 关键字是必要的。 - Paul Easter
3
这是否意味着在以下情况下,锁和volatile变量是互斥的:如果我已经在某个变量周围使用了锁,则不需要再将该变量声明为volatile? - Giorgi Moniava
5
@Giorgi 是的 - 由volatile保证的内存栅栏将因锁的存在而存在。 - Ohad Schneider
显示剩余2条评论

54

如果您想更深入地了解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内核上具有较低的延迟,但从不具有更高的延迟。

现在讲述有易失性的循环计数器。计数器存储在[esp]中,易失关键字告诉编译器该值应始终从内存中读取/写入,不应分配给寄存器。当更新计数器值时,编译器甚至不会执行加载/递增/存储这三个步骤(加载eax,递增eax,保存eax)。相反,内存直接在单个指令中进行修改(add mem,reg)。代码创建方式确保循环计数器的值始终在单个CPU核心的上下文中保持最新。数据的任何操作都不会导致破坏或数据丢失(因此不使用load/inc/store,因为值可能会在inc期间更改从而在存储时丢失)。由于中断只能在当前指令完成后进行服务,即使使用不对齐的内存,数据也永远不会被破坏。

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

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

易失关键字的设计初衷是与IO操作一起使用,其中IO会不断变化但具有恒定的地址,例如内存映射的UART设备,编译器不应始终重用从地址读取的第一个值。

如果要处理大量数据或有多个CPU,则需要更高级别(操作系统)的锁定系统才能正确处理数据访问。


3
这是C++,但原则适用于C#。 - Skizz
11
Eric Lippert写道,C++中的volatile仅防止编译器执行一些优化,而在C#中,volatile还会与其他核心/处理器进行一些通信,以确保读取最新的值。 - Peter Huber
4
他特别询问了关于C#的问题,而这个答案是关于C++的。显然,C#中的volatile关键字的行为与C++中的volatile关键字完全不同。 - user3700562

48
如果您使用.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之前,此.foo可能被分配为Foo的新实例,在构造函数完成运行之前。在这种情况下,第二个线程可能会在线程1调用Foo的构造函数期间进入,并经历以下操作:
  1. 线程2询问变量是否为空。 //if(this.foo == null)
  2. 线程2确定变量不为空,因此尝试使用它。 //this.foo.MakeFoo()
在.NET 2.0之前,您可以将此.foo声明为volatile以解决此问题。自.NET 2.0以来,您不再需要使用volatile关键字来实现双重检查锁定。
Wikipedia实际上有一篇关于Double Checked Locking的好文章,并简要涉及了这个主题:http://en.wikipedia.org/wiki/Double-checked_locking

3
这正是我在遗留代码中看到的,所以我开始进行深入研究。谢谢! - Peter Porfy
1
我不明白线程2如何给foo赋值?难道线程1不是锁定了this.bar,因此只有线程1能在某个时间点初始化foo吗?我的意思是,在释放锁之后再次检查值时,它应该已经具有来自线程1的新值。 - gilmishal
1
我的理解是,Thread2不会给foo赋值,而是会使用一个未完全初始化的foo,即使它不是null。 - clcto
@clcto 我不确定为什么我会这样表达 - 我想我假设它是一个单例,所以所有线程都会通过双重检查锁定以类似的方式访问对象 - 在这种情况下,我不确定volatile是如何必要的。 - gilmishal
@gilmishal 我相信他们的意思是,在.NET 2.0之前,当出现this.foo = new Foo();这一行时,只要该赋值在下一行指令之前完成,编译器就允许在构造函数结束之前执行字段赋值。对于单个线程来说,在字段赋值和构造函数完成之间存在这样一个窗口并不是问题,但如果第二个线程在此期间遇到了双重检查锁定,则可能会在第一个线程完成构造之前尝试使用该字段。 - Spencer Bench

31
有时候编译器会对某个字段进行优化,并将其存储在寄存器中。如果线程1写入该字段并且另一个线程访问它,由于更新被存储在寄存器(而不是内存)中,第二个线程将得到旧数据。
你可以把 volatile 关键字理解为告诉编译器“我希望你把这个值存储在内存中”。这保证了第二个线程检索到最新的值。

23

MSDN: volatile修饰符通常用于被多个线程访问且没有使用锁语句来序列化访问的字段。使用volatile修饰符可以确保一个线程检索到另一个线程写入的最新值。


这似乎不是真的。根据文档所述:“在多处理器系统上,volatile读操作不能保证获取到由任何处理器写入该内存位置的最新值。” - pooya13
@pooya13去年我在GitHub上提了一个问题,关于文档中这个确切陈述的疑惑,你可能会觉得有趣。 - Theodor Zoulias

14

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

显然,您会失去一些优化,但它确实使代码更简单。


7

只需查看volatile关键字的官方页面,您就可以看到典型用法的示例。

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采用强内存模型(如x86),则读取和写入volatile字段不需要特殊指令:普通的读取和写入(例如使用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

如果字段finished未被声明为volatile,则存储器将可以在存储finished后向主线程可见,因此主线程可以从result字段读取值0。

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

即使使用volatile也不能防止(所有类型的)重排序(参见C#-C#内存模型的理论与实践第二部分

尽管对A的写入是volatile的,从A_Won的读取也是volatile的,但是栅栏都是单向的,并且实际上允许这种重排序。

所以我认为,如果你想知道何时使用volatile(与lockInterlocked相比),你应该熟悉内存栅栏(全、半)和同步需求。然后你就可以自己得到宝贵的答案,以便更好地使用。


6

我发现Joydip Kanjilal的这篇文章非常有帮助:

当你将一个对象或变量标记为volatile时,它就成为了一个可进行volatile读写操作的候选对象。需要注意的是,在C#中,无论你是向一个volatile对象还是一个非volatile对象写入数据,所有的内存写操作都是volatile的。然而,当你读取非volatile数据时会出现歧义。当你读取非volatile数据时,执行线程可能会得到最新值,也可能不会。但如果对象是volatile的话,线程总是能获取到最新的值。


1
编译器有时会更改代码中的语句顺序以进行优化。在单线程环境下通常不会出现问题,但在多线程环境下可能会成为问题。请参见以下示例:
 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,你期望的输出是没有任何输出或者"Value: 10"。这可能是编译器在t1函数内部切换了行。如果然后执行t2,那么_flag的值可能为5,但_value的值为0。因此,预期的逻辑可能会被打破。
为了解决这个问题,你可以使用volatile关键字来应用于该字段。这个语句禁用了编译器优化,所以你可以强制在代码中正确地排序。
private static volatile int _flag = 0;

只有在确实需要时,才应使用volatile,因为它会禁用某些编译器优化,从而影响性能。此外,它并不受所有.NET语言的支持(Visual Basic不支持),因此它会妨碍语言互操作性。


3
你的例子真的很糟糕。程序员不应该基于 t1 代码先被编写这个事实来对 t2 任务中 _flag 的值有任何期望。先被编写并不等于先被执行。即使编译器没有交换 t1 中那两行语句,_flag 上使用 volatile 关键字也可能无法保证 else 分支中的 Console.WriteLine 不会执行。请注意不要改变原文意思,同时也不要添加解释。 - Jakotheshadows
@jakotheshadows,你是对的,我已经编辑了我的答案。我的主要想法是展示当我们同时运行t1和t2时,预期的逻辑可能会被打破。 - Aliaksei Maniuk

1
所以,总结一下,回答这个问题的正确答案是:如果你的代码在2.0运行时或更高版本中运行,则几乎不需要使用volatile关键字,如果不必要地使用它会带来更多的坏处。也就是说,永远不要使用它。但是,在早期版本的运行时中,对于具有静态类初始化代码的静态字段进行正确的双重检查锁定是需要它的。

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