Interlocked.CompareExchange是否比简单锁更快?

11

我看到了一个.NET 3.5下的ConcurrentDictionary实现(很抱歉我现在找不到链接),它使用了以下这种锁定方法:

var current = Thread.CurrentThread.ManagedThreadId;
while (Interlocked.CompareExchange(ref owner, current, 0) != current) { }

// PROCESS SOMETHING HERE

if (current != Interlocked.Exchange(ref owner, 0))
        throw new UnauthorizedAccessException("Thread had access to cache even though it shouldn't have.");

传统的 lock 已经不再使用:

lock(lockObject)
{
    // PROCESS SOMETHING HERE
}

问题是:这样做有任何真正的原因吗?它是否更快或具有某些隐藏的好处?
PS:我知道在一些最新版本的.NET中有一个ConcurrentDictionary,但我无法在遗留项目中使用。
编辑:
在我的特定情况下,我所做的只是以一种使其线程安全的方式操作内部的Dictionary类。
例如:
public bool RemoveItem(TKey key)
{
    // open lock
    var current = Thread.CurrentThread.ManagedThreadId;
    while (Interlocked.CompareExchange(ref owner, current, 0) != current) { }


    // real processing starts here (entries is a regular `Dictionary` class.
    var found = entries.Remove(key);


    // verify lock
    if (current != Interlocked.Exchange(ref owner, 0))
        throw new UnauthorizedAccessException("Thread had access to cache even though it shouldn't have.");
    return found;
}

根据 @doctorlove 的建议,以下是代码:https://github.com/miensol/SimpleConfigSections/blob/master/SimpleConfigSections/Cache.cs


你能详细说明一下吗?你需要整个部分像你说的“// PROCESS SOMETHING HERE”一样同步,还是只想要原子地交换值... - Deepak Bhatia
@dbw,我编辑了以提供更多信息。 - Andre Pena
这是代码吗?https://github.com/miensol/SimpleConfigSections/blob/master/SimpleConfigSections/Cache.cs - doctorlove
没错,谢谢 @doctorlove - Andre Pena
7个回答

7

您的CompareExchange示例代码如果“处理某些内容”抛出异常,则不会释放锁。

因此,除了更简单、更易读的代码之外,我更喜欢使用lock语句。

您可以通过try/finally来解决问题,但这会使代码变得更丑陋。

链接的ConcurrentDictionary实现存在一个错误:如果调用方传递了null键,则它将无法释放锁,可能会导致其他线程无限旋转。

至于效率,您的CompareExchange版本本质上是一个自旋锁,如果线程只被阻塞了很短的时间,那么它可能是有效的。但是,向托管字典中插入数据可能需要相对较长的时间,因为可能需要调整字典的大小。因此,在我的观点中,这不是自旋锁的好选择-在单处理器系统上可能是浪费的。


1
我同意。Interlocked 可能会带来更好的性能,但它的可读性较差且更容易出现错误。因此,除非你信任该源代码,否则我建议使用简单的锁。 - i3arnon

7
你的问题没有一个明确的答案,我会回答:它取决于具体情况。
你提供的代码的作用是:
1. 等待对象处于已知状态(`threadId == 0 == 没有当前工作`)。 2. 进行工作。 3. 将已知状态设置回对象中。 4. 另一个线程现在也可以进行工作,因为它可以从步骤1到步骤2。
正如你所指出的,代码中有一个循环实际上执行了“等待”步骤。你并不会阻止线程直到可以访问临界区域,而是会消耗CPU。尝试将处理(在你的情况下是调用 `Remove` )替换为 `Thread.Sleep(2000)` ,你会看到另一个“等待”的线程在循环中消耗了您一个 CPU 的所有时间。
这意味着哪种方法更好取决于几个因素。例如:有多少并发访问?操作完成需要多长时间?您有多少个 CPU?
我会使用 `lock` 而不是 `Interlocked` ,因为它更容易阅读和维护。异常情况是你有一个被调用数百万次的代码片段,并且你确定 `Interlocked` 更快。
所以你必须自己测量两种方法。如果你没有时间进行此操作,那么你可能不需要担心性能,并且应该使用 `lock`。

5

有点晚了...我已经阅读了你的示例,简而言之:

最快到最慢的MT同步:

  • Interlocked.* => 这是一个CPU原子指令。如果它足够满足您的需求,则无法击败。
  • SpinLock => 在后面使用Interlocked,非常快速。等待时使用CPU。不要用于等待时间长的代码(通常用于防止线程切换以进行快速操作的锁定)。如果您经常需要等待超过一个线程周期,建议使用“Lock”
  • Lock => 最慢但比SpinLock更易于使用和阅读。指令本身非常快,但如果无法获取锁定,则会放弃CPU。在幕后,它将在内核对象(CriticalSection)上执行WaitForSingleObject,然后Window只有在锁定被获取它的线程释放时才会给线程CPU时间。

享受MT吧!


3
是的。 Interlocked类提供原子操作,这意味着它们不像锁定那样阻塞其他代码,因为它们实际上不需要阻塞。当您锁定一块代码时,您希望确保没有2个线程同时进入其中,这意味着当一个线程在其中时,所有其他线程等待进入其中,这使用了资源(CPU时间和空闲线程)。另一方面,原子操作不需要阻塞其他原子操作,因为它们是原子的。这概念上是一个CPU操作,下一个操作只需在前一个操作之后进行,而不会浪费线程等待(顺便说一下,这就是为什么它仅限于非常基本的操作,如Increment,Exchange等)。
我认为锁(其底层是Monitor)使用interlocked来知道锁是否已经被获取,但它无法知道其中的动作是否可以是原子的。
尽管在大多数情况下,差异并不关键,但您需要验证您的特定情况。

谢谢,但您能否再详细解释一下? - Andre Pena
我还添加了一个代码示例,以便更详细地了解我的特定情况。 - Andre Pena
我可能错了,但似乎你添加的代码片段使用interlocked来验证在你进行操作时没有其他线程更改了该值。这并不是真正意义上的“阻塞”其他线程,而更像是尝试一项操作,如果与其他调用同时发生,则失败。 - i3arnon

3
Interlocked 类的文档告诉我们,它“为被多个线程共享的变量提供原子操作”。
理论上,原子操作可以比锁更快。 Albahari 对原子操作进行了更详细的介绍,指出它们更快。
请注意,Interlocked 提供的接口比 Lock 更“小”-请参见此先前的问题

1

锁和Interlocked.CompareExchange之间的一个重要区别是它们在异步环境中的使用方式。

在锁内部不能等待异步操作,因为如果继续执行await的线程不是最初获取锁的线程,则可能会发生死锁。

然而,这对于Interlocked来说不是问题,因为没有任何东西被线程“获取”。

另一种解决异步代码的方案可能比Interlocked更易读,如此博客文章所述: https://blog.cdemi.io/async-waiting-inside-c-sharp-locks/


1

Interlocked更快 - 在其他评论中已经解释过,你还可以定义等待的逻辑,比如spinWait.spin()、spinUntil、Thread.sleep等。一旦锁定失败,如果在锁内部的代码预计不会崩溃(自定义代码/委托/资源分配或事件/在锁定期间执行的意外代码),除非你要捕获异常以允许软件继续执行,“try”“finally”也会被跳过,从而提高了速度。lock(something) 确保如果你从外部捕获到异常,则解锁该something,就像"using"(在C#中)确保当执行块因任何原因退出时,处理“使用”的可处理对象。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接