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

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

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

1
这里有一个例子,展示了volatile关键字如何有效地发挥其预期的作用。假设我们想要实现一个简单版本的Task<TResult>类。我们的类只包含两个字段:_completed_result,并且只支持两个操作:设置_result和仅在_completed为真时读取它。让我们来看一个无锁实现这个简单类型的例子:
public class MyTask<TResult>
{
    private volatile bool _completed;
    private TResult _result;

    public void UnsafeSetResult(TResult result)
    {
        _result = result;
        _completed = true;
    }

    public bool TryGetResult(out TResult result)
    {
        if (_completed)
        {
            result = _result;
            return true;
        }
        result = default;
        return false;
    }
}
UnsafeSetResult 方法被称为 "不安全",因为它在整个 MyTask<TResult> 实例的生命周期中只能被调用一次。我们将在本答案的最后解决这个限制,但现在让我们假设通过我们应用程序的结构可以简单地强制执行这个规则。例如,我们可以有一个单独的专用线程负责创建 MyTask<TResult> 对象,并在它们上调用 UnsafeSetResult
我们必须回答的重要问题是:为什么在多线程环境中TryGetResult能够正常工作?是什么阻止了调用TryGetResult的线程接收到一个tornTResult值?答案在于_completed字段被声明为volatile,以及_completed_results字段的赋值和读取顺序。我们希望确保在将_result的值完全存储在字段中之前,没有线程会尝试读取它。请注意,我们对TResult泛型参数没有任何限制,因此它完全可以是一个大型结构,比如一个decimal、一个Int128、一个ValueTuple<long,long,long,long>等等。如果我们不小心,一个线程可能会读取一个半写入的_result值,其中一半的字节仍然未初始化。这被称为“tearing”,这是我们想要防止的灾难。
我们可以通过将_completed设置为true来确保不会发生撕裂,这是在我们分配_result之后,并在确认_completedtrue之后读取_result。在_completed字段上使用volatile关键字可以确保C#编译器和.NET Jitter不会发出访问/修改计算机内存的CPU指令以不同的顺序。如果你不知道的话,C#编译器、.NET Jitter和CPU处理器都可以重新排序程序的指令,只要这种重新排序不会影响在单个线程上运行时程序的行为。
让我们看看volatileUnsafeSetResult方法产生的确切影响。
它插入了一个内存屏障,防止处理器重新排序内存操作,具体如下:如果在代码中出现了读取或写入操作在此方法之前,处理器就不能将其移动到此方法之后。
换句话说,_result = result; 不能被移动到 _completed = true; 之后。
现在让我们看看 volatileTryGetResult 方法有什么影响:
它插入了一个内存屏障,防止处理器重新排序内存操作,具体如下:如果在代码中出现了读取或写入操作在此方法之后,处理器就不能将其移动到此方法之前。
换句话说,result = _result; 不能被移动到 if (_completed) 之前。
正如您所见,我们两个方法都需要内存屏障。如果我们移除其中一个内存屏障,我们的程序的正确性将无法保证。
最后让我们看看如何实现一个线程安全版本的UnsafeSetResult。我们需要一个过渡的“保留”状态,超出了bool字段的false/true值。所以我们将使用一个volatile int _state字段代替:
public class MyTask<TResult>
{
    private volatile int _state; // 0:incomplete, 1:reserved, 2:completed
    private TResult _result;

    public bool TrySetResult(TResult result)
    {
        if (Interlocked.CompareExchange(ref _state, 1, 0) == 0)
        {
            _result = result;
            _state = 2;
            return true;
        }
        return false;
    }

    public bool TryGetResult(out TResult result)
    {
        if (_state == 2)
        {
            result = _result;
            return true;
        }
        result = default;
        return false;
    }
}

实际的 Task<TResult> 类有一个类似的 internal volatile int m_stateFlags; 字段(源代码),在给 internal TResult? m_result; 字段赋值之前,其中的一个位会被原子地翻转(CompletionReserved源代码)。

