C#中的Volatile关键字用法与锁定比较

37

我在使用volatile时,不确定是否必要。我相当确定在我的情况下,使用锁会过度。阅读这个线程(Eric Lippert的评论)使我对使用volatile感到不安:When should the volatile keyword be used in c# ?

我使用volatile,因为我的变量在多线程环境中使用,在这种情况下,该变量可能会被并发访问/修改,但我可以在没有任何伤害的情况下丢失一个加法(参见代码)。

我添加了“volatile”以确保没有出现错位:仅读取32位变量的另外32位在另一个获取中,并且可以通过中间来自另一个线程的写入而被拆分成2个。

我的先前假设(前面的声明)是否真的会发生?如果不是,那么“volatile”使用是否仍然必要(选项属性修改可以在任何线程中发生)。

阅读前两篇答案后。我想坚持认为,由于代码编写方式,如果由于并发性我们错过了增量(想要从2个线程增加,但由于并发性导致结果只增加了一个),则至少变量'_actualVersion'增加是重要的。

这是我在使用它的代码部分作为参考。它用于仅在应用程序空闲时报告保存操作(写入磁盘)。

public abstract class OptionsBase : NotifyPropertyChangedBase
{
    private string _path;

    volatile private int _savedVersion = 0;
    volatile private int _actualVersion = 0;

    // ******************************************************************
    void OptionsBase_PropertyChanged(object sender, System.ComponentModel.PropertyChangedEventArgs e)
    {
        _actualVersion++;
        Application.Current.Dispatcher.BeginInvoke(new Action(InternalSave), DispatcherPriority.ApplicationIdle);
    }

    // ******************************************************************
    private void InternalSave()
    {
        if (_actualVersion != _savedVersion)
        {
            _savedVersion = _actualVersion;
            Save();
        }
    }

