写点什么

ConcurrentDictionary<T,V> 的这两个操作不是原子性的

  • 2022-10-12
    四川
  • 本文字数:2435 字

    阅读完需:约 8 分钟

好久不见,马甲哥封闭居家半个月,记录之前遇到的一件小事。


ConcurrentDictionary<TKey,TValue>绝大部分api都是线程安全且原子性的


唯二的例外是接收工厂委托的 api:AddOrUpdateGetOrAdd这两个 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);


(注意,包括其他接收工厂委托的重载函数)

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:如果工厂产值的代价很大,不允许多次创建,如何实现?

笔者的同事之前就遇到这样的问题,高并发请求频繁创建 redis 连接,直接打挂了机器。


A: 有一个 trick 能解决这个问题: valueFactory 工厂函数返回 Lazy<value>容器.


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<value>容器是一个精妙的 trick。


① 工厂函数依旧没进入锁定过程,会多次执行;


② 与最上面的例子类似,只会插入一个 Lazy 容器(后续线程依旧做 double check 发现字典 key 已经有 Lazy 容器了,会放弃插入);


③ 线程执行 Lazy<T>.Value, 这时才会执行创建 value 的工厂函数;


④ 多个线程尝试执行 Lazy<T>.Value, 但这个延迟初始化方式被设置为ExecutionAndPublication


不仅以线程安全的方式执行, 而且确保只会执行一次构造函数。


public Lazy(Func<T> valueFactory)  :this(valueFactory, LazyThreadSafetyMode.ExecutionAndPublication, useDefaultConstructor: false){}
复制代码



IHttpClientFactory在构建<命名 HttpClient,活跃连接 Handler>字典时, 也用到了这个技巧,大家自行欣赏DefaultHttpCLientFactory源码


  • https://andrewlock.net/making-getoradd-on-concurrentdictionary-thread-safe-using-lazy/



总结

为解决 ConcurrentDictionary GetOrAdd(key, valueFactory) 工厂函数在并发场景下被多次执行的问题。


① valueFactory 工厂函数产生 Lazy 容器


② 将 Lazy 容器的值初始化姿势设定为ExecutionAndPublication(线程安全且执行一次)。


两姿势缺一不可。

用户头像

急性子,入戏慢。 2018-06-17 加入

阿里云社区专家博主,同程旅行基础架构 ; 热衷分享,执着于阅读写作,佛系不水文,有态度公众号:《精益码农》; 持续输出高价值Go、.NET、云原生原创文章。

评论

发布
暂无评论
ConcurrentDictionary<T,V> 的这两个操作不是原子性的_有态度的马甲_InfoQ写作社区