【问题标题】:Should volatile and readonly be mutually exclusive?volatile 和 readonly 应该互斥吗?
【发布时间】:2016-08-17 18:44:57
【问题描述】:

假设我正在设计一个包装内部集合的线程安全类:

public class ThreadSafeQueue<T>
{
    private readonly Queue<T> _queue = new Queue<T>();

    public void Enqueue(T item)
    {
        lock (_queue)
        {
            _queue.Enqueue(item);
        }
    }

    // ...
}

基于my other question,上面的实现是有缺陷的,因为当它的初始化与它的使用同时执行时可能会出现竞争风险:

ThreadSafeQueue<int> tsqueue = null;

Parallel.Invoke(
    () => tsqueue = new ThreadSafeQueue<int>(),
    () => tsqueue?.Enqueue(5));

上面的代码是可以接受的不确定性:该项目可能会或可能不会入队。但是,在当前的实现下,它也被破坏了,并且可能会导致不可预知的行为,例如抛出IndexOutOfRangeExceptionNullReferenceException、多次将同一项目入队或陷入无限循环。这是因为Enqueue 调用可能会在新实例分配给局部变量tsqueue 之后运行,但内部_queue 字段的初始化之前完成(或似乎完成)。

Jon Skeet

Java 内存模型不能确保构造函数在对新对象的引用分配给实例之前完成。 Java 内存模型在 1.5 版中进行了重新设计,但在没有 volatile 变量(如在 C# 中)的情况下,双重检查锁定仍然被破坏。

可以通过向构造函数添加内存屏障来解决这种竞争风险:

    public ThreadSafeQueue()
    {
        Thread.MemoryBarrier();
    }

等效地,可以通过使字段 volatile 更简洁地解决它:

    private volatile readonly Queue<T> _queue = new Queue<T>();

但是,后者被 C# 编译器禁止:

'Program.ThreadSafeQueue<T>._queue': a field cannot be both volatile and readonly

鉴于上述似乎是 volatile readonly 的合理用例,这种限制是否是语言设计中的缺陷?

我知道可以简单地删除readonly,因为它不会影响类的公共接口。但是,这无关紧要,因为通常readonly 也可以这样说。我也知道现有的问题“Why readonly and volatile modifiers are mutually exclusive?”;但是,这解决了一个不同的问题。

具体场景:此问题似乎会影响 .NET Framework 类库本身的 System.Collections.Concurrent 命名空间中的代码。 ConcurrentQueue&lt;T&gt;.Segment 嵌套类有几个仅在构造函数中分配的字段:m_arraym_statem_indexm_source。其中,只有m_index 被声明为只读;其他的不能——尽管它们应该——因为它们需要被声明为 volatile 以满足线程安全的要求。

private class Segment
{
    internal volatile T[] m_array;                  // should be readonly too
    internal volatile VolatileBool[] m_state;       // should be readonly too
    private volatile Segment m_next;
    internal readonly long m_index; 
    private volatile int m_low;
    private volatile int m_high; 
    private volatile ConcurrentQueue<T> m_source;   // should be readonly too

    internal Segment(long index, ConcurrentQueue<T> source)
    {
        m_array = new T[SEGMENT_SIZE];              // field only assigned here
        m_state = new VolatileBool[SEGMENT_SIZE];   // field only assigned here
        m_high = -1;
        m_index = index;                            // field only assigned here
        m_source = source;                          // field only assigned here
    }

    internal void Grow()
    {
        // m_index and m_source need to be volatile since race hazards
        // may otherwise arise if this method is called before
        // initialization completes (or appears to complete)
        Segment newSegment = new Segment(m_index + 1, m_source);
        m_next = newSegment;
        m_source.m_tail = m_next;
    }

    // ...
}

【问题讨论】:

  • 这个问题怎么可能不是基于意见的?除非您希望 Eric Lippert 插话,否则我看不出其他人如何回答这个问题,而不仅仅是猜测或意见?
  • 认为 Eric Lippert 可能会加入并不是没有道理的。如果他还记得有关 C# 的任何事情 ;)
  • 这个问题不是推测性的或基于意见的。我要求熟悉 C# 编译器和 .NET 内存模型的人回答上述问题是否真的是语言疏忽,或者我所遗漏的互斥背后是否有原因。诚然,大多数人都没有这些知识,但这并没有超出公共知识的范畴,尤其是在 .NET 最近开源的情况下。除非您的意思是只应在 StackOverflow 上发布琐碎的问题。
  • @Douglas 如果您对 Microsoft 有任何疑问,请询问 Microsoft,不要随便问街上的人。既然你知道这里没有人能回答这个问题,为什么要在这里问这个问题?是的,这个问题基于意见的。您是否认为这是一个“缺陷”只是一个意见问题,而不是事实。
  • 具体场景的更新似乎不太具体。它没有说明它“似乎如何影响代码”。或许我们可以举个例子?

标签: c# .net multithreading concurrency volatile


【解决方案1】:

readonly 字段可以从构造函数的主体中完全写入。实际上,可以使用volatile 访问readonly 字段来导致内存屏障。我认为你的情况是这样做的一个很好的例子(并且它被语言阻止了)。

确实,构造函数内部的写入在 ctor 完成后可能对其他线程不可见。它们甚至可能以任何顺序变得可见。这并不为人所知,因为它在实践中很少发挥作用。构造函数的结尾不是内存屏障(通常根据直觉假设)。

您可以使用以下解决方法:

class Program
{
    readonly int x;

    public Program()
    {
        Volatile.Write(ref x, 1);
    }
}

我测试了这个编译。我不确定是否允许将ref 形成readonly 字段,但可以。

为什么语言会阻止readonly volatile?我最好的猜测是,这是为了防止你犯错误。大多数情况下,这将是一个错误。这就像在lock 中使用await:有时这是完全安全的,但大多数时候并非如此。

也许这应该是一个警告。

Volatile.Write 在 C# 1.0 时不存在,因此将其作为 1.0 的警告的理由更强。现在有一个解决方法,这是一个错误的情况很强烈。

我不知道 CLR 是否不允许 readonly volatile。如果是,那可能是另一个原因。 CLR 具有一种允许大多数合理实现的操作的风格。 C# 比 CLR 更严格。所以我很确定(不检查)CLR 允许这样做。

【讨论】:

  • 这是一个很好的观点(和类比)。如果允许readonly volatile 产生的错误风险大于强制省略readonly 产生的错误风险,那么语言设计是合理的。尽管错误应该在理想情况下表明某些用法是合理的,并建议使用 Volatile.Write 替代方案。
【解决方案2】:
ThreadSafeQueue<int> tsqueue = null;

Parallel.Invoke(
    () => tsqueue = new ThreadSafeQueue<int>(),
    () => tsqueue?.Enqueue(5));

在您的示例中,问题是tsqueue 以非线程安全方式发布。在这种情况下,绝对有可能在 ARM 等架构上获得部分构造的对象。因此,将tsqueue 标记为volatile 或使用Volatile.Write 方法分配值。

这个问题似乎会影响 System.Collections.Concurrent 中的代码 .NET Framework 类库本身的命名空间。这 ConcurrentQueue.Segment 嵌套类有几个字段 只在构造函数中赋值:m_array, m_state, m_index, 和 m_source。其中,只有 m_index 被声明为只读;这 其他人不能——尽管他们应该——因为他们需要 声明为 volatile 以满足线程安全的要求。

将字段标记为 readonly 只是添加了一些约束,编译器会检查这些约束,并且 JIT 可能会在以后用于优化(但 JIT 足够聪明,即使在某些情况下没有该关键字,也可以确定该字段是 readonly)。但是由于并发性,标记这些特定字段volatile 更为重要。 privateinternal 字段由该库的作者控制,因此完全可以省略 readonly

【讨论】:

    【解决方案3】:

    首先,这似乎是语言的限制,而不是平台:

    .field private initonly class SomeTypeDescription modreq ([mscorlib]System.Runtime.CompilerServices.IsVolatile) SomeFieldName    
    

    编译得很好,我找不到任何引用说明 initonly(只读)不能与 modreq ([mscorlib]System.Runtime.CompilerServices.IsVolatile) (volatile) 配对。

    据我了解,所描述的情况可能来自低级指令交换。构造对象并将其放入字段的代码如下所示:

    newobj       instance void SomeClassDescription::.ctor()   
    stfld        SomeFieldDescription
    

    正如 ECMA 所说:

    newobj 指令分配与 ctor 关联的类的新实例,并将新实例中的所有字段初始化为 0(适当类型)或 null。然后它使用给定的参数以及新创建的实例调用构造函数。在构造函数被调用后,现在初始化的对象引用被压入堆栈。

    因此,据我了解,在不交换指令之前(恕我直言,这是可能的,因为返回创建对象的地址并填充此对象是存储到不同的位置),您总是会看到完全初始化的对象或 null从另一个线程读取时。这可以通过使用 volatile 来保证。它会阻止交换:

    newobj
    volatile.
    stfld
    

    附:它本身并不是一个答案。不知道为什么C#禁止readonly volatile

    【讨论】:

      猜你喜欢
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 2011-05-06
      • 1970-01-01
      • 1970-01-01
      • 2014-08-12
      相关资源
      最近更新 更多