这个多线程单例模式更高效吗?

4

我在多线程环境下使用高吞吐量的Singleton。通常我会这样做:

public static Foo GetInstance()
{
    lock (Foo._syncLock)
    {
        if (Foo._instance == null)
            Foo._instance = new Foo();
        return Foo._instance;
    }
}

我想知道如果采取以下方式是否更有效,因为它可以避免不断的线程锁定,或者它存在隐藏问题?

public static Foo GetInstance()
{
    if (Foo._instance != null)
        return Foo._instance;
    lock (Foo._syncLock)
    {
        if (Foo._instance == null)
            Foo._instance = new Foo();
        return Foo._instance;
    }
}

10
你可以做得更好:http://csharpindepth.com/Articles/General/Singleton.aspx - MarcinJuraszek
1
@Voo 假设实例是线程安全的。我这里只关心确保实例化过程是线程安全的,即:我只实例化一次。 - Nick Gotch
1
如果只是 static Foo _instance; 那么它不是线程安全的,因为你没有正确的可见性保证。必须使用 volatile 或者自己引入正确的内存屏障。 - Voo
1
@Voo,你刚才说的是一个不使用锁的例子。现在又开始谈论使用锁,所以显然这不是一个无锁示例,对吧。 - Servy
1
@Voo:我不知道。我会把这个问题交给像Joe Duffy或Grant Morrison这样的人来回答。 - Eric Lippert
显示剩余17条评论
2个回答

9
我在想,如果采用以下方式是否更有效率,因为这样可以避免不断的线程锁定,或者有隐藏的问题吗?
你的问题是“通过采取危险的低锁定模式是否会获得性能提升?” 这是完全错误的问题。永远不要这样推理!那样会浪费时间、精力,并且会产生疯狂、难以调试的 bug。
正确的问题是“我的测量结果是否强烈表明我本来就存在性能问题?” 如果答案是“否”,那么你就完成了。
只有当答案是“是”时,你才应该问下一个问题,即“是否可以通过消除锁上的争用来消除性能问题?”
如果答案是“是”,那么消除锁上的争用并回到第一个问题。
只有当答案是“否”时,你才应该问下一个问题,即“采用低锁定解决方案是否能够提供可接受的性能?”
请注意,为了回答这个问题,你必须处于这样一种情况:无争用锁所带来的十纳秒惩罚是你性能的关键因素。很少有人处于十或二十纳秒太长的位置。
在极其不可能的情况下,如果答案是“是”,那么你应该继续下一个问题,即“正确实现的双重检查锁定是否消除了我的性能问题?”
如果双重检查锁定不够快,那么实现它就毫无意义。你必须用其他方法解决问题。
只有当答案是“是”时,你才应该实现双重检查锁定。
现在让我们来看看你实际的问题:
这种实现方式是正确的。然而,一旦你偏离了正常模式,所有的保证都将不存在。例如:
static object sync = new object();
static bool b = false;
static int x = 0;
static int GetIt()
{
  if (!b)
  {
    lock(sync)
    {
      if (!b)
      {
        b = true;
        x = ExpensiveComputation();
      }
    }
  }
  return x;
}

看起来没问题,对吗?但实际上不对!要考虑低锁定路径。由于这条路径上没有屏障,因此一个线程可以预取x值为零,然后另一个线程可以运行并将b设置为true和x设置为123,然后原始线程可以获取b,得到true,并返回预取的x。

那么有什么解决方案呢?按我的偏好顺序,它们是:

  • 不要懒惰。仅初始化静态字段一次即可。
  • 使用Jon Skeet网站上记录的受认可的延迟单例模式。
  • 使用Lazy<T>
  • 使用单次检查锁定。
  • 如果您不关心单例是否会在罕见情况下创建两次并且其中一个被丢弃,请使用InterlockedCompareExchange
  • 使用经过认可的双重检查锁定模式。

谢谢,我正在使用单个实例初始化静态字段。我也很感激你回答中的详细信息。 - Nick Gotch
@Eric:你忘记将 b 设置为 true,这会导致另一个竞态条件?当 b 被设置为 true 时,您仍应该能够获取一个 x,在您的线程从两个不同的缓存行读取时,x 的值可能为 null。至少在我的 Core I7 上,我无法使其失败。您是指像 ARM 这样的非 x86 内存模型吗? - Alois Kraus
@AloisKraus:我修正了拼写错误,谢谢。这里给出的错误模式在x86上不会失败,因为x86具有强大的内存模型保证。 - Eric Lippert

0

避免锁定总是一个好主意!但是...正常的方法应该是:

原始代码:

if(_instance == null)
  lock(_syncRoot)
    if(_instance == null)
      _instance = new Foo();

return _instance;

这样你可以在锁定之前和之后进行验证,以确保不会引入竞争条件。

这个想法是,在锁释放之后,另一个线程可能已经初始化了对象。所以你应该再次检查。

备注

如果你阅读足够多关于这个主题的内容,你会发现大多数人反对在并行方式下使用Singleton结构。

编辑

根据评论:更好的方法是允许框架实例化静态成员。如果我理解正确,这将消除任何类型的锁定需求。

private static Foo _instance = new Foo();

然而,正如Eric所指出的那样...在很少有线程同时访问对象的情况下,避免使用锁并不是必要的。

1
正常?这怎么可能是正常的呢?private static Foo instance = new Foo();更短、更易于理解、更高效,而且是更常见的做法。 - Servy
@Servy 常规的懒加载方式显然是可行的。话虽如此,代码可能存在潜在的问题。 - Voo
@Voo 我刚才描述的就是懒加载。静态字段在类被使用之前不会被初始化。 - Servy
很有趣...我不知道它会防止多个实例化。 - poy
2
第一句话是错误的。通过低锁代码路径避免锁定几乎总是一个坏主意!一旦你偏离了一个被认可的模式,你就会陷入困境,并面临各种疯狂的竞争条件和弱内存模型。永远不要在没有强制性的基于性能的原因的情况下使用低锁定解决方案。无争用锁的成本大约为十几个纳秒。 - Eric Lippert
显示剩余4条评论

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