在多线程应用程序中同步属性值的正确方法

Posted

技术标签:

【中文标题】在多线程应用程序中同步属性值的正确方法【英文标题】:Proper way to synchronize a property's value in a multi-threaded application 【发布时间】:2021-12-27 21:08:51 【问题描述】:

我最近开始重新审视我的一些旧的多线程代码,并想知道它是否安全且正确(生产中还没有问题......)。特别是我是否正确处理对象引用?我已经阅读了大量使用简单原语(如整数)的示例,但与引用和任何可能的细微差别有关的例子并不多。

首先,我最近了解到对象引用分配是原子的,至少在 64 位机器上是我针对这个特定应用程序所关注的全部内容。以前,我锁定类属性的 get/sets 以避免破坏引用,因为我没有意识到引用分配是原子的。 例如:

    // Immutable collection of options for a Contact
    public class ContactOptions
    
        public string Email  get; 
        public string PhoneNumber  get; 
    
    
    // Sample class that implements the Options
    public class Contact
    
        private readonly object OptionsLock = new object();
        private ContactOptions _Options;
        public ContactOptions Options  get  lock(OptionsLock)  return _Options;  
            set  lock(OptionsLock)  _Options = value;   ;
    

现在我知道引用分配是原子的,我想“太好了,是时候移除这些丑陋且不必要的锁了!” 然后我进一步阅读并了解了线程之间的内存同步。现在我又开始保留锁以确保数据在访问时不会过时。例如,如果我访问联系人的选项,我想确保我始终收到最新分配的一组选项。

问题:

    如果我在这里错了,请纠正我,但上面的代码确实可以确保当我以线程安全的方式获取选项时,我实现了获取选项的最新值的目标?使用此方法还有其他问题吗? 我相信锁存在一些开销(转换为 Monitor.Enter/Exit)。我认为我可以使用 Interlocked 来获得名义上的性能提升,但对我来说更重要的是,一组更干净的代码。以下是否可以实现同步?
    private ContactOptions _Options;
    public ContactOptions Options  
        get  return Interlocked.CompareExchange(ref _Options, null, null); 
        set  Interlocked.Exchange(ref _Options, value);  
    由于引用分配是原子的,分配引用时是否需要同步(使用锁或互锁)?如果我省略了set逻辑,只维护get,我还会保持原子性和同步吗?我有希望的想法是 get 中的锁/互锁使用将提供我正在寻找的同步。我曾尝试编写示例程序来强制使用陈旧的值场景,但我无法可靠地完成它。
    private ContactOptions _Options;
    public ContactOptions Options  
        get  return Interlocked.CompareExchange(ref _Options, null, null); 
        set  _Options = value;  

旁注:

    ContactOptions 类是故意不可变的,因为我不想同步或担心选项本身的原子性。它们可能包含任何类型的数据类型,因此我认为在需要更改时分配一组新的选项会更干净/更安全。 我熟悉获取一个值、使用该值,然后设置该值的非原子含义。考虑以下 sn-p:
    public class SomeInteger
    
        private readonly object ValueLock = new object();
        private int _Value;
        public int Value  get  lock(ValueLock)  return _Value;  
            private set  lock(ValueLock)  _Value = value;   ;
        
        // WRONG
        public void manipulateBad()
        
            Value++;
        
        
        // OK
        public void manipulateOk()
        
            lock (ValueLock)
            
                Value++;
                // Or, even better: _Value++; // And remove the lock around the setter
            
        
    

重点是,我真的只关注内存同步问题。

解决方案: 我选择了 Volatile.Read 和 Volatile.Write 方法,因为它们确实使代码更明确,它们比 Interlocked 和 lock 更干净,而且比前面提到的更快。

    // Sample class that implements the Options
    public class Contact
    
        public ContactOptions Options  get  return Volatile.Read(ref _Options);  set  Volatile.Write(ref _Options, value);  
        private ContactOptions _Options;
    

【问题讨论】:

