谁能很好地解释 C# 中的 volatile 关键字?它解决了哪些问题,没有解决哪些问题?在哪些情况下它会节省我使用锁定的时间?
11 回答
我认为没有比Eric Lippert更好的人来回答这个问题(强调原文):
在 C# 中,“volatile”不仅意味着“确保编译器和抖动不会对此变量执行任何代码重新排序或注册缓存优化”。这也意味着“告诉处理器做他们需要做的任何事情,以确保我正在读取最新的值,即使这意味着停止其他处理器并使它们与它们的缓存同步主内存”。
实际上,最后一点是谎言。volatile 读写的真正语义比我在这里概述的要复杂得多。事实上,它们实际上并不能保证每个处理器都会停止它正在做的事情并更新缓存到主内存/从主内存更新。相反,它们提供了较弱的保证,即可以观察到读取和写入之前和之后的内存访问如何相对于彼此进行排序。某些操作(例如创建新线程、进入锁或使用 Interlocked 系列方法之一)会为观察排序引入更强的保证。如果您想了解更多详细信息,请阅读 C# 4.0 规范的第 3.10 和 10.5.3 节。
坦率地说,我不鼓励你创建一个 volatile field。易失性字段表明您正在做一些彻头彻尾的疯狂事情:您试图在两个不同的线程上读取和写入相同的值而没有设置锁。锁保证观察到锁内的内存读取或修改是一致的,锁保证一次只有一个线程访问给定的内存块,等等。锁太慢的情况很少,因为不了解确切的内存模型而导致代码错误的可能性很大。除了互锁操作的最琐碎用法之外,我不会尝试编写任何低锁代码。我将“易失性”的用法留给真正的专家。
如需进一步阅读,请参阅:
如果您想稍微了解 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) 的锁定系统来正确处理数据访问。
如果您使用的是 .NET 1.1,则在执行双重检查锁定时需要 volatile 关键字。为什么?因为在 .NET 2.0 之前,以下场景可能会导致第二个线程访问非空但未完全构造的对象:
- 线程 1 询问变量是否为空。//如果(this.foo == null)
- 线程 1 确定变量为空,因此进入锁。//锁定(this.bar)
- 线程 1 再次询问变量是否为空。//如果(this.foo == null)
- 线程 1 仍然确定变量为 null,因此它调用构造函数并将值分配给变量。//this.foo = new Foo();
在 .NET 2.0 之前,可以在构造函数完成运行之前为 this.foo 分配新的 Foo 实例。在这种情况下,第二个线程可能进入(在线程 1 调用 Foo 的构造函数期间)并遇到以下情况:
- 线程 2 询问变量是否为空。//如果(this.foo == null)
- 线程 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
有时,编译器会优化一个字段并使用寄存器来存储它。如果线程 1 写入该字段并且另一个线程访问它,因为更新存储在寄存器(而不是内存)中,所以第二个线程将获得陈旧的数据。
您可以将 volatile 关键字视为对编译器说“我希望您将此值存储在内存中”。这保证了第二个线程检索最新的值。
来自MSDN: volatile 修饰符通常用于由多个线程访问的字段,而不使用 lock 语句来序列化访问。使用 volatile 修饰符可确保一个线程检索另一个线程写入的最新值。
CLR 喜欢优化指令,因此当您访问代码中的字段时,它可能并不总是访问该字段的当前值(它可能来自堆栈等)。将字段标记为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
我把它留在这里以供参考
只需查看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;
}
将 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
如果已完成的字段没有被声明为 volatile,那么在存储完成后,存储结果对主线程可见是允许的,因此主线程可以从字段结果中读取值 0。
易失性行为取决于平台,因此您应该始终考虑volatile
在需要时使用,以确保它满足您的需求。
甚至volatile
无法阻止(各种)重新排序(C# - The C# Memory Model in Theory and Practice,第 2 部分)
尽管对 A 的写入是易失的,而从 A_Won 的读取也是易失的,但栅栏都是单向的,实际上允许这种重新排序。
所以我相信如果你想知道什么时候使用volatile
(vs lock
vs Interlocked
) 你应该熟悉内存栅栏(full, half) 和同步的需求。然后,您自己就可以得到宝贵的答案。
编译器有时会更改代码中语句的顺序以对其进行优化。通常这在单线程环境中不是问题,但在多线程环境中可能是问题。请参见以下示例:
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 不支持它),因此它阻碍了语言的互操作性。
综上所述,问题的正确答案是:如果您的代码在 2.0 运行时或更高版本中运行,则几乎不需要 volatile 关键字,如果不必要地使用,弊大于利。IE 永远不要使用它。但是在运行时的早期版本中,需要对静态字段进行正确的双重检查锁定。特别是类具有静态类初始化代码的静态字段。
多个线程可以访问一个变量。最新更新将在变量上