前言
最近,看到一篇文章,讲到《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】,邀你加入技术交流群