当实现一个线程安全的类时,我是否应该在其构造函数末尾包含一个内存屏障,以确保任何内部结构在可以访问之前已完成初始化?或者消费者有责任在使实例可供其他线程使用之前插入内存屏障?
简化问题:
下面的代码中是否存在竞争危险,由于初始化和线程安全类的访问之间缺乏内存屏障,可能会导致错误的行为?或者线程安全类本身应该防止这种情况发生?
ConcurrentQueue<int> queue = null;
Parallel.Invoke(
() => queue = new ConcurrentQueue<int>(),
() => queue?.Enqueue(5));
请注意,程序不将任何内容放入队列是可以接受的,如果第二个委托在第一个委托之前执行,就会发生这种情况。 (空条件运算符?.
防止NullReferenceException
此处。)但是,程序抛出一个错误是不可接受的。IndexOutOfRangeException
, NullReferenceException
, 入队5
多次,陷入无限循环,或者做任何其他由内部结构的竞争危险引起的奇怪的事情。
详细问题:
具体来说,想象一下我正在为队列实现一个简单的线程安全包装器。 (我知道.NET已经提供了ConcurrentQueue<T> https://msdn.microsoft.com/en-us/library/dd267265(v=vs.110).aspx;这只是一个例子。)我可以写:
public class ThreadSafeQueue<T>
{
private readonly Queue<T> _queue;
public ThreadSafeQueue()
{
_queue = new Queue<T>();
// 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<T>
已初始化。作为一个人为的例子:
ThreadSafeQueue<int> queue = null;
Parallel.For(0, 10000, i =>
{
if (i == 0)
queue = new ThreadSafeQueue<int>();
else if (i % 2 == 0)
queue?.Enqueue(i);
else
{
int item = -1;
if (queue?.TryDequeue(out item) == true)
Console.WriteLine(item);
}
});
上面的代码遗漏一些数字是可以接受的;然而,如果没有内存障碍,它也可能会得到一个NullReferenceException
(或其他一些奇怪的结果)由于内部Queue<T>
尚未初始化Enqueue
or TryDequeue
叫做。
线程安全类有责任在其构造函数末尾包含内存屏障,还是使用者应该在类的实例化与其对其他线程的可见性之间包含内存屏障? .NET Framework 中标记为线程安全的类的约定是什么?
Edit:这是一个高级线程主题,所以我理解一些评论中的混乱。一个实例can如果从其他线程访问而没有正确的同步,则显示为半生不熟。本主题在双重检查锁定的上下文中进行了广泛讨论,在不使用内存屏障(例如通过volatile
). Per 乔恩·斯基特 http://csharpindepth.com/Articles/General/Singleton.aspx#dcl:
Java 内存模型不确保构造函数在对新对象的引用分配给实例之前完成。 Java 内存模型在 1.5 版本中进行了重新设计,但在此之后,如果没有 易失性变量,双重检查锁定仍然会被破坏(as in C#).
没有任何内存障碍,它在 ECMA CLI 规范中也被破坏了。在 .NET 2.0 内存模型(比 ECMA 规范更强)下它可能是安全的,但我不想依赖那些更强的语义,特别是如果对安全性有任何疑问的话。