易变性和只读是否相互排斥?

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

public class ThreadSafeQueue { private readonly Queue _queue = new Queue(); public void Enqueue(T item) { lock (_queue) { _queue.Enqueue(item); } } // ... } 

基于我的另一个问题 ,上面的实现是错误的,因为当它的初始化与其使用同时执行时可能会出现种族危险:

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

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

Per Jon Skeet :

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

可以通过向构造函数添加内存屏障来解决此种族危险:

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

同样,通过使字段变化可以更简洁地解决:

  private volatile readonly Queue _queue = new Queue(); 

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

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

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

我知道可以简单地删除readonly ,因为它不会影响类的公共接口。 然而,这是不重要的,因为一般来说,只能readonly相同的内容。 我也知道现有的问题“ 为什么只读和易变的修饰符互相排斥? “; 然而,这解决了另一个问题。

具体方案 :此问题似乎会影响.NET Framework类库本身的System.Collections.Concurrent命名空间中的代码。 ConcurrentQueue.Segment嵌套类有几个只在构造函数中分配的字段: m_arraym_indexm_sourcem_source 。 其中,只有m_index被声明为readonly; 其他人不能 – 尽管他们应该 – 因为他们需要被声明为易变的以满足线程安全的要求。

 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 m_source; // should be readonly too internal Segment(long index, ConcurrentQueue 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; } // ... } 

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

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

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

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

我测试了这个编译。 我不确定它是否被允许形成一个readonly字段的ref ,但确实如此。

为什么语言会阻止readonly volatile ? 我最好的猜测是,这是为了防止你犯错误。 大多数时候这都是错误的。 就像在lock内使用await一样:有时这是非常安全的,但大部分时间都不是。

也许这应该是一个警告。

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

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

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

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

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

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

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

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

编译好了,我找不到任何引用声明initonly(readonly)不能与modreq ([mscorlib]System.Runtime.CompilerServices.IsVolatile)volatile )配对。

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

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

正如ECMA所述:

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

所以,据我所知,直到指令没有被交换(这是因为返回创建的对象的地址并填充此对象是存储到不同的位置),你总是看到完全初始化的对象或从读取时的null另一个线程。 使用volatile可以保证这一点。 它会阻止交换:

 newobj volatile. stfld 

Ps它本身不是答案。 我不知道为什么C#禁止readonly volatile