我已经理解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;
}
答案 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
。
现在会发生什么?
Remove()
移动时会被移动。foreach
使用的枚举器抛出异常,因为List<T>.Enumerator
如果在枚举时更改列表而另一个线程正在执行此操作则抛出。List<T>
永远不应抛出的异常,因为线程1位于其中一个方法的中间,并且其不变量仅在每个方法调用之前和之后保持。Remove()
之前读取汽车的一部分并且在其之后读取部分汽车。 (仅当Car
的类型是值类型时;引用的读取和写入始终是单独原子的。)所有这一切显然都很糟糕。问题是您在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());