每个对象的实例是否可以锁定?

时间:2016-01-21 11:17:19

标签: c# multithreading locking

我已经理解lock()锁定一行代码区域,其他线程无法访问锁定的代码行。编辑:事实证明这是错误的 是否也可以执行对象的每个实例?编辑:是的,这只是静态和非静态之间的区别。

E.g。在延迟加载期间检查null引用,但实际上不需要锁定相同类型的其他对象?

object LockObject = new object();
List<Car> cars;
public void Method()
{
   if (cars == null)
   {
      cars = Lookup(..)
      foreach (car in cars.ToList())
      {
          if (car.IsBroken())
          {
             lock(LockObject)
              {
                 cars.Remove(car)
              }
           }
       }
    }
    return cars;
}

编辑,这是编写此代码的正确方法:
因为当cars == null和线程A锁定它时,另一个线程B将等待。然后当A准备就绪时,B继续,但是应该再次检查是否cars == null,否则代码将再次执行 但这看起来不自然,我从未见过这样的模式 请注意,锁定第一个null - 检查意味着您甚至会一次又一次地检查null以获取锁定。所以这样做不好。

public void Method()
{
   if (cars == null)
   {
      lock(LockObject)
      {
         if (cars == null)
         {
            cars = Lookup(..)
            foreach (car in cars.ToList())
             {
               if (car.IsBroken())
               {
                   cars.Remove(car)
               }
             }
          }
       }
    }
    return cars;
}

2 个答案:

答案 0 :(得分:2)

重要的是要意识到锁定是锁定对象的问题。

我们通常希望完全锁定特定的代码块。因此,我们使用readonly字段来锁定一个部分,从而防止该代码的任何其他运行(如果该字段是静态的)或给定的实例(如果该字段不是静态的)。但是,这是最常见的用途,而不是唯一可能的用途。

考虑:

ConcurrentDictionary<string, List<int>> idLists = SomeMethodOrSomething();
List<int> idList;
if (idLists.TryGetValue(someKey, out idList))
{
  lock(idList)
  {
    if (!idList.Contains(someID))
      idList.Add(someID);
  }
}

