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

30
当实现一个旨在保证线程安全的类时,我应该在其构造函数的末尾包含一个内存屏障,以确保任何内部结构在被访问之前已经完成初始化吗?还是消费者有责任在将实例提供给其他线程之前插入内存屏障?简化问题:下面的代码是否存在竞争风险,由于初始化和访问线程安全类之间缺少内存屏障而导致错误行为?或者线程安全类本身应该防止这种情况发生?
ConcurrentQueue<int> queue = null;

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

请注意,程序可以不将任何内容加入队列,这种情况可能会在第二个委托在第一个委托之前执行时发生。(空值条件运算符?.可防止此处出现NullReferenceException。)但是,程序不应该抛出IndexOutOfRangeExceptionNullReferenceException、多次将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);
    }
});

上面的代码可能会遗漏一些数字,但是如果没有内存屏障,由于内部的Queue<T>在调用EnqueueTryDequeue时尚未初始化,也可能会出现NullReferenceException(或其他奇怪的结果)。
线程安全类是否有责任在构造函数末尾包含内存屏障,还是消费者应该在类的实例化和对其他线程的可见性之间包含内存屏障?在.NET Framework中标记为线程安全的类的惯例是什么?
编辑:这是一个高级的线程主题,所以我理解一些评论中的困惑。如果没有适当的同步,实例从其他线程访问时可能会出现半成品。这个主题在双重检查锁定的上下文中广泛讨论,在ECMA CLI规范下,如果没有使用内存屏障(例如通过volatile),则双重检查锁定是无效的。根据Jon Skeet的说法:
Java内存模型不能确保构造函数在将新对象的引用分配给实例之前完成。 Java内存模型在1.5版本进行了重新设计,但是在没有volatile变量的情况下,双重检查锁定仍然存在问题(就像C#中一样)。如果没有任何内存屏障,ECMA CLI规范也无法解决该问题。可能在.NET 2.0内存模型(比ECMA规范更强)下是安全的,但是如果有任何安全方面的疑虑,最好不要依赖这些更强的语义。

2
你提到的 ConcurrentQueue<T> 的源代码在其构造函数中没有任何保护措施。这是你自己的判断。http://referencesource.microsoft.com/#mscorlib/system/Collections/Concurrent/ConcurrentQueue.cs,18bcbcbdddbcfdcb - Bradley Uffner
1
除了在构造函数中实际有异步调用的情况下,一个引用是否能被设置为在实例构造之前引用该实例? - Uueerdo
1
@IvanStoev 在单线程环境下是可以的,但在多线程环境下,您可以观察到操作顺序与单线程程序的保证不同。 您的CPU允许重新排序对完全不依赖于彼此的不同写入值。 - Servy
2
@Uueerdo 一个单线程程序无法观察到这些动作的顺序混乱。另一个线程观察另一个线程执行的操作的约束条件极少。就这个例子而言,第二个线程实际上可以观察到另一个线程的构造函数调用在构造函数本身完成运行之前返回实例。调用构造函数的线程无法观察到这种不寻常的顺序,但是任何其他线程都可以。 - Servy
1
@Douglas,我点赞你的问题是因为我觉得它很有趣。但是,如果像C#这样的高级语言不能提供如此简单的保证,那么我不知道我们在这里做什么。我放弃编程 :) - Ivan Stoev
显示剩余13条评论
4个回答

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);
    }
});

1
+1:鉴于我的不确定性,这是我目前所做的。但我想知道是否可以预期代码在没有来自消费者的线程同步的情况下正常工作。换句话说:如果我是实现线程安全类的人,如果消费者仍然需要使用Lazy<T>,那么我是否能够称呼我的类为“线程安全”? - Douglas
1
我不理解这个编辑。我的原始代码也不会执行两次初始化。 - Douglas
@Douglas,你在运行这段代码时,是否确实遇到了null队列?至少有一次吗? - Zein Makki
@user3185569 代码永远不会打印“null”,但如果在队列初始化之前处理它们,则可能会将项目丢弃。您假设第0次迭代在任何其他迭代开始之前完成。没有这样的保证。 - Servy
2
@user3185569:我可能选择了一个糟糕的例子。代码丢失一些数字是可以接受的。但代码抛出NullReferenceExceptionIndexOutOfRangeException,重复打印数字,陷入无限循环或出现其他由于内部结构中的竞争条件而引起的奇怪问题都是不可接受的。 - Douglas

