使用volatile或内存屏障实现双重锁定

4

这是我已经使用了几个月没有问题的代码。

public sealed class Singleton 
{
    private static Singleton value;
    private static object syncRoot = new Object();
    public static Singleton Value 
    {
        get 
        {
            if (Singleton.value == null) 
            {
                lock (syncRoot) 
                {
                    if (Singleton.value == null) 
                    {
                        Singleton.value = new Singleton();
                    }
                }
            }
            return Singleton.value;
        }
    }      
}

然而,我发现了这个链接,它概述了上述问题。

a) 写入Singleton.value = new Singleton();可能会被处理器缓存,因此另一个线程可能无法看到它。为了解决这个问题,使用volatile关键字。

Q(1): C#的lock关键字不需要处理吗?

b) 在同一篇文章中,另一个更好的解决方案是避免使用volatile,在写入Singleton.value后引入System.Threading.Thread.MemoryBarrier();

问题:

Q(2) 我不太明白为什么需要在写操作之后使用MemoryBarrier()。有哪些可能导致其他线程将Singleton.value视为null的重新排序方式?lock甚至可以阻止其他线程读任何内容。

Q(3) 屏障只会维护顺序,但如果值仍然从某些缓存中读取,还需要volatile吗?

Q(4) 内存屏障是否真的需要在那里,因为C#的lock本身就放置了它?

最后,
我需要更新我的代码采用哪种方法或者现有代码已经足够好了吗?

编辑
提出了使用Lazy初始化的答案。我懂了。

但他们试图通过使用volatile和memorybarrier来实现lock不能保证的目标是什么?


6
另见C#中实现Singleton模式 - Dustin Kingen
我甚至不确定你在问什么。你只是想要实现一个单例吗? - maccettura
5
有些微软员工可能永远无法完全从处理Itanium处理器的经历中恢复过来。遗憾的是,他们在使用volatile控制该处理器时所做的愚蠢行为也出现在C#规范中。这些已经不再相关了。现在请使用Lazy类。 - Hans Passant
好的,但有人能解释一下当时他们试图通过memorybarrier和volatile实现什么吗? - Frank Q.
1
@FrankQ。"执行当前线程的处理器不能重新排序指令,以使在调用MemoryBarrier之前的内存访问在执行MemoryBarrier之后的内存访问之后执行。" 因此,如果没有MemoryBarrier,Itanium处理器将重新排序指令。 - Dustin Kingen
1
这篇链接的MSDN文章基本上是有缺陷的。单独的内存屏障是无用的 - 你需要在读取时再加一个屏障。 - Douglas
2个回答

9
这是我已经使用了几个月且没有问题的代码。
如果有一亿分之一的失败机会,而代码每天在一千台机器上运行一千次,那么平均每三年就会出现一个不可能调试的关键性失败。
如果它只在特定的硬件上失败,并且您在x86上进行所有测试,则永远不会看到该失败。
不存在测试低锁代码正确性的方法。代码要么可以被证明是正确的,要么就不是。您不能依靠测试。
锁关键字在其中一个读取操作上被省略了。
锁防止其他线程甚至读取任何内容。
屏障只维护顺序,但如果仍然从某个缓存中读取该值,那么volatile是否仍然需要?
从缓存中读取等同于将读取向后移动时间;由volatile或显式屏障引起的屏障对如何观察这种向后移动施加限制。
既然C#锁本身放置了屏障,那么真的需要屏障吗?
锁关键字在其中一个读取操作上被省略了。
我永远不会编写像这样的代码。如果您需要延迟初始化,请使用Lazy。如果您需要单例,请使用单例模式的标准实现。不要自己解决这些问题,让专家为您解决这些问题。
但他们试图使用volatile和memorybarrier实现lock无法保证的功能是什么?
他们试图正确省略锁定,从而在非争用路径中节省几个纳秒。对于用户来说,这些纳秒有多重要,与难以调试的罕见关键性失败的成本相比如何?
每当您尝试省略锁定时,您就完全沉浸在低级内存模型的疯狂世界中。您必须假设所有内存都在不断变化,除非有东西使其静止;您必须假设所有合法的内存访问重新排序都是可能的,即使是大多数硬件上不可能的那些。您不知道未来会发明什么奇怪的硬件来运行您的代码。
别去那里。我不喜欢使用线程;如果我想并行化某些内容,我的首选是将虚拟机、容器或进程投入问题中。如果必须使用线程,请尽量不共享内存。如果必须共享内存,请使用专家构建的最高级别结构,例如Task和Lazy,而不是使用内存屏障和交错操作自己创建。