你可能对这个Eric Lippert answer about volatile感兴趣。 @JohnWu 谢谢,这种担忧正是我一直避免使用 volatile 的原因。我选择了 Volatile.Read/Write 以确保内存屏障满足我的需要,更明确,并且比 Interlocked 执行得更好,并且绝对比 lock 更快 Volatility 是不够的,因为 volatile 不会对写入进行排序。处理器 1 创建一个 ContactOptions 并将引用写入内存。但是 ContactOptions 的内容仍然位于 L1 缓存中,并且不会刷新到内存中。处理器 2 读取引用并尝试访问 ContactOptions 并获取未初始化的数据,因为处理器 1 尚未将其写出。或者处理器 2 可能会使用其自己的 L1 高速缓存中的内存,而不是从内存中读取。在写入之前需要一个释放屏障,在读取之前需要一个获取屏障。 【参考方案1】:
    是的,lock (OptionsLock) 确保所有线程都能看到Options 的最新值,因为在进入和退出lock 时会插入memory barriers。 将lock 替换为InterlockedVolatile 类的方法同样可以很好地实现最新值可见性目标。这些方法也插入了内存屏障。我认为使用Volatile 可以更好地传达代码的意图:
public ContactOptions Options

    get  return Volatile.Read(ref _Options); 
    set  Volatile.Write(ref _Options, value); 

    getset 访问器中忽略同步会使您自动进入memory models、缓存一致性协议和CPU 架构的大黑森林。为了知道省略它是否安全,需要对目标硬件/操作系统配置有复杂的了解。您将需要expert 的建议,或者自己成为专家。如果您更喜欢留在软件开发领域,请不要忽略同步!

【讨论】:

谢谢!我将使用 Volatile 类运行一些测试,它确实使意图更加明确。你是对的,不妨谨慎行事并保持 get/set 显式同步。【参考方案2】:

如果我在这里错了,请纠正我,但上面的代码确实确保我在以线程安全的方式获取选项时实现获取选项的最新值的目标?使用此方法还有其他问题吗?

是的,锁会发出内存屏障,因此它会确保从内存中读取值。除了可能比它必须的更保守之外,没有其他真正的问题。但我有一句话,如果有疑问,请使用锁。

我相信锁定有一些开销(转换为 Monitor.Enter/Exit)。我认为我可以使用 Interlocked 来获得名义上的性能提升,但对我来说更重要的是,一组更干净的代码。以下是否可以实现同步?

Interlocked 也应该发出内存屏障,所以我认为这应该或多或少做同样的事情。

由于引用分配是原子的,在分配引用时是否需要同步(使用锁或互锁)?如果我省略了set逻辑,只维护get,我还会保持原子性和同步吗?我有希望的想法是 get 中的锁/互锁使用将提供我正在寻找的同步。我已经尝试编写示例程序来强制使用陈旧的值场景,但我无法可靠地完成它。

我认为在这种情况下,仅使字段 volatile 就足够了。据我了解,“陈旧值”的问题有些夸张,缓存一致性协议应该解决大多数问题。

据我所知,主要问题是阻止编译器只是将值放入寄存器而不进行任何后续加载。并且volatile 应该防止这种情况,强制编译器在每次读取时发出负载。但在循环中重复检查值时,这主要是个问题。

但只查看单个属性并不是很有用。当您有多个需要同步的值时,问题经常会出现。一个潜在的问题是编译器或处理器对指令重新排序。锁和内存屏障可以防止这种重新排序,但如果这是一个潜在问题,最好锁定更大的代码部分。

总的来说,我认为在处理多个线程时偏执是谨慎的做法。使用多同步可能比使用少更好。一个例外是死锁可能是由于拥有太多锁而导致的。我对此的建议是在持有锁时要非常小心你所调用的内容。理想情况下,锁应该只持有很短的、可预测的时间。

还要继续使用纯函数和不可变数据结构。这些是避免担心线程问题的好方法。

【讨论】:

感谢您的反馈!我读到了 volatile,但得到了很多不同的印象,一些人认为它实现了同步,而另一些人则没有,并认为它具有更微妙的含义。对 msdn 上的笔记有什么想法? docs.microsoft.com/en-us/dotnet/csharp/language-reference/…我完全同意多线程偏执,所以我想我现在可能远离 volatile。 @heyufool1 我对注释的解释是它属于“不保证 volatile writes 的单个总排序”条款。因此,如果线程 1 写入一个值,线程 2 不能保证在下一个时钟周期看到它,但保证最终看到写入。但这与线程 1 稍后进行写入基本上没有什么不同。如果您需要在某些特定的总排序中完成操作,您最好将所有这些操作放在关键部分中。

以上是关于在多线程应用程序中同步属性值的正确方法的主要内容,如果未能解决你的问题,请参考以下文章

如何在多线程或基于 wcf 服务的应用程序中正确使用事件?

java同步锁的正确使用

为啥在多线程应用程序 C++ 中没有发生同步

C# this.Invoke()的作用与用法

如何在多线程中同步“for”循环计数器?

在JAVA中ArrayList如何保证线程安全