0
线程安全的类在其构造函数末尾应该有内存屏障吗?
我认为没有必要。队列是一个本地变量,从一个线程分配并从另一个线程访问。这种并发访问应该进行同步,这是访问代码的责任。这与构造函数或变量类型无关,这种访问应始终明确同步,否则即使对于原始类型(即使分配是原子的),您也可能会陷入危险区域(可能会被某些缓存陷阱所困扰)。如果对变量的访问已经正确同步,则不需要在构造函数中提供任何支持。

1
不是说这个答案是错误的,但我觉得它超出了所问问题的范围。这个问题是关于类,而不是原始类型(通常是结构体)。在这种情况下,赋值的原子性应该被视为事实,而不是什么靠不住的东西。 - Theodor Zoulias

0
我将尝试回答这个有趣且清晰的问题,基于Servy和Douglas的评论以及其他相关问题的信息。以下仅是我的假设,而非来自可靠来源的实质性信息。
  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);
    }
});

在这个特定的例子中,实例化queue变量然后调用Parallel.For方法会更简单和高效。这样做将使显式的Volatile调用变得不必要。Parallel.For方法在内部使用TaskTPL在每个任务的开头/结尾包含适当的内存屏障。内存屏障是由.NET基础架构隐式自动生成的,并且由任何启动线程或导致委托在另一个线程上执行的内置机制自动处理。(citation
我要重申一下,我对上面提供的信息的正确性并不100%有信心。

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


在构造函数末尾添加Thread.MemoryBarrier();这一行并不足以使构造函数线程安全。但对于像我所介绍的线程安全类来说,这并不一定正确。是的,内存可见性是协作的,但是类的所有其他方法已经通过它们的lock语句确保了这一点,这会生成一个隐式的内存屏障。 - Douglas
@Douglas,你的ThreadSafeQueue<T>类在构造函数中创建了一个Queue<T>Queue<T>类的构造函数并没有做太多事情。它只是将静态字段T[] _emptyArray分配为私有字段T[] _array的值。但是你有多大把握,可以确保另一个线程在获得对新构造的ThreadSafeQueue<T>的引用时,不会看到_array具有其原始的null值,从而导致NullReferenceException - Theodor Zoulias
@Douglas提到了一个很好的观点,关于lock隐式插入的内存屏障。我之前没有想过这个问题。这可能是允许安全访问非易失性变量/字段(存储您类的实例)的关键因素。或者也可能不是。希望一些熟悉CLR/C#规范并且了解这方面知识的专家能够给我们启示! - Theodor Zoulias
我没有读过Igor,但许多世界专家都认为CLI模型太弱、模糊或者完全失效。可以参考Jon Skeet和Joe Duffy的两个例子。Jon SkeetJoe Duffy - Douglas
1
再次确认官方的CLR实现即使在ARM64上也能保护免受这种问题的影响: https://github.com/dotnet/runtime/issues/46911#issuecomment-760004625 - Kevin Gosse
显示剩余3条评论

-1

不,你在构造函数中不需要内存屏障。尽管你的假设展示了一些创造性的思考,但是它是错误的。没有线程可以获得一个半成品的queue实例。只有当初始化完成时,新的引用才对其他线程“可见”。假设thread_1是第一个初始化queue的线程——它通过ctor代码,但是主堆栈中queue的引用仍然为null!只有当thread_1退出构造函数代码时,它才会分配引用。

请参见下面的评论和OP详细说明问题。


1
很遗憾,我认为您错过了ECMA CLI内存模型的复杂性。您可以获得一个半成品的queue实例,使其对其他线程可见。 - Douglas
2
@Douglas 我得承认我没有考虑过这个问题。然而,在“System.Collections.Concurrent”类的构造函数中,您不会找到任何内存屏障的证据,这些构造函数根据定义是线程安全的。您的话题将线程安全定义扩展到了新的领域。而这非常棒 :) - shay__
1
提醒自己,比你认为的不知道的还要多,这总是很好的 :) - shay__
1
@Servy:我同意它的定义不够严谨。但是它经常出现在MSDN上:“ConcurrentQueue<T> 的所有公共和受保护成员都是线程安全的,可以从多个线程同时使用。”正如其他人指出的那样,ConcurrentQueue<T> 在构造函数末尾没有包含内存屏障,因此文档或实现可能存在错误。 - Douglas
1
还要注意的是,在lock代码块的开头或结尾之后不需要内存屏障。在锁定块边界处不允许重新排序读取或写入...这意味着如果你发现lock (obj) { },你可以假设没有写入或读取会从整个块的前面/后面移动到另一侧,或者从内部/外部移动到另一侧。 - Miguel Angelo
显示剩余13条评论

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接