【发布时间】:2016-12-17 07:53:10
【问题描述】:
当实现一个线程安全的类时,我是否应该在其构造函数的末尾包含一个内存屏障,以确保任何内部结构在可以访问之前都已完成初始化?还是消费者有责任在将实例提供给其他线程之前插入内存屏障?
简化问题:
下面的代码中是否存在竞争风险,由于在初始化和线程安全类的访问之间缺乏内存屏障,可能会导致错误行为?或者线程安全类本身应该防止这种情况发生吗?
ConcurrentQueue<int> queue = null;
Parallel.Invoke(
() => queue = new ConcurrentQueue<int>(),
() => queue?.Enqueue(5));
请注意,程序不排队是可以接受的,如果第二个委托在第一个委托之前执行就会发生这种情况。 (这里的空条件运算符?. 可以防止NullReferenceException。)但是,程序抛出IndexOutOfRangeException、NullReferenceException、入队5 多次是不可接受的,会卡在一个无限循环,或者做任何其他由内部结构上的竞争风险引起的奇怪事情。
详细问题:
具体来说,假设我正在为队列实现一个简单的线程安全包装器。 (我知道.NET 已经提供了ConcurrentQueue<T>;这只是一个例子。)我可以写:
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(或其他一些奇怪的结果),因为在调用Enqueue 或TryDequeue 时内部Queue<T> 尚未初始化。
是线程安全类的责任在其构造函数的末尾包含一个内存屏障,还是消费者应该在类的实例化和它对其他线程的可见性之间包含一个内存屏障? .NET Framework 中标记为线程安全的类的约定是什么?
编辑:这是一个高级线程主题,所以我理解一些 cmets 的困惑。如果在没有正确同步的情况下从其他线程访问,实例可能显示为半生不熟。该主题在双重检查锁定的上下文中进行了广泛讨论,在 ECMA CLI 规范下不使用内存屏障(例如通过volatile)打破了双重检查锁定。每Jon Skeet:
Java 内存模型不能确保构造函数在对新对象的引用分配给实例之前完成。 Java 内存模型在 1.5 版中进行了重新设计,但在没有 volatile 变量(如在 C# 中)的情况下,双重检查锁定仍然被破坏。
没有任何内存障碍,它在 ECMA CLI 规范中也被打破了。在 .NET 2.0 内存模型(比 ECMA 规范更强)下它可能是安全的,但我宁愿不依赖那些更强大的语义,特别是如果对安全性有任何疑问。
【问题讨论】:
-
您提到的
ConcurrentQueue<T>的源代码在其构造函数中没有任何保护。随心所欲。 referencesource.microsoft.com/#mscorlib/system/Collections/… -
除非构造函数中实际上有异步调用,否则可以在构造实例之前将引用设置为引用实例吗?
-
@IvanStoev 在单线程上下文中,是的,在多线程上下文中,您可以观察到操作顺序与单线程程序的保证不同。您的 CPU 可以将不同的写入重新排序为完全不同的值,这些值彼此不依赖。
-
@Uueerdo 单线程程序无法观察到这些乱序发生的动作。另一个线程观察在另一个线程上执行的操作对观察到的操作顺序的限制根本更少。就本示例而言,第二个线程实际上可以在构造函数本身完成运行之前观察另一个线程返回实例的构造函数调用。调用构造函数的线程无法观察到这种异常顺序,但任何其他线程都可以。
-
@Douglas 我赞成你的问题,因为我觉得它很有趣。但是如果像 C# 这样的高级语言不能提供这么简单的保证,那么我看不到我们在这里做什么。我退出编程:)
标签: c# .net multithreading parallel-processing memory-barriers