如何证明 ConcurrentDictionary 字典操作"不全是"线程安全的

前言

最近,看到一篇文章,讲到《ConcurrentDictionary字典操作竟然不全是线程安全的?》。

首先,这个结论是正确的,但文中给出的一个证明例子,我觉得是有问题的。

相关代码如下:.

using System.Collections.Concurrent;

public class Program
{
   private static int _runCount = 0;
   private static readonly ConcurrentDictionary<string, string> _dictionary
       = new ConcurrentDictionary<string, string>();

   public static void Main(string[] args)
   {
       var task1 = Task.Run(() => PrintValue("The first value"));
       var task2 = Task.Run(() => PrintValue("The second value"));
       var task3 = Task.Run(() => PrintValue("The three value"));
       var task4 = Task.Run(() => PrintValue("The four value"));
       Task.WaitAll(task1, task2, task4,task4);
       
       PrintValue("The five value");
       Console.WriteLine($"Run count: {_runCount}");
   }

   public static void PrintValue(string valueToPrint)
   {
       var valueFound = _dictionary.GetOrAdd("key",
                   x =>
                   {
                       Interlocked.Increment(ref _runCount);
                       Thread.Sleep(100);
                       return valueToPrint;
                   });
       Console.WriteLine(valueFound);
   }
}

如何证明 ConcurrentDictionary 字典操作

那这个例子是不是能够说明 ConcurrentDictionary 字典操作不是线程安全的呢?

首先,让我们看看什么是“线程安全”。

线程安全

线程安全:当多个线程同时访问时,保证实现没有争用条件。

这里的“争用条件”又是什么呢?下面举个例子来说明。

假设两个线程各自将全局整数变量的值递增 1。理想情况下,将发生以下操作序列:

线程 1 线程 2   整数值
      0
读取值   0
增加值     0
回写   1
  读取值 1
  增加值   1
  回写 2

在上面显示的情况下,最终值为 2,如预期的那样。但是,如果两个线程在没有锁定或同步的情况下同时运行,则操作的结果可能是错误的。下面的替代操作序列演示了此方案:

线程 1 线程 2   整数值
      0
读取值   0
  读取值 0
增加值     0
  增加值   0
回写   1
  回写 1

在这种情况下,最终值为 1,而不是预期的结果 2。发生这种情况是因为此处的增量操作不是互斥的。互斥操作是在访问某些资源(如内存位置)时无法中断的操作。

如果用那篇文章的例子,演示是否线程安全的代码应该是这样的:

using System.Collections.Concurrent;

public class Program
{
    private static int _runCount = 0;
    private static int _notsafeCount = 0;

    public static void Main(string[] args)
    {
        var tasks = new Task[100];
        for (int i = 0; i < tasks.Length; i++)
        {
            tasks[i] = Task.Run(() => PrintValue($"The {i} value"));
        }
      
        Task.WaitAll(tasks);
         
        Console.WriteLine($"Run count: {_runCount}");
        Console.WriteLine($"Not Safe Count: {_notsafeCount}");
    }

    public static void PrintValue(string valueToPrint)
    {
        Interlocked.Increment(ref _runCount);
        _notsafeCount++;
        Thread.Sleep(100);
    }
}

我们把 Task 数量加大到 100,便于查看效果。

执行 3 次,_runCount 始终等于 100,因为Interlocked是线程安全的,而 _notsafeCount 的值却是随机的,说明 PrintValue 方法不是线程安全的。

如何证明 ConcurrentDictionary 字典操作

GetOrAdd

让我们再把 PrintValue 方法改成使用 GetOrAdd:

public static void PrintValue(string valueToPrint)
{
    var valueFound = _dictionary.GetOrAdd("key",
                x =>
                {
                    Interlocked.Increment(ref _runCount);
                    _notsafeCount++;
                    Thread.Sleep(100);
                    return valueToPrint;
                });
    Console.WriteLine(valueFound);
}

再执行 3 次,我们发现,_notsafeCount 的值始终和 _runCount 的值相同,貌似没出现线程争用。

如何证明 ConcurrentDictionary 字典操作

 

大家看到这是不是有点懵逼,这不反而证明了,

ConcurrentDictionary字典操作是线程安全的!

真是这样吗?

这也正是我认为原文的例子不太恰当的原因:它只证明了有多个线程进入,而没证明出现了线程争用,无法得到线程不安全的结论。

从上面线程不安全的例子我们看到,一共 100 个 Task 执行而 _notsafeCount 的值都是 90 多,这说明线程争用很难被触发。而上面的操作只执行了 8 次,也许是还没触发线程争用呢?

我们修改代码,每进入 1 次 valueFactory 就执行 10 次 _notsafeCount++:

public static void PrintValue(string valueToPrint)
{
    var valueFound = _dictionary.GetOrAdd("key",
        x =>
        {
            Interlocked.Increment(ref _runCount);
            for (int i = 0; i < 10; i++)
            {
                _notsafeCount++;
                Thread.Sleep(100);
            }
            
            return valueToPrint;
        });
    Console.WriteLine(valueFound);
}

如何证明 ConcurrentDictionary 字典操作

理论上,_notsafeCount 应该等于 90(9*10),而实际上输出 88,这说明出现了线程争用。

也就是说,ConcurrentDictionary 的 GetOrAdd(TKey key, Func<TKey, TValue> valueFactory) 方法不是线程安全的。

这个结论从 GetOrAdd 方法的源码也可以得到验证,执行 valueFactory(key) 时是没加锁的:

public TValue GetOrAdd(TKey key, Func<TKey, TValue> valueFactory)
{
    if (key is null)
    {
        ThrowHelper.ThrowKeyNullException();
    }

    if (valueFactory is null)
    {
        ThrowHelper.ThrowArgumentNullException(nameof(valueFactory));
    }

    IEqualityComparer<TKey>? comparer = _comparer;
    int hashcode = comparer is null ? key.GetHashCode() : comparer.GetHashCode(key);

    if (!TryGetValueInternal(key, hashcode, out TValue? resultingValue))
    {
        TryAddInternal(key, hashcode, valueFactory(key), updateIfExists: false, acquireLock: true, out resultingValue);
    }

    return resultingValue;
}

总结

如果你想验证某个方法是否线程安全,都可以用上面这种触发线程争用方式。

还不赶紧试试?!