在运行代码之前,可以在锁之前和内部“仔细检查”吗?

时间:2012-11-06 23:55:51

标签: c# multithreading thread-safety

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

示例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 个答案:

答案 0 :(得分:6)

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

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

答案 1 :(得分:4)

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

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

乍一看,它可能会在没有锁定的情况下提高性能,但也存在问题,因为内存模型(读取可能会在写入之前重新排序)或者积极的编译器优化(reference ),例如:

  1. 线程 A 注意到值item未初始化,因此它获取锁定并开始初始化值。
  2. 由于内存模型,编译器优化等原因,允许编译器生成的代码在 A 完成初始化之前更新共享变量以指向部分构造的对象。 / LI>
  3. 主题 B 注意到共享变量已初始化(或显示),并返回其值。因为线程 B 认为该值已经初始化,所以它不会获得锁定。如果在 A 完成初始化之前使用变量,程序可能会崩溃。
  4. 该问题有解决方案:

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

      (请参阅The need for volatile modifier in double checked locking in .NETImplementing the Singleton Pattern in 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 Method和此topic

    3. 更新:双重检查锁定,如果正确实施,似乎在C#中正常工作。有关详情,请查看其他参考资料MSDNMSDN magazinethis answer

答案 2 :(得分:3)

我建议你把这个问题留给专业人士使用ConcurrentDictionary(我知道我会)。它具有GetOrAdd method,它可以完全满足您的需求并保证正常工作。

答案 3 :(得分: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中的每个插槽都将被写入两次 - 首先是预先锁定的虚拟对象,然后是真实对象。只有一个线程能够编写虚拟对象,只有成功编写虚拟对象的线程才能编写真实对象。任何其他线程都会看到一个真实的对象(在这种情况下对象被完全初始化)或者一个虚拟对象,它将被锁定,直到数组被更新为对真实对象的引用。

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