    // ******************************************************************
    /// <summary>
    /// Save Options
    /// </summary>
    private void Save()
    {
        using (XmlTextWriter writer = new XmlTextWriter(_path, null))
        {
            writer.Formatting = Formatting.Indented;
            XmlSerializer x = new XmlSerializer(this.GetType());

            x.Serialize(writer, this);
            writer.Close();
        }
    }

不是你要求的(因此作为评论添加),但我可能会将Save()方法调用移动到'_savedVersion = _actualVersion'行的上方。这样,如果Save()抛出异常,_savedVersion变量就不会被错误地更新。 - Baldrick
为什么要从事件“Dispatcher”线程中保存?! - Ahmed KRAIEM
@Baldrick,我不能按照你说的(移动)做,因为这会涉及到多线程的问题(如果这样做,我可能会错过版本更改)。但是你在某种程度上是正确的,我应该防止我的代码出现异常。谢谢! - Eric Ouellet
@Ahmed,好问题。在大多数情况下,我的OptionBase类将从UI线程进行修改,并且许多属性将同时被修改。通过这种方式,我可以确保每个属性修改只保存一次。我希望从接口中抽象出加载和保存(对用户隐藏)。它应该更容易使用。选项也可以在代码中的任何地方逐个修改,通常在UI线程中进行。我更喜欢确保不必一直管理保存。 - Eric Ouellet
1
@EricOuellet - 我发现这个答案最好理解它们如何一起工作 - https://dev59.com/VHVC5IYBdhLWcg3w4Vb6 - Gregory William Bryant
@GregoryWilliamBryant,非常感谢。这太好心了。你是对的,Zach Saw 是我真正赞赏关于“Volatile”的第一个也是唯一一个很好的回答。 - Eric Ouellet
5个回答

67

我在不确定是否必要的地方使用了 volatile 关键字。

让我非常明确的一点是:

如果你对 C# 中 volatile 的含义不是百分之百清楚,那么请不要使用它。 这是一个专为专家设计的尖端工具。如果你不能描述出在弱内存模型结构下两个线程同时读写两个不同的 volatile 字段所允许的所有可能的内存访问重排序情况,那么你就不足以安全使用 volatile,并且你会犯错,就像你在这里做的一样,编写一个极其脆弱的程序。

在我的情况下,我相当确定锁会过度杀伤。

首先,最好的解决方案是根本不去碰这个东西。如果你不写试图共享内存的多线程代码,那么你就不必担心锁定,因为这很难正确处理。

如果你必须编写共享内存的多线程代码,则最佳实践是始终使用锁。锁几乎永远不会过度杀伤。未受争议的锁定的价格大约在十纳秒左右。你真的告诉我,多十纳秒会对你的用户产生影响吗?如果是这样,那么你有一个非常快速的程序和一个具有异常高标准的用户。

当然,如果锁内部的代码很昂贵,那么受争议的锁定价格就是任意高的。 不要在锁内执行昂贵的工作 ,以使争用的概率低。

只有在使用锁无法移除争用的情况下,你才应该考虑使用低锁解决方案。

我添加了 "volatile",以确保没有发生错误对齐:读取变量的32位和另外32位的时候没有出现问题,将它们分为两个部分后,另一个线程中的写入可能会破坏它们。

这句话告诉我你需要立即停止编写多线程代码。特别是低锁代码,只有专家才能编写。在重新开始编写多线程代码之前,您必须了解系统的实际工作原理。找一本好书并认真学习。

你的句子毫无意义,因为:

首先,整数已经只有32位。

第二,按照规范,int访问是保证原子性的!如果想要原子性,它已经具备了。

第三,是的,volatile访问始终是原子的,但这不是因为C#将所有volatile访问转换为原子访问!相反,除非该字段已经具有原子性,否则将volatile放在字段上是非法的。

第四,volatile的目的是防止C#编译器、jitter和CPU进行某些会在弱内存模型下改变程序含义的优化。特别是,volatile不能使++操作成为原子操作。(我在一家制造静态分析器的公司工作;我将使用您的代码作为我们“volatile字段上的不正确非原子操作”检查器的一个测试案例。对于我们想确保我们实际上正在发现人们编写的错误,充满现实主义错误的真实世界代码非常有帮助,所以感谢您发布这篇文章。)

看着你的实际代码:正如Hans指出的那样,volatile完全不足以使你的代码正确。最好的做法是我之前说的:不要让任何线程调用这些方法,除了主线程。计数逻辑错误应该是你最不用担心的事情。如果在序列化时其他线程正在修改对象的字段,那么什么会使其线程安全? 这是你首先应该担心的问题。


2
@EricOuellet:C# 语言 仅保证对于32位整数(或更小)、布尔值、单精度浮点数、引用和指针的读写是原子性的(当它们被对齐时)。在64位操作系统上,运行时 可能会保证64位双精度和整数的读写是原子性的,但这不是 语言 提供的保证,而是 运行时 提供的。 - Eric Lippert
22
@EricLippert 很棒的文章!不过在某些时候有点儿居高临下的语气([paraphrase] 我会在工作中使用你的代码举例说明愚蠢的编码方式)。我们真的不需要那样。 - estebro
3
@estebro说:我并没有傲慢的意思;当聪明的人发布他们编写的看似正确但实际是有问题的代码时,我真的感到高兴。这有助于我和我的同事编写分析器来找出有问题的代码,从而使影响您的关键任务代码更有可能是正确的。 - Eric Lippert
2
@estebro 我看 Eric Lippert 的回答没有任何居高临下的意味。以上代码非常糟糕,是一个很好的反面教材,可以用来教授如何不编写多线程代码。 - xxbbcc
4
@EricLippert,虽然这与回答本身无关,但我同意estebro的看法,你的回答给人以居高临下的感觉。我认为如果你编辑一下你的回答,它可能会更简洁、更有信息量。 - Gregory William Bryant
显示剩余7条评论

16

Volatile 是不足以使该代码安全的。您可以使用低级锁定,如 Interlocked.Increment() 和 Interlocked.CompareExchange(),但几乎没有理由认为 Save() 是线程安全的。它看起来确实试图保存正在被工作线程修改的对象。

在这里非常强烈地建议使用 lock,不仅可以保护版本号,还可以防止在序列化过程中对象发生更改。如果不这样做,您将得到完全太少且无法排错的损坏保存。


我在某种程度上同意。足够同意按照你的说法修改我的代码。但是,即使没有一致的保存,我也可以存活下来,但让我真正害怕的是在保存期间出现异常...虽然我也可以编写代码来防止这种情况发生,但锁定在这里成为一个明显的选择。谢谢! - Eric Ouellet

3
根据Joe Albahari在他同样优秀的书 C# 5.0 In A Nutshell 中的精彩文章(有关线程和锁),他在这里指出,即使使用了volatile关键字,写语句后跟读语句仍然可能被重新排序。
此外,他还指出,MSDN文档在这个问题上是错误的,并建议完全避免使用volatile关键字。他指出,即使你恰好理解了其中的微妙之处,随后的其他开发人员也能理解吗?
因此,使用锁不仅更“正确”,而且更易于理解,可以轻松添加新功能,以原子方式添加多个更新语句到代码中——这既不是volatile也不是像MemoryBarrier这样的屏障类所能做到的,速度非常快,并且更容易维护,因为较不经验的开发人员很少有引入微妙错误的机会。

1
关于您的陈述,即在使用volatile时是否可以将变量拆分为两个32位获取,如果您正在使用大于Int32的内容,则可能存在这种可能性。
只要您使用Int32,您就不会遇到您所说的问题。
但是,正如您在建议的链接中所读到的,volatile仅提供弱保证,我更喜欢锁定,以便在安全方面处于安全状态,因为今天的机器具有多个CPU,并且volatile不能保证另一个CPU不会突然出现并执行一些意外操作。
编辑:
您是否考虑过使用Interlocked.Increment?

谢谢idipous,我真的很喜欢你的回答。我更希望得到一些更深入的东西。我会等几天,如果没有更深入的回答,我会接受你的回答。 - Eric Ouellet

0

在您的InternalSave()方法中进行的比较和赋值操作即使使用了volatile关键字也不是线程安全的。如果您想避免使用锁,可以在代码中使用框架Interlocked类中的CompareExchange()和Increment()方法。


“Interlocked.Increment” 对于增量操作会很有效,但是 CompareExchange 无法处理 InternalSave 方法中的竞态条件。我认为他需要一个锁。 - Jim Mischel
这里的竞态条件并不是一个问题,因为代码编写方式的缘故。但就像Hans所说,如果我不使用锁来防止在修改其他线程的属性时进行保存操作,可能会导致不连贯的已保存数据。 - Eric Ouellet
@Jim,保存本身只能在UI线程上发生(仅一个线程),默认情况下是安全的。如果你说的是其他代码与保存之间的关系,那么是的,你是正确的,我需要一个锁。谢谢,Eric。 - Eric Ouellet

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