【问题标题】:Should thread-safe class have a memory barrier at the end of its constructor?线程安全类是否应该在其构造函数的末尾有一个内存屏障?
【发布时间】:2016-12-17 07:53:10
【问题描述】:

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

简化问题

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

ConcurrentQueue<int> queue = null;

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

请注意,程序不排队是可以接受的,如果第二个委托在第一个委托之前执行就会发生这种情况。 (这里的空条件运算符?. 可以防止NullReferenceException。)但是,程序抛出IndexOutOfRangeExceptionNullReferenceException、入队5 多次是不可接受的,会卡在一个无限循环,或者做任何其他由内部结构上的竞争风险引起的奇怪事情。

详细问题

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

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&lt;T&gt; 初始化之前访问实例。作为一个人为的例子:

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(或其他一些奇怪的结果),因为在调用EnqueueTryDequeue 时内部Queue&lt;T&gt; 尚未初始化。

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

编辑:这是一个高级线程主题,所以我理解一些 cmets 的困惑。如果在没有正确同步的情况下从其他线程访问,实例可能显示为半生不熟。该主题在双重检查锁定的上下文中进行了广泛讨论,在 ECMA CLI 规范下不使用内存屏障(例如通过volatile)打破了双重检查锁定。每Jon Skeet:

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

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

【问题讨论】:

  • 您提到的ConcurrentQueue&lt;T&gt; 的源代码在其构造函数中没有任何保护。随心所欲。 referencesource.microsoft.com/#mscorlib/system/Collections/…
  • 除非构造函数中实际上有异步调用,否则可以在构造实例之前将引用设置为引用实例吗?
  • @IvanStoev 在单线程上下文中,是的,在多线程上下文中,您可以观察到操作顺序与单线程程序的保证不同。您的 CPU 可以将不同的写入重新排序为完全不同的值,这些值彼此不依赖。
  • @Uueerdo 单线程程序无法观察到这些乱序发生的动作。另一个线程观察在另一个线程上执行的操作对观察到的操作顺序的限制根本更少。就本示例而言,第二个线程实际上可以在构造函数本身完成运行之前观察另一个线程返回实例的构造函数调用。调用构造函数的线程无法观察到这种异常顺序,但任何其他线程都可以。
  • @Douglas 我赞成你的问题,因为我觉得它很有趣。但是如果像 C# 这样的高级语言不能提供这么简单的保证,那么我看不到我们在这里做什么。我退出编程:)

标签: c# .net multithreading parallel-processing memory-barriers


【解决方案1】:

Lazy&lt;T&gt; 是线程安全初始化的一个非常好的选择。我认为应该让消费者来提供:

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:考虑到我的不确定性,这就是我目前所做的。但我想知道代码是否应该在没有来自消费者的线程同步的情况下工作。换句话说:如果我是实现线程安全类的人,如果消费者仍然需要使用Lazy&lt;T&gt;,我是否可以将我的类称为“线程安全”?
  • 我不懂编辑。我的原始代码也永远不会执行两次初始化。
  • @Douglas 在运行此代码时,您真的有null 队列吗?至少一次?
  • @user3185569 代码永远不能打印null,但如果在队列初始化之前处理它们,它可能会将项目放在地板上。您假设第 0 次迭代在任何其他迭代开始之前完成。没有这样的保证。
  • @user3185569:我可能选择了一个不好的例子。代码删除一些数字是可以接受的。代码抛出NullReferenceExceptionIndexOutOfRangeException、打印重复的数字、陷入无限循环或做任何其他由内部结构的竞争风险引起的奇怪事情是不可接受的。
【解决方案2】:

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

我看不出这是什么原因。 queue 是从一个线程分配并从另一个线程访问的局部变量。这种并发访问应该是同步的,这是访问代码的责任。它与变量的构造函数或类型无关,这种访问应该始终显式同步,否则即使对于原始类型,您也会进入危险区域(即使分配是原子的,您也可能会被一些缓存陷阱捕获)。如果对变量的访问正确同步,则不需要构造函数的任何支持。

【讨论】:

  • 不用说这个答案是不正确的,我觉得它冒险超出了所问问题的范围。这个问题是关于类的,而不是原始类型(通常是结构)。在这种情况下,atomicity of assignment 应该被视为事实,而不是不确定的东西。
【解决方案3】:

我将尝试根据 Servy 和 Douglas 的 cmets 以及来自其他相关问题的信息来回答这个有趣且精心提出的问题。以下只是我的假设,并非来自可靠来源的可靠信息。

  1. 线程安全的类具有可以被多个线程同时安全调用的属性和方法,但它们的构造函数不是线程安全的。这意味着线程完全有可能“看到”具有无效状态的线程安全类的实例,前提是该实例是由另一个线程同时构造的。

  2. 在构造函数末尾添加Thread.MemoryBarrier(); 行不足以使构造函数线程安全,因为该语句仅影响运行构造函数的线程¹。其他可能同时访问在建实例的线程不受影响。内存可见性是协作的,一个线程无法通过以非协作方式更改另一个线程的执行流程(或使另一个线程正在运行的 CPU 内核的本地缓存无效)来改变另一个线程“看到”的内容。

  3. 确保所有线程都看到具有有效状态的实例的正确且可靠的方法是在所有线程中包含适当的内存屏障。这可以通过将实例声明为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);
    }
});

在这个特定的示例中,在调用Parallel.For 方法之前实例化queue 变量会更简单、更有效。这样做会使显式的Volatile 调用变得不必要。 Parallel.For 方法在内部使用Tasks,TPL 在每个任务的开始/结束处包含适当的内存屏障。内存屏障由 .NET 基础结构、任何启动线程或导致委托在另一个线程上执行的内置机制隐式自动生成。 (citation)

我再说一遍,我对上述信息的正确性不是 100% 有信心。

¹ 引用Thread.MemoryBarrier 方法的documentation同步内存访问如下:执行当前线程的处理器不能以这样一种方式重新排序指令,即在调用之前内存访问MemoryBarrier() 在调用 MemoryBarrier() 之后的内存访问之后执行。

【讨论】:

  • "在构造函数末尾添加Thread.MemoryBarrier(); 行不足以使构造函数线程安全。" lock 语句确保了这一点,这会产生隐式内存屏障。
  • @Douglas 你的ThreadSafeQueue&lt;T&gt; 类在其构造函数中创建了一个Queue&lt;T&gt;Queue&lt;T&gt;doesn't do much 的构造函数。它只是将静态字段T[] _emptyArray 分配为私有字段T[] _array 的值。但是,如果另一个线程被传递到一个新构造的ThreadSafeQueue&lt;T&gt; 的引用,那么您有多大把握不会看到_array 具有其原始null 值,从而导致NullReferenceException
  • 访问_array 的唯一方法是通过EnqueueTryDequeue 方法,这两种方法都在_array 实例之前采用lock(因此是隐式内存屏障)被访问。也就是说,我不确定隐式屏障是否可以防止 lock(x) 中的 x 仍然是 null,所以如果这就是你的意思,那么你有一个观点。
  • @Douglas 关于lock 隐式插入的内存屏障的好点。我没有想过。它可能是允许安全访问存储类实例的非易失性变量/字段的成分。或者可能不会。希望有研究过CLR/C#规范并了解这方面的高手,能不吝赐教!
  • 再一次确认官方 CLR 实现可以防止此类事情发生,即使在 ARM64 上也是如此:github.com/dotnet/runtime/issues/46911#issuecomment-760004625
【解决方案4】:

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

请参阅下面的 cmets 和 OP 详细说明的问题。

【讨论】:

  • 不幸的是,我认为您错过了 ECMA CLI 内存模型的复杂性。您可以获得一个对其他线程可见的 queue 的半生不熟的实例。
  • 不幸的是,这个答案基本上是一厢情愿。它假定没有同步。
  • @Douglas 我不得不承认我没有考虑过。然而,您在System.Collections.Concurrent 类ctor 中没有发现任何内存屏障的证据,它们根据定义 是线程安全的。您的主题将线程安全定义扩展到新区域。这很酷:)
  • System.Collections.Concurrent classes ctors which are by definition thread safe 这不是真的。 根据定义,它们并不安全。它们只是类型的实现,就像其他任何类型一样。与你或我编写的代码相比,它们出错的可能性要小得多,但它们也不是不可能出错;他们的代码根据定义并不正确,而是对其正确性有很高的信心。
  • 提醒您,您不知道的比您认为自己不知道的要多得多:)
猜你喜欢
  • 1970-01-01
  • 2010-10-13
  • 1970-01-01
  • 2012-05-27
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多