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

有人能很好地解释一下c#中的volatile关键字吗?它能解决哪些问题,不能解决哪些问题?在哪些情况下,它将节省我使用锁定?

101035 次浏览

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

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

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

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

你可以把volatile关键字看作是对编译器说“我想让你把这个值存储在内存中”。这保证了第二个线程检索到最新的值。

如果你想稍微了解一下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,reg)中直接修改内存。创建代码的方式确保循环计数器的值在单个CPU内核的上下文中始终是最新的。对数据的任何操作都不会导致损坏或数据丢失(因此不要使用load/inc/store,因为在inc期间值可能会改变,从而在store上丢失)。由于中断只能在当前指令完成后才能被服务,因此数据永远不会被损坏,即使是在内存未对齐的情况下。

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

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

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

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

我认为没有比埃里克。更好的人来回答这个问题了(在原文中强调):

在c#中,“volatile”的意思不仅仅是“确保编译器和 抖动不执行任何代码重排序或寄存器缓存 这个变量的优化”。它还意味着“告诉处理器去做” 做任何他们需要做的事情,以确保我正在阅读 最新的值,即使这意味着停止其他处理器和制作 它们同步主存和缓存".

事实上,最后一点是谎言。volatile读取的真正语义 书写要比我在这里概述的复杂得多;在 事实他们实际上不能保证每个处理器都停止它 正在执行并从主存更新缓存。相反,它们提供的是 weak保证在读取和之后如何访问内存 可以观察到写操作的顺序是相对于彼此的。 某些操作,如创建新线程、输入锁或 使用其中的联锁族方法介绍更强 关于顺序观察的保证。如果你想了解更多细节, 请阅读c# 4.0规范的3.10和10.5.3节 < p >坦白说,我不建议你做一个不稳定场。挥发性 字段表明你正在做一些完全疯狂的事情:你是 试图在两个不同的线程上读写相同的值 没有锁到位。锁保证内存读取或 内部修改的锁观察一致,锁有保证 每次只有一个线程访问给定的内存块,等等 上。锁太慢的情况有很多 小,你会得到错误的代码的概率 因为你不明白确切的内存模型是非常大的。我 不要尝试编写任何低锁代码,除非是最琐碎的代码 互锁操作的用法。我把“volatile”的用法留到 真正的专家。< / p >

欲进一步阅读,请参阅:

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

如果你使用的是。net 1.1,在进行双重检查锁定时需要volatile关键字。为什么?因为在。net 2.0之前,下面的场景可能会导致第二个线程访问一个非空的,但还没有完全构造的对象:

  1. 线程1询问变量是否为空。 / /如果(这一点。Foo == null)
  2. 线程1确定变量为空,因此进入锁。 / /锁(this.bar)李< / > 线程1再次询问变量是否为空。 / /如果(这一点。Foo == null)
  3. 线程1仍然确定变量为空,因此调用构造函数并将值赋给变量。 / /这个。foo = new foo ();
  4. .

在。net 2.0之前,这个。在构造函数完成运行之前,foo可以被分配给foo的新实例。在这种情况下,第二个线程可以进来(在线程1调用Foo的构造函数期间),并经历以下情况:

    线程2询问变量是否为空。 / /如果(这一点。Foo == null)
  1. 线程2确定变量不是null,所以尝试使用它。 李/ / this.foo.MakeFoo () < / >

在. net 2.0之前,您可以声明这一点。Foo是不稳定的来解决这个问题。从。net 2.0开始,您不再需要使用volatile关键字来完成双重检查锁定。

维基百科实际上有一篇关于双重检查锁定的好文章,简要地谈到了这个话题: http://en.wikipedia.org/wiki/Double-checked_locking < / p >
编译器有时会改变代码中语句的顺序来优化它。通常这在单线程环境中不是问题,但在多线程环境中可能是问题。请看下面的例子:

 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。因此,预期的逻辑可能会被打破。

为了解决这个问题,你可以使用挥发性关键字,你可以应用到字段。此语句禁用编译器优化,以便您可以强制代码中的正确顺序。

private static volatile int _flag = 0;

你应该只在你真的需要它的时候才使用挥发性,因为它会禁用某些编译器优化,它会损害性能。它也不是所有。net语言都支持(Visual Basic不支持),因此它阻碍了语言互操作性。

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

我发现这篇文章由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

我就把它放在这里,供大家参考

只要看看动荡的关键字的官方页面,你就可以看到一个典型用法的例子。

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

将volatile修饰符添加到_shouldStop的声明中,您将总是得到相同的结果。但是,如果_shouldStop成员上没有这个修饰符,行为是不可预测的。

所以这肯定不是完全疯了

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

如果CPU使用强记忆模型(如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

如果finished字段没有被声明为volatile,那么在store to finished之后,允许主线程可以看到store to result,因此主线程可以从字段结果中读取值0。

Volatile行为是平台相关的,所以你应该总是考虑在需要时使用volatile,以确保它满足你的需求。

即使volatile也不能阻止(所有类型)重排序(c#内存模型的理论和实践,第2部分)

尽管对A的写操作是不稳定的,从A_Won的读操作也是不稳定的,但栅栏都是单向的,实际上允许这种重新排序。

所以我相信,如果你想知道什么时候使用volatile (vs lock vs Interlocked),你应该熟悉内存围栏(full, half)和同步的需求。那为了你自己,你自己去找答案吧。