9

我一直在阅读有关无锁技术的文章,例如比较和交换以及利用 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);
4

1 回答 1

13

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

  • 运行 8 个不同的线程(我有一台 8 核机器)。
  • 每个线程调用Append10,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在讨论记忆障碍时,我实际上不喜欢“陈旧”和“新鲜”这两个词,因为它们的真正含义并非如此。与实际保证相比,这更像是一种副作用。但是,在这种情况下,它更好地说明了我的观点。

于 2013-10-20T02:48:40.723 回答