【问题标题】:Locks vs Compare-and-swap锁与比较和交换
【发布时间】:2013-10-27 21:38:43
【问题描述】:

我一直在阅读有关无锁技术的文章,例如比较和交换以及利用 Interlocked 和 SpinWait 类来实现无锁定的线程同步。

我自己进行了一些测试,其中我只是有许多线程试图将字符附加到字符串。我尝试使用常规的locks 和比较和交换。令人惊讶的是(至少对我而言),锁显示出比使用 CAS 更好的结果。

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

    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 是更好的选择? (我知道有人问过这个问题,但我找不到任何令人满意的答案来解释我的具体情况)。

据我了解,使用 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);

【问题讨论】:

  • 不,你不测量CAS的执行时间,但主要是字符串比较的执行时间。不幸的是,Interlocked 类没有针对引用类型的原子读取-修改-写入操作(这就是您在“锁定”示例中基本上所做的事情,而不依赖于字符串比较。)
  • 您的无锁解决方案比有锁版本做的工作更多。首先,读取现有值的初始CompareExchange 是多余的,执行易失性读取 (Thread.VolatileRead) 将给您相同的结果,而不会减少开销。其次,循环中的每次尝试更新都会复制字符串的“当前”值并附加新值。您对此无能为力,但锁定版本不会遇到此问题。最有可能造成大部分时间差异的是字符串副本。
  • 对于我们这些凡人来说,坚持使用现有的锁,而不是尝试使用自己的锁。无需处理ABA 问题,多线程就足够困难了。
  • 所以你是说问题出在字符串复制和字符串比较操作上?但如果是这样的话,这不会使这种模式在所有场景中都无效吗?模式要求复制一个值,修改它,然后用新值交换旧值。如果交换失败(您通过比较来验证),则重新开始。
  • 另外:高故障率是“正常的”吗?而@William,Thread.VolatileRead 对字符串(或引用类型)没有任何重载。我很确定使用CompareExchange 是执行易失性读取的好方法,它似乎比仅将字段标记为volatile 更好(不是100% 肯定)。

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


【解决方案1】:

问题是循环失败率和字符串不可变这一事实的结合。我使用以下参数自己做了几个测试。

  • 运行 8 个不同的线程(我有一台 8 核机器)。
  • 每个线程调用Append 10,000 次。

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

这里最大的问题是顺序字符串连接不适合并行性。由于操作 Xn 的结果取决于 Xn-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在讨论内存屏障时,我实际上不喜欢“陈旧”和“新鲜”这两个词,因为它们的真正含义并非如此。与实际保证相比,这更像是一种副作用。但是,在这种情况下,它更好地说明了我的观点。

【讨论】:

  • 这很有启发性,尤其是对不断增长的失败率以及为什么这种方法无法扩展的解释。谢谢。
猜你喜欢
  • 2011-07-17
  • 2011-04-09
  • 2019-04-16
  • 1970-01-01
  • 2012-03-10
  • 2011-05-11
  • 1970-01-01
  • 1970-01-01
  • 2021-03-09
相关资源
最近更新 更多