当实现一个用于线程安全的类时,我是否应该在其构造函数的末尾包含一个内存屏障,以确保在访问它们之前已完成任何内部结构的初始化?或者消费者有责任在将实例提供给其他线程之前插入内存屏障吗?
简化问题:
由于初始化和线程安全类的访问之间缺少内存屏障,下面的代码中是否存在种族风险可能会导致错误行为?或者线程安全类本身是否应该防止这种情况?
ConcurrentQueue<int> queue = null;
Parallel.Invoke(
() => queue = new ConcurrentQueue<int>(),
() => queue?.Enqueue(5));
请注意,程序可以接受任何内容,如果第二个委托在第一个委托之前执行,则会发生这种情况。 (空条件运算符?.
可以防止此处出现NullReferenceException
。但是,程序抛出IndexOutOfRangeException
,NullReferenceException
,排队{{}不应该是可以接受的。 1}}多次,陷入无限循环,或做内部结构上由种族危害引起的任何其他奇怪的事情。
详述问题:
具体来说,假设我正在为队列实现一个简单的线程安全包装器。 (我知道.NET已经提供了ConcurrentQueue<T>
;这只是一个例子。)我可以写:
5
一旦初始化,此实现是线程安全的。但是,如果初始化本身由另一个消费者线程竞争,则可能会出现竞争危险,即后一个线程将在内部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
之前初始化1}}被称为。
线程安全类的责任是在其构造函数的末尾包含一个内存屏障,还是应该在类的实例化之间包含内存屏障的消费者以及它对其他线程的可见性? .NET Framework中标准为线程安全的类的约定是什么?
编辑:这是一个高级线程主题,所以我理解一些评论中的混淆。如果没有正确同步从其他线程访问,则实例可以显示为半成品。在双重检查锁定的上下文中广泛讨论了该主题,这在ECMA CLI规范下被破解而没有使用内存障碍(例如通过TryDequeue
)。每Jon Skeet:
在将对新对象的引用分配给实例之前,Java内存模型不能确保构造函数完成。 Java内存模型经历了1.5版的重写,但是在没有volatile变量的情况下,双重检查锁定仍然被破坏(在C#中)。
没有任何内存障碍,它也在ECMA CLI规范中被破坏了。有可能在.NET 2.0内存模型(比ECMA规范更强)下它是安全的,但我宁愿不依赖那些更强大的语义,特别是如果有的话。对安全性有任何疑问。
答案 0 :(得分:1)
Lazy<T>
是线程安全初始化的一个非常好的选择。我认为应该由消费者提供:
var queue = new Lazy<ThreadSafeQueue<int>>(() => new ThreadSafeQueue<int>());
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);
}
});
答案 1 :(得分:1)
无关,但仍然有趣的是,在Java
中,对于构造函数内部编写的所有最终字段,在构造函数存在后写入两个栅栏:StoreStore
和LoadStore
- 这将使发布引用线程安全。
答案 2 :(得分:0)
不,你在构造函数中不需要内存屏障。尽管展示了一些创造性思维,但你的假设是错误的。没有线程可以获得 queue
的半支持实例。只有在初始化完成时,新引用才对其他线程“可见”。假设thread_1是初始化queue
的第一个线程 - 它通过ctor代码,但主堆栈中的queue
引用仍然为空!只有当thread_1存在构造函数代码时,它才会分配引用。
见下面的评论和OP阐述的问题。
答案 3 :(得分:0)
回答简化问题:
ConcurrentQueue<int> queue = null;
Parallel.Invoke(
() => queue = new ConcurrentQueue<int>(),
() => queue?.Enqueue(5));
您的代码可能会在queue.Enqueue(5)
具有值之前尝试调用queue
,但在Queue
的构造函数中,您无法保护它们。 }。在构造函数完成之前,queue
实际上未获得对新实例的引用。
答案 4 :(得分:0)
我将根据 Servy 和 Douglas 的评论以及来自其他相关问题的信息,尝试回答这个有趣且提出得很好的问题。以下只是我的假设,并非来自可靠来源的可靠信息。
线程安全类具有可以被多个线程同时安全调用的属性和方法,但它们的构造函数不是线程安全的。这意味着线程完全有可能“看到”具有无效状态的线程安全类的实例,前提是该实例是由另一个线程同时构造的。
在构造函数末尾添加行 Thread.MemoryBarrier();
不足以使构造函数线程安全,因为该语句仅影响运行构造函数¹的线程。其他可能同时访问在建实例的线程不受影响。内存可见性是协作的,一个线程不能通过以非协作方式改变另一个线程的执行流程(或使另一个线程正在运行的 CPU 核心的本地缓存无效)来改变另一个线程“看到”的内容。
确保所有线程都看到具有有效状态的实例的正确和健壮的方法是在所有线程中包括适当的内存屏障。这可以通过将实例声明为 volatile
来实现,以防它是类的字段,或者使用静态 Volatile
类的方法:
ThreadSafeQueue<int> queue = null;
Parallel.For(0, 10000, i =>
{
if (i == 0)
Volatile.Write(ref queue, new ThreadSafeQueue<int>());
else if (i % 2 == 0)
Volatile.Read(ref queue)?.Enqueue(i);
else
{
int item = -1;
if (Volatile.Read(ref queue)?.TryDequeue(out item) == true)
Console.WriteLine(item);
}
});
在此特定示例中,在调用 queue
方法之前实例化 Parallel.For
变量会更简单、更有效。这样做会使显式 Volatile
调用变得不必要。 Parallel.For
方法在内部使用 Task
,TPL 在每个任务的开始/结束处包含适当的内存屏障。内存屏障是由 .NET 基础结构通过任何启动线程或导致委托在另一个线程上执行的内置机制隐式和自动生成的。 (citation)
我再说一遍,我对上述信息的正确性不是 100% 有信心。
¹ 引用Thread.MemoryBarrier
方法的documentation:同步内存访问如下:执行当前线程的处理器不能以内存访问优先的方式重新排序指令在调用 MemoryBarrier()
之后的内存访问之后执行调用 MemoryBarrier()
。
答案 5 :(得分:0)
线程安全类是否应该在其末尾有一个内存屏障 构造函数?
我看不出有什么原因。 queue
是从一个线程分配并从另一个线程访问的局部变量。这种并发访问应该是同步的,并且访问代码有责任这样做。它与构造函数或变量类型无关,此类访问应始终显式同步,否则即使对于原始类型,您也正在进入危险区域(即使分配是原子的,您也可能被某些缓存陷阱捕获)。如果对变量的访问正确同步,则不需要构造函数中的任何支持。