1
换句话说,volatile 分别为加载和存储操作提供了获取/释放语义。请参考链接:https://preshing.com/20120913/acquire-and-release-semantics/. - undefined
1
@PeterCordes 感谢你的评论。我写这个答案是因为我觉得有趣的是如何将volatile应用于字段A,以保护非volatile字段B。我几乎完全使用了C#开发人员可用的术语和官方文档,以免让读者感到陌生。 :-) - undefined
1
是的,https://en.wikipedia.org/wiki/Memory_ordering 维基链接可能更好。或者也可以参考https://preshing.com/20120710/memory-barriers-are-like-source-control-operations/,这是一个有用的关于内存排序的心智模型(对一致共享状态的本地重新排序访问),我认为它很好地匹配了C#的内存模型。 - undefined
1
@PeterCordes 好的,我用“内存排序”维基百科链接替换了它。Jeff Preshing的博客文章可能更具信息性,但也更加C++中心化,可能会吓到这里的观众。 :-) - undefined
1
完全公平。我仍然强烈推荐给任何阅读这些评论的人。 - undefined
显示剩余3条评论

-1
文档中:
在多处理器系统上,volatile读操作不能保证获取到任何处理器写入该内存位置的最新值。
对我来说,这听起来并不像其他答案所说的“获得最新值”的保证。根据规范,唯一的保证似乎是:
volatile int v;

x = 5;
print(y);
...
v = 3; // previous read/write operations on this thread cannot be moved after this (even if there are no dependencies)

...

print(v); // following read/write operations on this thread cannot be moved before this (even if there are no dependencies)
x = 5;
print(y);
...

看你怎么理解吧。举个例子,如果线程1进行多次volatile写操作:
// thread 1
v1 = 1
...
v2 = 2

然后线程2将被保证看到v1被设置为1,然后v2被设置为2。但是例如线程间的顺序不能保证。来自Albahari的例子:
// thread 1
volatile int v1 = 0;
v1 = 1;
print(v2)

// thread 2
volatile int v2 = 0;
v2 = 1;
print(v1);

你可能会看到:
0
0

打印自从读取可以在写入之前移动。

规范也没有提到缓存。但是文档中在一个注释中提到了:

易失性读取和写入确保值被读取或写入内存,而不是缓存在处理器寄存器中。

如果这是真的,意味着你可以像这样在线程之间使用它们进行同步:

// main thread
v = false // at t = 0s

// worker thread 1
...
// value is written directly to memory
v = true // at t = 1s
...

// worker thread 2
...
// latest value from memory is retrieved
if (v)
{
  print("this is guaranteed to be printed after t = 1s")
}
...

这似乎与之前的注释相矛盾,即您不能假设您正在获取最新的值。

在日常编程中,我认为最好避免使用volatile,只在其特定的弱要求能够提供比锁等更强的同步机制更好的性能优化的情况下小心使用它。


我认为你的回答没有解决问题所问的内容。问题是询问“volatile解决了哪些问题,又解决不了哪些问题?”而你的回答基本上是“我不知道volatile解决了什么问题,所以我不鼓励你使用它,除非你需要性能并且小心使用。”这并没有帮助。我们已经有Eric Lippert通过Ohad Schneider的回答来劝阻人们使用volatile了。我们不需要更多的恐慌和消极情绪。人们应该基于知识和可靠信息做出决策,而不是在困惑中。 - Theodor Zoulias
@TheodorZoulias 答案是不使用它,因为文档中存在冲突的陈述,除非您了解优化。当有可能文档/实施修正这些矛盾时,给出具体的用例就没有意义了。本帖中几乎每一个其他答案都说在读取 volatile 时会得到最新值。我的回答则通过指出文档的说法与之相反,增加了讨论的内容。 - pooya13
你是否也建议微软停止使用volatile,就像这里CancellationTokenSource_state)一样,直到他们修复文档中的不一致之处?否则,如果允许微软使用它,为什么其他人不能使用呢? - Theodor Zoulias
基本上,这个帖子中的其他回答都说当读取一个volatile时,你会得到最新的值。这是因为在历史的某个时刻,文档中曾经写过这样的内容。随着计算机体系结构在多年间变得更加复杂,"最新值"的概念变得非常模糊,所以这个短语已经从文档中删除了。如果你想要知道多处理器系统上共享字段的最新值,使用lock并不会给你比使用volatile更及时的值。无论如何,系统的自然延迟都会对你施加影响。 - Theodor Zoulias

-10

多个线程可以访问变量。 变量将被更新为最新值。


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