关于多线程:何时在C#中使用volatile关键字?

关于多线程:何时在C#中使用volatile关键字?

When should the volatile keyword be used in C#?

谁能对C#中的volatile关键字提供很好的解释? 它可以解决哪些问题,哪些不能解决? 在什么情况下可以节省锁定的使用时间?


我认为没有比埃里克·利珀特(Eric Lippert)更好的人了(原文强调):

In C#,"volatile" means not only"make sure that the compiler and the
jitter do not perform any code reordering or register caching
optimizations on this variable". It also means"tell the processors to
do whatever it is they need to do to ensure that I am reading the
latest value, even if that means halting other processors and making
them synchronize main memory with their caches".

Actually, that last bit is a lie. The true semantics of volatile reads
and writes are considerably more complex than I've outlined here; in
fact they do not actually guarantee that every processor stops what it
is doing and updates caches to/from main memory. Rather, they provide
weaker guarantees about how memory accesses before and after reads and
writes may be observed to be ordered with respect to each other.
Certain operations such as creating a new thread, entering a lock, or
using one of the Interlocked family of methods introduce stronger
guarantees about observation of ordering. If you want more details,
read sections 3.10 and 10.5.3 of the C# 4.0 specification.

Frankly, I discourage you from ever making a volatile field. Volatile
fields are a sign that you are doing something downright crazy: you're
attempting to read and write the same value on two different threads
without putting a lock in place. Locks guarantee that memory read or
modified inside the lock is observed to be consistent, locks guarantee
that only one thread accesses a given chunk of memory at a time, and so
on. The number of situations in which a lock is too slow is very
small, and the probability that you are going to get the code wrong
because you don't understand the exact memory model is very large. I
don't attempt to write any low-lock code except for the most trivial
usages of Interlocked operations. I leave the usage of"volatile" to
real experts.

有关更多阅读,请参阅:

  • 了解低锁技术在多线程应用程序中的影响
  • 香ara挥发物

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#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):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
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],而volatile关键字告诉编译器应始终从存储器中读取/写入该值,并且永远不要将其分配给寄存器。甚至在更新计数器值时,编译器甚至不做为三个不同的步骤(加载eax,inc eax,保存eax)进行加载/增量/存储,而是直接在一条指令中修改内存(添加内存) ,reg)。代码的创建方式可确保在单个CPU内核的上下文中循环计数器的值始终是最新的。对数据的任何操作都不会导致损坏或数据丢失(因此,由于在inc期间可能会更改值,因此在存储上丢失,因此不使用load / inc / store)。由于仅在当前指令完成后才可以处理中断,因此即使内存未对齐,数据也永远不会损坏。

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

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

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

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


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

  • 线程1询问变量是否为空。
    //if(this.foo == null)
  • 线程1确定变量为null,因此输入一个锁。
    //锁定(this.bar)
  • 线程1再次询问变量是否为null。
    //if(this.foo == null)
  • 线程1仍确定该变量为null,因此它调用构造函数并将该值分配给该变量。
    //this.foo = new Foo();
  • 在.NET 2.0之前,可以在构造函数完成运行之前为this.foo分配新的Foo实例。在这种情况下,第二个线程可能会进入(在对Foo的构造函数的线程1调用期间),并且会遇到以下情况:

  • 线程2询问变量是否为null。
    //if(this.foo == null)
  • 线程2确定该变量不为null,因此尝试使用它。
    //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


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

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


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


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

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


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


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

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
     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关键字。该语句禁用编译器优化,因此您可以在代码中强制使用正确的顺序。

    1
    private static volatile int _flag = 0;

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


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


    推荐阅读