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

Proper way to synchronize a property's value in a multi-threaded application

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

首先,我最近了解到对象引用分配是原子的,至少在 64 位机器上是这样,这是我针对这个特定应用程序所关注的全部内容。以前,我锁定 class 属性的 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 的最新值时实现了获取 Options 的最新值的目标?使用此方法还有其他问题吗?
  2. 我相信锁有一些开销(转换为监视器。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 中的 lock/Interlock 用法将提供我正在寻找的同步。我已经尝试编写示例程序来强制执行陈旧的值场景,但我无法可靠地完成它。
    private ContactOptions _Options;
    public ContactOptions Options { 
        get { return Interlocked.CompareExchange(ref _Options, null, null); }
        set { _Options = value; } }

旁注:

  1. ContactOptions class 故意不可变,因为我不想同步或担心选项本身的原子性。它们可能包含任何类型的数据,所以我认为在需要更改时分配一组新的选项是很多 cleaner/safer。
  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;
    }

Correct me if I'm wrong here, but the above code does ensure that I'm achieving the goal of getting the latest value of Options when I get it in a thread safe manner? Any other issues using this method?

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

I believe there is some overhead with the lock (Converts to Monitor.Enter/Exit). I thought I could use Interlocked for a nominal performance gain, but more importantly to me, a cleaner set of code. Would the following work to achieve synchronization?

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

Since a reference assignment is atomic, is the synchronization (using either lock or Interlocked) necessary when assigning the reference? If I omit the set logic and only maintain the get, will I still maintain atomicity and synchronization? My hopeful thinking is that the lock/Interlock usage in the get would provide the synchronization I'm looking for. I've tried writing sample programs to force stale value scenarios, but I couldn't get it done reliably.

我认为在这种情况下,只要让字段变易变就足够了。据我了解,“过时值”的问题有些夸张,缓存一致性协议应该可以解决大多数问题。

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

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

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

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

  1. 是的,lock (OptionsLock) 确保所有线程将看到 Options 的最新值,因为 memory barriers 在进入和退出 lock 时被插入。
  2. lock 替换为 InterlockedVolatile class 的方法同样可以很好地实现最新值可见性目标。这些方法也插入了内存屏障。我认为使用 Volatile 可以更好地传达代码的意图:
public ContactOptions Options
{
    get { return Volatile.Read(ref _Options); }
    set { Volatile.Write(ref _Options, value); }
}
  1. getset 访问器中省略同步会使您自动进入 memory models, cache coherency protocols and CPU architectures. In order to know if it's safe to omit it, intricate knowledge of the targeted hardware/OS configuration is required. You will need either an expert 建议的大黑森林,或者您自己成为专家。如果您更愿意停留在软件开发领域,请不要忽略同步!