2
“我不喜欢使用线程” - 无价!大多数问题都不能通过这种方式解决。如果你的问题不能在一个“线程”上解决,那么使用x个“线程”也不会改善它的任何因素。 - leppie
1
不要自己解决这些问题;让专家为您解决这些问题。然后,您永远不会成为专家。我更喜欢:给定时间,尝试解决这些问题以了解专家处理的内容;如果没有时间,至少思考问题并选择安全第一。 - acelent
1
@acelent:这种通常明智的方法的问题在于,芯片制造商给我们带来了一个世界,在多线程程序中没有任何东西能够合理地工作。在过去的十年里,我对C#的内存模型进行了大量思考,并得出结论:我不理解它到足以自信地编写非平凡解决方案为止。我尽可能避免在线程之间共享内存;如果我不能避免,则会按照已经确立的正确模式精确地进行,而不是试图推理关于内存模型的疯狂世界。 - Eric Lippert
1
好的,想象一下,在[math.se]上有一个像你这样的声誉的人说:“不要自己解决这些问题;让专家为您解决这些问题。”或者在任何数学主题上说:“不要去那里。我不喜欢<subject>;”。与其他网站(如[softwareengineering.se](或[physics.se]))相比,[so]绝对是这些主题的网站。除此之外,我同意您在这个问题上发布和评论的内容。 - acelent
3
如果有人在数学领域说“你所谓的P=NP证明根本没有开始解决几十年来专家已知的二十几个问题,不要去那里,这是你浪费时间和打扰其他人的废话证明”,那么我会把这当作一种公共服务。我提供实用、可靠的工程建议。我们作为语言设计者已经失败了。我们构建的系统过于复杂,难以正确使用,并未提倡更简单、更安全的系统。我正努力以我所能的微小方式来缓解这种情况。 - Eric Lippert
如果问题缺乏努力和研究,我会同意你的观点。但这是一个主观看法,所以我就到此为止了。 - acelent

3

正如其他人所提到的,您可以使用Lazy来为您生成实例,这样就可以避免很多麻烦:

public sealed class Singleton 
{
    private static Lazy<Singleton> _value = new Lazy<Singleton>(() => new Singleton());

    public static Singleton Value => _value.Value;
}

正如比我聪明的人所指出的那样,大多数情况下,可以通过使用静态初始化程序来进一步简化:

public sealed class Singleton 
{
    public static Singleton Value = new Singleton();
}

参考Eric Lippert对我的回答的评论,了解这些方法之间的关键差异以及如何帮助您决定使用哪一种。


根本没有必要使用Lazy,只需直接初始化静态字段即可,因为它已经是*安全的。整个解决方案所需的仅仅是public static Singleton Value {get;} = new Singleton(); - Servy
3
@Servy: 虽然我完全同意你的观点——这通常是正确的做法99.999%的时间——但有一个区别。静态字段初始化器在第一次被访问时运行;而懒操作在属性第一次被访问时运行。如果存在一种情况,即操作非常昂贵在访问属性之前很长时间就可能访问类,则使用延迟初始化可能是值得的。 - Eric Lippert
2
@EricLippert 因此,在单例类具有不涉及访问单例实例的操作的情况下,使用Lazy是一个有用的功能(实际上并不是,因为在这种情况下真正的解决方案是将该逻辑移出类,因为它显然与单例分开)。即使您永远不需要它,也不应该到处使用Lazy,这是没有生产力的。 - Servy
我已更新我的答案,以反映使用简单的静态初始化程序。 - Jesse Carter

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