ConcurrentDictionary<T,V> 的这两个操作不是原子操作
好久不见,马甲哥封闭居家半个月,记录之前遇到的一件小事。
ConcurrentDictionary<TKey,TValue>绝大部分api都是线程安全且原子性的,
唯二的例外是接收工厂委托的api:AddOrUpdate、GetOrAdd,这两个api的全过程不是原子性的,需要引起重视。
All these operations are atomic and are thread-safe with regards to all other operations on the ConcurrentDictionary<TKey,TValue> class. The only exceptions are the methods that accept a delegate, that is, AddOrUpdate and GetOrAdd.
之前有个同事就因为这个case背了一个P。
AddOrUpdate(TKey, TValue, Func<TKey,TValue,TValue> valueFactory);
GetOrAdd(TKey key, Func<TKey, TValue> valueFactory);
(注意,包括其他接收工厂委托的重载函数)
整个过程中涉及与字典直接交互的都用到锁,valueFactory工厂函数在锁定区外面被执行,因此,工厂产值代码不受原子性约束。
安全字段GetOrAdd(key, valueFactory) 源码分析
我们以GetOrAdd(TKey key, Func<TKey, TValue> valueFactory)为例。
这个函数
① 利用工厂计算出可能会插入的value, 也就是说不管字典最终是否会插入该值, 这个工厂函数都先执行了一遍。 这一步实际没有加锁, 这是陷阱的关键。
② 在字典中尝试添加第一步的value : 这一过程会加锁。
public TValue GetOrAdd(TKey key, Func<TKey, TValue> valueFactory)
{
if (key is null)
{
ThrowHelper.ThrowKeyNullException();
}
if (valueFactory is null)
{
ThrowHelper.ThrowArgumentNullException(nameof(valueFactory));
}
Tables tables = _tables;
IEqualityComparer<TKey>? comparer = tables._comparer;
int hashcode = GetHashCode(comparer, key);
if (!TryGetValueInternal(tables, key, hashcode, out TValue? resultingValue))
{
TryAddInternal(tables, key, hashcode, valueFactory(key), updateIfExists: false, acquireLock: true, out resultingValue);
}
return resultingValue;
}
以上是GetOrAdd(key, valueFactory)的入口函数,该函数做上层业务逻辑(未出现锁相关的代码),明显看到valueFactory(key) 是直接计算得到值;
与字典内操作相关的函数是 Try开头的函数:读操作TryGetValueInternal不加锁; 写操作TryAddInternal加锁(acquireLock: true),也就是与字典直接相关的操作保证原子性。
Q1: valueFactory工厂函数不在锁定范围,为什么不在锁范围?
A: 还不是因为微软不相信你能写出健壮的业务代码,未知的业务代码可能造成死锁。
However, delegates for these methods are called outside the locks to avoid the problems that can arise from executing unknown code under a lock. Therefore, the code executed by these delegates is not subject to the atomicity of the operation.
Q2:带来的效果?
- valueFactory工厂函数可能会多次执行
- 虽然会多次执行, 但插入的值永远是一个,插入的值取决于哪个线程率先插入字典。
Q3: 怎么做到的?
A: 源代码做了double check了,后续线程通过工厂类创建值后,会再次检查字典,发现已有值,会丢弃自己创建的值。
示例代码:
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);
}
}
上面4个线程并发插入字典,每次随机输出,_runCount=4显示工厂类执行4次。

Q4:如果工厂产值的代价很大,不允许多次创建,如何实现?
笔者的同事之前就遇到这样的问题,调用安全字典的GetOrAdd(key, valueFactory) 时, 不管字典最终是否会插入该值, 这个工厂函数都先执行了一遍, 高并发频繁调用时(在工厂函数创建redis连接),最终打挂了机器。
A: 有一个trick能解决这个问题: valueFactory工厂函数返回Lazy
using System.Collections.Concurrent;
public class Program
{
private static int _runCount2 = 0;
private static readonly ConcurrentDictionary<string, Lazy<string>> _lazyDictionary
= new ConcurrentDictionary<string, Lazy<string>>();
public static void Main(string[] args)
{
task1 = Task.Run(() => PrintValueLazy("The first value"));
task2 = Task.Run(() => PrintValueLazy("The second value"));
task3 = Task.Run(() => PrintValueLazy("The three value"));
task4 = Task.Run(() => PrintValueLazy("The four value"));
Task.WaitAll(task1, task2, task4, task4);
PrintValue("The five value");
Console.WriteLine($"Run count: {_runCount2}");
}
public static void PrintValueLazy(string valueToPrint)
{
var valueFound = _lazyDictionary.GetOrAdd("key",
x => new Lazy<string>(
() =>
{
Interlocked.Increment(ref _runCount2);
Thread.Sleep(100);
return valueToPrint;
}));
Console.WriteLine(valueFound.Value);
}
}

上面示例,依旧会稳定随机输出,但是_runOut=1表明产值动作只执行了一次、
valueFactory工厂函数返回Lazy
① 工厂函数依旧没有锁定过程,会多次执行;
② 与最上面的例子类似,只会插入一个Lazy容器(后续线程依旧做double check发现字典key已经有Lazy容器了,会放弃插入);
③ 线程执行Lazy
④ 多个线程尝试执行LazyExecutionAndPublication:
不仅以线程安全的方式执行, 而且确保只会执行一次构造函数。
public Lazy(Func<T> valueFactory)
:this(valueFactory, LazyThreadSafetyMode.ExecutionAndPublication, useDefaultConstructor: false)
{
}
| 控制构造函数执行的枚举值 | 描述 |
|---|---|
| ExecutionAndPublication | 能确保只有一个线程能够以线程安全方式执行构造函数 |
| None | 线程不安全 |
| Publication | 并发线程都会执行初始化函数,以先完成初始化的值为准 |
IHttpClientFactory在构建<命名HttpClient,活跃连接Handler>字典时, 也用到了这个技巧,大家自行欣赏DefaultHttpCLientFactory源码。
总结
为解决ConcurrentDictionary GetOrAdd(key, valueFactory) 工厂函数在并发场景下被多次执行的问题。
① valueFactory工厂函数产生Lazy容器
② 将Lazy容器的值初始化姿势设定为ExecutionAndPublication(线程安全且执行一次)。
两姿势缺一不可。
本文来自博客园,作者:{有态度的马甲},转载请注明原文链接:https://www.cnblogs.com/JulianHuang/p/16698976.html
欢迎关注我的原创技术、职场公众号, 加好友谈天说地,一起进化

浙公网安备 33010602011771号