锁定与比较和交换

时间:2013-10-18 22:17:12

标签: c# .net multithreading locking compare-and-swap

我一直在阅读无锁技术,例如比较和交换,并利用Interlocked和SpinWait类来实现线程同步而不会锁定。

我已经运行了一些我自己的测试,我只是有很多线程尝试将字符附加到字符串。我尝试使用常规lock和比较和交换。令人惊讶的是(至少对我来说),锁比使用CAS显示出更好的结果。

这是我的代码的CAS版本(基于this)。它遵循copy-> modify->交换模式:

    private string _str = "";
    public void Append(char value)
    {
        var spin = new SpinWait();
        while (true)
        {
            var original = Interlocked.CompareExchange(ref _str, null, null);

            var newString = original + value;                
            if (Interlocked.CompareExchange(ref _str, newString, original) == original)
                break;
            spin.SpinOnce();
        }
    }

更简单(也更有效)的锁定版本:

    private object lk = new object();
    public void AppendLock(char value)
    {
        lock (lk)
        {
            _str += value;
        }
    }

如果我尝试添加50.000个字符,则CAS版本需要1.2秒,锁定版本需要700毫秒(平均值)。对于100k字符,它们分别需要7秒和3.8秒。 这是在四核(i5 2500k)上运行。

我怀疑CAS显示这些结果的原因是因为它失败了最后一次“交换”步骤。我是正确的。当我尝试添加50k字符(50k成功交换)时,我可以计算70k(最佳情况)和近200k(最差情况)失败尝试。最糟糕的情况是,每5次尝试中有4次失败。

所以我的问题是:

  1. 我错过了什么? CAS不应该给出更好的结果吗?哪个好处?
  2. 为什么CAS确切地说何时更好? (我知道有人问过,但我找不到任何令人满意的答案,这也解释了我的具体情况)。
  3. 我的理解是,采用CAS的解决方案尽管难以编码,但随着争用的增加,其规模要好得多,并且比锁具有更好的性能。在我的例子中,操作非常小且频繁,这意味着高争用和高频率。那么为什么我的测试显示不同呢?

    我认为较长的操作会使情况更糟 - > “掉期”失败率会进一步上升。

    PS:这是我用来运行测试的代码:

    Stopwatch watch = Stopwatch.StartNew();
    var cl = new Class1();
    Parallel.For(0, 50000, i => cl.Append('a'));
    
    var time = watch.Elapsed;
    Debug.WriteLine(time.TotalMilliseconds);
    

1 个答案:

答案 0 :(得分:8)

问题是循环上的失败率和字符串是不可变的这一事实的组合。我使用以下参数自行完成了几项测试。

  • 跑8个不同的线程(我有一台8核机器)。
  • 每个帖子称Append 10,000次。

我观察到的是弦的最终长度是80,000(8 x 10,000),所以这是完美的。对我来说,追加尝试的次数平均约为300,000。这就是失败率~73%。只有27%的CPU时间带来了有用的工作。现在因为字符串是不可变的,这意味着在堆上创建了字符串的新实例,并且原始内容加上一个额外字符被复制到其中。顺便说一句,这个复制操作是O(n),因此随着字符串的长度增加它变得越来越长。由于复制操作,我的假设是失败率会随着字符串长度的增加而增加。原因在于,由于线程花费更多时间竞争完成ICX,因此复制操作花费的时间越来越多,冲突的可能性就越高。我的测试证实了这一点你应该自己尝试同样的测试。

这里最大的问题是顺序字符串连接不能很好地适应并行性。由于操作X n 的结果取决于X n-1 ,因此采用完全锁定会更快,特别是如果它意味着您避免了所有的故障,重试。在这种情况下,悲观的策略赢得了与乐观的战斗。当你可以将问题分成独立的卡盘时,低技术可以更好地工作,这些卡盘真的可以无阻碍地并行运行。

作为旁注,使用Interlocked.CompareExchange来初始阅读_str是不必要的。原因是在这种情况下读取不需要存储器屏障。这是因为实际执行工作的Interlocked.CompareExchange调用(代码中的第二个调用)将创建一个完整的屏障。因此最糟糕的情况是第一次读取是“陈旧的”,ICX操作未通过测试,并且循环旋转回来再次尝试。然而,这一次,之前的ICX强制进行了“新鲜”的阅读。 1

以下代码是我如何使用低锁机制推广复杂操作。实际上,下面给出的代码允许您传递代表操作的委托,因此它非常通用。你想在生产中使用它吗?可能不是因为调用委托很慢,但至少你得到了这个想法。你总是可以对操作进行硬编码。

public static class InterlockedEx
{
  public static T Change<T>(ref T destination, Func<T, T> operation) where T : class
  {
    T original, value;
    do
    {
        original = destination;
        value = operation(original);
    }
    while (Interlocked.CompareExchange(ref destination, value, original) != original);
    return original;
  }
}

1 在讨论记忆障碍时,我实际上不喜欢“陈旧”和“新鲜”这两个词,因为这不是真正的内容。与实际保证相比,它更具有副作用。但是,在这种情况下,它更好地说明了我的观点。