在使用线程安全时,我发现自己总是在执行锁定块中的代码之前“仔细检查”,我想知道我是否正在做正确的事情。考虑以下三种做同样事情的方法:
示例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,这就是为什么我认为我做的正确
DoSomething(string)
MyCollection[key] == null
所以线程1获得锁定,就像线程2进入MyCollection[key] == null
仍然是,因此线程2等待获取锁MyCollection[key]
的值并将其添加到集合DoSomethingWithResult(MyCollection[key]);
MyCollection[key] != null
示例1可行,但线程2可能会冗余地计算MyCollection[key]
。
示例2可以工作,但是每个线程都会获得一个锁,即使它不需要 - 这可能是一个(无可否认的很小)瓶颈。如果你不需要,为什么要保留线程?
我是否过度思考这个问题,如果是这样,处理这些情况的首选方法是什么?
答案 0 :(得分:6)
不应使用第一种方法。正如您所意识到的那样,它会泄漏,因此不止一个线程最终会运行昂贵的方法。该方法所用的时间越长,另一个线程也会运行它的风险就越大。在大多数情况下,这只是一个性能问题,但在某些情况下,结果数据稍后会被一组新数据替换也可能是一个问题。
第二种方法是最常用的方法,如果数据被频繁访问以致锁定成为性能问题,则使用第三种方法。
答案 1 :(得分:4)
我会介绍某种不确定性,因为这个问题不是微不足道的。基本上我同意Guffa,我会选择第二个例子。这是因为第一个被打破,而第三个被打破,尽管事实似乎是优化的,是棘手的。这就是为什么我会专注于第三个:
if (item == null)
{
lock (_locker)
{
if (item == null)
item = new Something();
}
}
乍一看,它可能会在没有锁定的情况下提高性能,但也存在问题,因为内存模型(读取可能会在写入之前重新排序)或者积极的编译器优化(reference ),例如:
item
未初始化,因此它获取锁定并开始初始化值。该问题有解决方案:
您可以将item
定义为易变量,以确保读取变量始终是最新的。 Volatile用于在变量的读写操作之间创建内存屏障。
(请参阅The need for volatile modifier in double checked locking in .NET和Implementing the Singleton Pattern in C#)
您可以使用MemoryBarrier
(item
非易失性):
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
之后执行内存访问。
更新:双重检查锁定,如果正确实施,似乎在C#中正常工作。有关详情,请查看其他参考资料MSDN,MSDN magazine和this 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
中的每个插槽都将被写入两次 - 首先是预先锁定的虚拟对象,然后是真实对象。只有一个线程能够编写虚拟对象,只有成功编写虚拟对象的线程才能编写真实对象。任何其他线程都会看到一个真实的对象(在这种情况下对象被完全初始化)或者一个虚拟对象,它将被锁定,直到数组被更新为对真实对象的引用。
与上面显示的其他方法不同,此方法将允许同时初始化数组中的不同项;阻止的唯一情况是,是否尝试访问正在进行初始化的对象。