为什么我的线程安全字典实现产生数据竞争?

时间:2016-09-22 08:06:07

标签: c# .net multithreading dictionary concurrency

我目前正在C#中实现一个线程安全字典,它在内部使用不可变的AVL树作为存储区。我的想法是在没有锁定的情况下提供快速读取访问,因为在我的应用程序上下文中,我们仅在启动时向该字典添加条目,之后,大多数值都被读取(但仍有少量写入)。

我通过以下方式构建了TryGetValueGetOrAdd方法:

public sealed class FastReadThreadSafeDictionary<TKey, TValue> where TKey : IEquatable<TKey>
{
    private readonly object _bucketContainerLock = new object();
    private ImmutableBucketContainer<TKey, TValue> _bucketContainer;

    public bool TryGetValue(TKey key, out TValue value)
    {
        var bucketContainer = _bucketContainer;
        return bucketContainer.TryFind(key.GetHashCode(), key, out value);
    }

    public bool GetOrAdd(TKey key, Func<TValue> createValue, out TValue value)
    {
        createValue.MustNotBeNull(nameof(createValue));
        var hashCode = key.GetHashCode();
        lock (_bucketContainerLock)
        {
            ImmutableBucketContainer<TKey, TValue> newBucketContainer;
            if (_bucketContainer.GetOrAdd(hashCode, key, createValue, out value, out newBucketContainer) == false)
                return false;

            _bucketContainer = newBucketContainer;
            return true;
        }
    }

    // Other members omitted for sake of brevity
}

正如您所看到的,我不会在TryGetValue中使用锁定,因为reference assignment in .NET runtimes is an atomic operation by design。通过将字段_bucketContainer的引用复制到本地变量,我确信我可以安全地访问该实例,因为它是不可变的。在GetOrAdd中,我使用锁来访问私有_bucketContainer,因此我可以确保不会创建两次值(即,如果两个或多个线程正在尝试添加值,则只有一个可以实际创建一个新的ImmutableBucketContainer,由于锁定而具有附加值。

我使用Microsoft Chess来测试并发性,在我的一个测试中,MCUT(Microsoft并发单元测试)在我用新旧桶交换新的桶容器时报告GetOrAdd中的数据竞争之一:

[DataRaceTestMethod]
public void ReadWhileAdd()
{
    var testTarget = new FastReadThreadSafeDictionary<int, object>();
    var writeThread = new Thread(() =>
                                 {
                                     for (var i = 5; i < 10; i++)
                                     {
                                         testTarget.GetOrAdd(i, () => new object());
                                         Thread.Sleep(0);
                                     }
                                 });
    var readThread = new Thread(() =>
                                {
                                    object value;
                                    testTarget.TryGetValue(5, out value);
                                    Thread.Sleep(0);
                                    testTarget.TryGetValue(7, out value);
                                    Thread.Sleep(10);
                                    testTarget.TryGetValue(9, out value);
                                });
    readThread.Start();
    writeThread.Start();
    readThread.Join();
    writeThread.Join();
}

MCUT报告以下消息:

23&GT;测试结果:DataRace 23 GT; ReadWhileAdd()(Context =,TestType = MChess):[DataRace]在GetOrAdd找到数据竞争:FastReadThreadSafeDictionary.cs(68)

_bucketContainer = newBucketContainer;中的作业GetOrAdd

我的实际问题是:为什么作业_bucketContainer = newBucketContainer是竞争条件?当前正在执行的广告TryGetValue总是复制_bucketContainer字段,因此不应该对更新感到困扰(除了在复制发生后可能会将搜索到的值添加到_bucketContainer,但这与数据竞争无关)。在GetOrAdd中,有一个显式锁定来阻止并发访问。这是国际象棋中的一个错误还是我错过了一些非常明显的错误?

1 个答案:

答案 0 :(得分:0)

正如@CodesInChaos在问题评论中提到的那样,我错过了TryGetValue中的易失性读物。该方法现在看起来像这样:

public bool TryGetValue(TypeKey typeKey, out TValue value)
{
    var bucketContainer = Volatile.Read(ref _bucketContainer);
    return bucketContainer.TryFind(typeKey, out value);
}

此易失性读取是必要的,因为访问此字典的不同线程可能会相互独立地缓存数据和重新排序指令,这可能会导致数据争用。另外,运行代码的CPU架构也很重要,例如,默认情况下,x86和x64处理器执行易失性读取,而对于ARM或Itanium等其他体系结构可能不是这样。这就是为什么必须使用内存屏障与其他线程同步读访问,内存屏障在Volatile.Read内部执行(注意lock语句内部也使用内存屏障)。 Joseph Albahari在这里写了一篇全面的教程:http://www.albahari.com/threading/part4.aspx