10

在使用线程安全时,我发现自己在执行锁定块中的代码之前总是“双重检查”,我想知道我是否做对了。考虑以下三种方法来做同样的事情:

示例 1:

private static SomeCollection MyCollection;
private static Object locker;
private void DoSomething(string key)
{
    if(MyCollection[key] == null)
    {
         lock(locker)
         {
              MyCollection[key] = DoSomethingExpensive(); 
         }
    }
    DoSomethingWithResult(MyCollection[key]);
}

示例 2:

private static SomeCollection MyCollection;
private static Object locker;
private void DoSomething(string key)
{
    lock(locker)
    {
         if(MyCollection[key] == null)
         {
              MyCollection[key] = DoSomethingExpensive(); 
         }
    }
    DoSomethingWithResult(MyCollection[key]);
}

示例 3:

private static SomeCollection MyCollection;
private static Object locker;
private void DoSomething(string key)
{
    if(MyCollection[key] == null)
    {
        lock(locker)
        {
             if(MyCollection[key] == null)
             {
                  MyCollection[key] = DoSomethingExpensive(); 
             }
        }
    }
    DoSomethingWithResult(MyCollection[key]);
}

我总是倾向于示例 3,这就是为什么我认为我在做正确的事情

  • 线程 1 进入DoSomething(string)
  • MyCollection[key] == null所以线程 1 获得了锁,就像线程 2 进入一样
  • MyCollection[key] == null仍然为真,所以线程 2 等待获取锁
  • 线程 1 计算值MyCollection[key]并将其添加到集合中
  • 线程 1 释放锁并调用DoSomethingWithResult(MyCollection[key]);
  • 线程 2 获得锁,此时MyCollection[key] != null
  • 线程 2 什么也不做,释放锁并继续其愉快的方式

示例 1 可行,但线程 2 可能会冗余计算MyCollection[key].

示例 2 可以工作,但每个线程都会获得一个锁,即使它不需要 - 这可能是一个(诚然非常小的)瓶颈。如果不需要,为什么要挂起线程?

我是否过度考虑了这一点,如果是这样,处理这些情况的首选方法是什么?

4

4 回答 4

7

不应该使用第一种方法。正如您所意识到的,它会泄漏,因此最终可能会有多个线程运行这种昂贵的方法。该方法花费的时间越长,另一个线程也将运行它的风险就越大。在大多数情况下,这只是一个性能问题,但在某些情况下,结果数据后来被一组新数据替换也可能是一个问题。

第二种方法是最常用的方法,如果数据访问如此频繁以至于锁定成为性能问题,则使用第三种方法。

于 2012-11-07T00:17:46.590 回答
4

我将介绍某种不确定性,因为这个问题并非微不足道。基本上我同意 Guffa 的观点,我会选择第二个例子。这是因为第一个被破坏了,而第三个又被破坏了,尽管事实似乎是优化的,但很棘手。这就是为什么我将在这里专注于第三个:

if (item == null)
{
    lock (_locker)
    {
        if (item == null)
            item = new Something();
    }
}

乍一看,它可能会在不一直锁定的情况下提高性能,但也存在问题,因为内存模型(读取可能会重新排序以在写入之前进行)或激进的编译器优化(参考),例如:

  1. 线程A注意到该值item没有被初始化,因此它获得了锁并开始初始化该值。
  2. 由于内存模型、编译器优化等原因,允许编译器生成的代码在A完成初始化之前更新共享变量以指向部分构造的对象。
  3. 线程B注意到共享变量已被初始化(或者看起来如此),并返回它的值。因为线程B认为该值已经初始化,所以它不会获取锁。如果在A完成初始化之前使用该变量,程序可能会崩溃。

有解决该问题的方法:

  1. 您可以将其定义item为 volatile 变量,以确保读取变量始终是最新的。Volatile 用于在变量的读取和写入之间创建内存屏障。

    (请参阅.NET 中双重检查锁定中对 volatile 修饰符的需求在 C# 中实现单例模式

  2. 您可以使用MemoryBarrieritem非易失性):

    if (item == null)
    {
        lock (_locker)
        {
            if (item == null)
            {
                var temp = new Something();
                // Insure all writes used to construct new value have been flushed.
                System.Threading.Thread.MemoryBarrier();                     
                item = temp;
            }
        }
    }
    

    执行当前线程的处理器不能以这样一种方式重新排序指令,即在调用之前的内存访问在调用MemoryBarrier之后的内存访问之后执行MemoryBarrier

    (请参阅Thread.MemoryBarrier 方法和本主题

更新:双重检查锁定,如果正确实施,似乎在 C# 中工作正常。有关更多详细信息,请查看其他参考资料,例如MSDNMSDN 杂志此答案

于 2012-11-07T01:35:29.087 回答
3

我建议你把这个问题留给专业人士并使用ConcurrentDictionary(我知道我会的)。它具有GetOrAdd 方法,该方法完全符合您的要求,并保证正常工作。

于 2012-11-07T00:33:18.947 回答
1

可以使用多种模式来创建惰性对象,这正是您的代码示例所关注的内容。如果您的集合类似于数组,或者ConcurrentDictionary允许代码自动检查值是否已设置并仅在未设置时才写入,则另一种有时可能有用的变体是:

Thing theThing = myArray[index];
if (theThing == null) // Doesn't look like it's created yet
{
  Thing tempThing = new DummyThing(); // Cheap
  lock(tempThing) // Note that the lock surrounds the CompareExchange *and* initialization
  {
    theThing = System.Threading.Interlocked.CompareExchange
       (ref myArray[index], tempThing, null);
    if (theThing == null)
    {
      theThing = new RealThing(); // Expensive
      // Place an empty lock or memory barrier here if loose memory semantics require it
      myArray[index] = theThing ;
    }
  }
}
if (theThing is DummyThing)
{
  lock(theThing) { } // Wait for thread that created DummyThing to release lock
  theThing = myArray[index];
  if (theThing is DummyThing)
      throw something; // Code that tried to initialize object failed to do so
  }
}

此代码假定可以廉价地构造一个从Thing. 新对象不应单例,也不应以其他方式重用。每个插槽都myArray将被写入两次——首先是预先锁定的虚拟对象,然后是真实对象。只有一个线程能够写入一个虚拟对象,只有成功写入虚拟对象的线程才能写入真正的对象。任何其他线程要么会看到一个真实的对象(在这种情况下,该对象已完全初始化),要么会看到一个虚拟对象,该对象将被锁定,直到使用对真实对象的引用更新数组为止。

与上面显示的其他方法不同,这种方法将允许同时初始化数组中的不同项目;唯一会阻塞的情况是尝试访问正在初始化的对象。

于 2013-08-22T20:12:52.957 回答