首页 > 解决方案 > 在多线程应用程序中同步属性值的正确方法

问题描述

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

首先,我最近了解到对象引用分配是原子的,至少在 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; } } };
    }

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

问题:

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

旁注:

  1. ContactOptions 类是故意不可变的,因为我不想同步或担心选项本身的原子性。它们可能包含任何类型的数据类型,因此我认为在需要更改时分配一组新的选项会更干净/更安全。
  2. 我熟悉获取一个值、使用该值、然后设置该值的非原子含义。考虑以下代码段:
    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;
    }

标签: c#multithreadinglockingthread-synchronizationinterlocked

解决方案


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

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

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

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

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

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

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

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

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

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


推荐阅读