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

Dou*_*las 16 .net c# parallel-processing multithreading memory-barriers

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

简化问题:

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

ConcurrentQueue<int> queue = null;

Parallel.Invoke(
    () => queue = new ConcurrentQueue<int>(),
    () => queue?.Enqueue(5));
Run Code Online (Sandbox Code Playgroud)

请注意,程序可以接受任何内容,如果第二个委托在第一个委托之前执行,则会发生这种情况.(null条件运算符?.可以防止NullReferenceException这里.)然而,程序抛出一个IndexOutOfRangeException,多次入NullReferenceException5,陷入无限循环,或者做任何由种族危险引起的其他奇怪事情都是不可接受的关于内部结构.

详细问题:

具体来说,假设我正在为队列实现一个简单的线程安全包装器.(我知道.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;
        }
    }
}
Run Code Online (Sandbox Code Playgroud)

一旦初始化,该实现是线程安全的.但是,如果初始化本身由另一个消费者线程竞争,那么可能会出现竞争危险,即后一个线程将在内部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);
    }
});
Run Code Online (Sandbox Code Playgroud)

上面的代码可以错过一些数字; 但是,如果没有内存屏障,它也可能会得到NullReferenceException(或其他一些奇怪的结果),因为内部Queue<T>没有被调用Enqueue或被TryDequeue调用的时间初始化.

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

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

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

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

Zei*_*kki 4

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);
    }
});
Run Code Online (Sandbox Code Playgroud)

  • @user3185569:我可能选择了一个不好的例子。代码删除一些数字是可以接受的。代码抛出“NullReferenceException”、“IndexOutOfRangeException”、打印重复的数字、陷入无限循环或执行由内部结构上的竞争危险引起的任何其他奇怪的事情是不可接受的。 (2认同)