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

Posted dotNET跨平台

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了如何证明 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 字典操作不是线程安全的呢?

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

线程安全

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

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

假设两个线程各自将全局整数变量的值递增 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 方法不是线程安全的。

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字典操作是线程安全的!

真是这样吗?

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

从上面线程不安全的例子我们看到,一共 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);

理论上,_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;

总结

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

还不赶紧试试?! 

添加微信号【MyIO666】,邀你加入技术交流群

以上是关于如何证明 ConcurrentDictionary 字典操作"不全是"线程安全的的主要内容,如果未能解决你的问题,请参考以下文章

.NET 锁定还是 ConcurrentDictionary?

ConcurrentDictionary

.NET - 字典锁定与 ConcurrentDictionary

ConcurrentDictionary和线程

ConcurrentDictionary 的列表顺序是不是得到保证?

ConcurrentDictionary与Dictionary 替换