假设我正在设计一个包装内部集合的线程安全类:
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));
上面的代码是可接受的非确定性的:该项目可能会或可能不会入队。但是,在当前的实现中,它也被破坏了,并且可能引起不可预测的行为,例如抛出IndexOutOfRangeException
,NullReferenceException
,多次排队同一个项目,或陷入无限循环。这是因为{/ 1}}调用可能在新实例已分配给本地变量Enqueue
后运行,但之前内部初始化tsqueue
字段完成(或似乎完成)。
在将对新对象的引用分配给实例之前,Java内存模型不能确保构造函数完成。 Java内存模型经历了1.5版的重写,但是在没有volatile变量的情况下,双重检查锁定仍然被破坏(在C#中)。
可以通过向构造函数添加内存屏障来解决此种族危险:
_queue
同样地,可以通过使字段变为更简洁来解决:
public ThreadSafeQueue()
{
Thread.MemoryBarrier();
}
但是,C#编译器禁止后者:
private volatile readonly Queue<T> _queue = new Queue<T>();
鉴于上述内容似乎是'Program.ThreadSafeQueue<T>._queue': a field cannot be both volatile and readonly
的合理用例,这种限制是否是语言设计中的一个缺陷?
我知道可以简单地删除volatile readonly
,因为它不会影响该类的公共接口。然而,这不是重点,因为readonly
一般来说也是如此。我也知道现有的问题“Why readonly and volatile modifiers are mutually exclusive?”;然而,这解决了另一个问题。
具体方案:此问题似乎会影响.NET Framework类库本身的readonly
命名空间中的代码。 ConcurrentQueue<T>.Segment
嵌套类具有几个仅在构造函数中分配的字段:System.Collections.Concurrent
,m_array
,m_state
和m_index
。其中,只有m_source
被声明为只读;其他人不可能 - 虽然他们应该 - 因为他们需要被宣布为挥发性的,以满足线程安全的要求。
m_index
答案 0 :(得分:4)
readonly
字段可以从构造函数的主体完全写入。实际上,可以使用volatile
对readonly
字段的访问来引起内存屏障。我认为,你的情况是这样做的一个好例子(并且它被语言所阻止)。
在ctor完成后,在构造函数内部进行的写入可能对其他线程不可见。它们甚至可以以任何顺序显示出来。这并不为人所知,因为它在实践中很少发挥作用。构造函数的结尾不是一个内存障碍(通常从直觉中假设)。
您可以使用以下解决方法:
class Program
{
readonly int x;
public Program()
{
Volatile.Write(ref x, 1);
}
}
我测试过这个编译。我不确定是否允许ref
到readonly
字段,但确实如此。
为什么语言会阻止readonly volatile
?我最好的猜测是,这是为了防止你犯错误。大多数时候这都是错误的。这就像在await
中使用lock
一样:有时候这是非常安全的,但大部分时间都没有。
也许这应该是一个警告。
在C#1.0时, Volatile.Write
不存在,因此将此警告为1.0的情况更强。现在有一个解决方法就是这个错误很强烈。
我不知道CLR是否禁止readonly volatile
。如果是,那可能是另一个原因。 CLR具有允许大多数合理实施的操作的风格。 C#比CLR更具限制性。所以我非常肯定(没有检查)CLR允许这样做。
答案 1 :(得分:1)
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被声明为readonly;该 其他人不可能 - 尽管他们应该 - 因为他们需要 声明为易变,以满足线程安全的要求。
将字段标记为readonly
只会添加编译器检查的一些约束,JIT以后可能会用于优化(但JIT足够聪明,即使在某些情况下没有该关键字,该字段也是readonly
)。但是由于并发性,标记这些特定字段volatile
更为重要。 private
和internal
字段受该图书馆作者的控制,因此可以在那里省略readonly
。
答案 2 :(得分:0)
首先,它似乎是语言所施加的限制,而不是平台:
.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
P.S。它本身不是答案。我不知道为什么C#禁止readonly volatile
。