这里的“锁定”部分可以通过尽可能多的线程同时调用。但是,它不能同时在同一列表中调用。这样的用法是不寻常的,必须确保没有其他任何东西可以尝试锁定其中一个列表(如果没有其他任何内容可以访问idLists或访问任何列表之前或之后访问它们很容易它很难,但它确实出现在实际代码中。

但重要的是获得idList本身就是线程安全的。当创建一个新的idList时,这种更狭隘的锁定将无效。

相反,我们必须做两件事之一:

最简单的方法是在任何操作(更正常的方法)之前锁定只读字段

另一种是使用GetOrAdd

List<int> idList = idLists.GetOrAdd(someKey, () => new List<int>());
lock(idList)
{
  if (!idList.Contains(someID))
    idList.Add(someID);
}

现在要注意的一件有趣的事情是,GetOrAdd()并不能保证如果它调用工厂() => new List<int>(),那么该因子的结果就会被返回。也不承诺只召唤一次。一旦我们远离那些仅仅lock在一个只读字段上的代码,潜在的种族变得更复杂,并且更多的想法必须进入它们(在这种情况下,可能的想法是如果种族意味着更多创建了一个列表,但只使用了一个列表,剩下的就是GC,那就没关系了。

把这个带回你的案子。虽然上面的内容表明,锁定的可能性不如第一个例子那么精细,但再次更精细,它的安全性取决于更广泛的背景。

你的第一个例子被打破了:

cars = Lookup(..)
foreach (car in cars.ToList()) // May be different cars to that returned from Lookup. Is that OK?
{
    if (car.IsBroken()) // May not be in cars. Is that OK?
    { // IsBroken() may now return false. Is that OK?
       lock(LockObject)

调用ToList()时,可能无法在放入cars的同一实例上调用它。这不是必然一个错误,但它很可能是。要离开它,你必须证明比赛是安全的。

每次获得新的car时,同时cars可能会被覆盖。每次我们输入锁定时,car的状态可能已更改,以便IsBroken()在此期间返回false。

所有这一切都可能很好,但表明它们很好很复杂。

嗯,当它很好的时候往往会很复杂,有时很复杂,但是通常很容易得到答案,“不,它不行”。事实上就是这种情况,因为在第二个例子中也出现了非线程安全的最后一点:

if (cars == null)
{
  lock(LockObject)
  {
     if (cars == null)
     {
        cars = Lookup(..)
        foreach (car in cars.ToList())
         {
           if (car.IsBroken())
           {
               cars.Remove(car)
           }
         }
      }
   }
}
return cars; // Not thread-safe.

考虑一下,线程1检查cars并发现它为null。然后它获得一个锁,检查cars是否仍然为空(好),如果是,则将其设置为从Lookup获得的列表并开始删除“损坏的”汽车。

现在,此时线程2检查cars并发现它不为空。因此它会向调用者返回cars

现在会发生什么?

  1. 线程2可以在列表中找到“已损坏”的汽车,因为它们尚未被删除。
  2. 线程2可以跳过过去的汽车,因为列表的内容在Remove()移动时会被移动。
  3. 线程2可以让foreach使用的枚举器抛出异常,因为List<T>.Enumerator如果在枚举时更改列表而另一个线程正在执行此操作则抛出。
  4. 线程2可能会抛出List<T>永远不应抛出的异常,因为线程1位于其中一个方法的中间,并且其不变量仅在每个方法调用之前和之后保持。
  5. 线程2可以获得一辆奇怪的法兰克福车,因为它在Remove()之前读取汽车的一部分并且在其之后读取部分汽车。 (仅当Car的类型是值类型时;引用的读取和写入始终是单独原子的。)
  6. 所有这一切显然都很糟糕。问题是您在cars处于其他线程可以安全查看的状态之前进行设置。相反,您应该执行以下操作之一:

    if (cars == null)
    {
      lock(LockObject)
      {
        if (cars == null)
        {
          cars = Lookup(..).RemoveAll(car => car.IsBroken());
        }
      }
    }
    return cars;
    

    在完成工作之前,这不会在cars中设置任何内容。因此,在这样做之前,另一个线程无法看到它。

    可替换地:

    if (cars == null)
    {
      var tempCars = Lookup(..).RemoveAll(car => car.IsBroken());
      lock(LockObject)
      {
        if (cars == null)
        {
          cars = tempCars;
        }
      }
    }
    return cars;
    

    这样可以锁定更短的时间,但代价是可能会浪费掉工作。如果完全这样做(可能不是这样)是安全的,那么在锁定时间较短的时间内,可以在前几次查找的潜在额外时间之间进行权衡。这有点值得,但通常不值得。

答案 1 :(得分:0)

执行延迟初始化的最佳策略是使用字段属性:

private List<Car> Cars
{
    get
    {
        lock (lockObject)
        {
            return cars ?? (cars = Lockup(..));
        }
    }
}

在这里使用你的锁对象也可以确保没有其他线程也创建它的实例。

锁定时也要执行添加和删除操作:

void Add(Car car)
{
    lock(lockObject) Cars.Add(car);
}

void Remove(Car car)
{
    lock(lockObject) Cars.Remove(car);
}

识别使用Cars属性访问列表!

现在您可以获得列表的副本:

List<Car> copyOfCars;
lock(lockObject) copyOfCars = Cars.ToList();

然后可以安全地从原始列表中删除某些对象:

foreach (car in copyOfCars)
{
    if (car.IsBroken())
    {
        Remove(car);
    }
}

但请务必使用您自己锁定在其中的Remove(car)方法。

特别是对于List,还有另一种清理内部元素的可能性:

lock(lockObject) Cars.RemoveAll(car => car.IsBroken());