线程安全类应该在其构造函数的末尾有一个内存屏障吗?

在实现一个用于线程安全的类时,我是否应该在其构造函数的末尾包含一个内存屏障,以确保在访问它们之前已完成任何内部结构的初始化? 或者消费者有责任在将实例提供给其他线程之前插入内存屏障吗?

简化问题

由于初始化和线程安全类的访问之间缺乏内存屏障,下面的代码中是否存在种族风险可能会导致错误行为? 或者线程安全类本身是否应该防止这种情况?

ConcurrentQueue queue = null; Parallel.Invoke( () => queue = new ConcurrentQueue(), () => queue?.Enqueue(5)); 

请注意,程序可以接受任何内容,如果第二个委托在第一个委托之前执行,则会发生这种情况。 (null条件运算符?.在这里防止NullReferenceException 。)但是,程序抛出IndexOutOfRangeExceptionNullReferenceException ,多次排队5 ,陷入无限循环或执行任何其他操作都不应该是可接受的。由内部结构的种族危害引起的怪异事物。

详细问题

具体来说,假设我正在为队列实现一个简单的线程安全包装器。 (我知道.NET已经提供了ConcurrentQueue ;这只是一个例子。)我可以写:

 public class ThreadSafeQueue { private readonly Queue _queue; public ThreadSafeQueue() { _queue = new Queue(); // Thread.MemoryBarrier(); // Is this line required? } public void Enqueue(T item) { lock (_queue) { _queue.Enqueue(item); } } public bool TryDequeue(out T item) { lock (_queue) { if (_queue.Count == 0) { item = default(T); return false; } item = _queue.Dequeue(); return true; } } } 

一旦初始化,此实现是线程安全的。 但是,如果初始化本身由另一个消费者线程竞争,那么可能会出现竞争危险,即后一个线程将在内部Queue初始化之前访问该实例。 作为一个人为的例子:

 ThreadSafeQueue queue = null; Parallel.For(0, 10000, i => { if (i == 0) queue = new ThreadSafeQueue(); else if (i % 2 == 0) queue?.Enqueue(i); else { int item = -1; if (queue?.TryDequeue(out item) == true) Console.WriteLine(item); } }); 

上面的代码可以接受错过一些数字; 但是,如果没有内存屏障,它也可能会获得NullReferenceException (或其他一些奇怪的结果),因为内部QueueEnqueueTryDequeue被调用时尚未初始化。

线程安全类是否有责任在其构造函数的末尾包含一个内存屏障,或者是应该在类的实例化与其对其他线程的可见性之间包含内存障碍的消费者? .NET Framework中标准为线程安全的类的约定是什么?

编辑 :这是一个高级线程主题,所以我理解一些注释中的混淆。 如果在没有正确同步的情况下从其他线程访问,则实例可能显示为半成品。 在双重检查锁定的上下文中广泛讨论了该主题,这在ECMA CLI规范下被破坏而没有使用内存屏障(例如通过volatile )。 Per Jon Skeet :

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

没有任何内存障碍,它也在ECMA CLI规范中被打破。 有可能在.NET 2.0内存模型(比ECMA规范更强)下它是安全的,但我宁愿不依赖那些更强大的语义,特别是如果对安全性有任何疑问的话。

Lazy是线程安全初始化的一个非常好的选择。 我认为应该由消费者提供:

 var queue = new Lazy>(() => new ThreadSafeQueue()); Parallel.For(0, 10000, i => { else if (i % 2 == 0) queue.Value.Enqueue(i); else { int item = -1; if (queue.Value.TryDequeue(out item) == true) Console.WriteLine(item); } }); 

不相关,但仍然有趣的是,在Java ,构造函数内部编写的所有最终字段都会在构造函数存在之后写入两个栅栏: StoreStoreLoadStore – 这将使发布引用线程安全。

回答您的简化问题:

 ConcurrentQueue queue = null; Parallel.Invoke( () => queue = new ConcurrentQueue(), () => queue?.Enqueue(5)); 

你的代码肯定可能在queue有值之前尝试调用queue.Enqueue(5) ,但它不是你可以在Queue的构造函数中保护的任何东西。 在构造函数完成之前,实际上不会为queue分配对新实例的引用。

不,您在构造函数中不需要内存屏障。 尽管展示了一些创造性的想法,但你的假设是错误的。 没有线程可以获得半支持的queue实例。 只有在初始化完成时,新引用才对其他线程“可见”。 假设thread_1是第一个初始化queue线程 – 它通过ctor代码,但主堆栈中的queue引用仍为空! 只有当thread_1存在构造函数代码时,它才会分配引用。

见下面的评论和OP阐述的问题。