是否可以为每个对象的实例锁定
本文关键字:对象 实例 锁定 是否 | 更新日期: 2023-09-27 18:31:27
我知道lock()
锁定了代码行区域,其他线程无法访问锁定的代码行。编辑:事实证明这是错误的。
是否可以为每个对象的实例执行此操作?编辑:是的,这只是静态和非静态之间的区别。
例如,在延迟加载期间检查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
-check 意味着您甚至可以获得一个锁来检查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;
}
重要的是要意识到锁定在很大程度上取决于锁定的对象。
大多数情况下,我们希望完全锁定特定的代码块。因此,我们使用只读字段来锁定部分,从而阻止该代码的任何其他运行(如果字段是静态的)或给定实例(如果字段不是静态的)。但是,这是最常见的用途,而不是唯一可能的用途。
考虑:
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'd,那很好)。
把这个带回你的案子。虽然上面表明,不仅可以像第一个示例那样精细地锁定,而且可以再次更精细地锁定,但它的安全性取决于更广泛的上下文。
你的第一个例子坏了:
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
返回给调用方。
现在会发生什么?
- 线程 2 可以在列表中找到"损坏"的汽车,因为它们尚未被删除。
- 线程 2 可以跳过汽车,因为列表的内容在处理时被
Remove()
移动。 - 线程 2 可以让
foreach
使用的枚举器引发异常,因为如果您在枚举时更改列表并且另一个线程正在执行此操作,则会引发List<T>.Enumerator
。 - 线程 2 可以引发
List<T>
永远不应该引发的异常,因为线程 1 位于其其中一个方法的中间,并且其不变量仅在每个方法调用之前和之后保持。 - 线程 2 可以获得一辆奇怪的弗兰肯汽车,因为它在
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;
这样可以保持锁的时间更少,但代价是可能浪费工作只是为了扔掉它。如果这样做是安全的(可能不是),那么在前几次查找的潜在额外时间与锁定中更少的时间之间进行权衡。有时这是值得的,但通常不是。
执行延迟初始化的最佳策略是使用字段的属性:
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());