ConcurrentDictionary字典操作竟然不全是线程安全的?

9738f3e36941cd9098050916814e4da0.gif

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

标题不准确,实际上ConcurrentDictionary<TKey,TValue>绝大部分api都是线程安全且原子性的[1],
唯二的例外是接收工厂函数的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);
(注意,包括其他接收工厂委托的重载函数)

整个过程中涉及与字典直接交互的都用到精细锁,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[2]了,后续线程通过工厂类创建值后,会再次检查字典,发现已有值,会丢弃自己创建的值。

示例代码:

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次。

141023b024054eb99ad4e7bc6a309f49.png

Q4:如果工厂产值的代价很大,不允许多次创建,如何实现?

笔者的同事之前就遇到这样的问题,高并发请求频繁创建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);
   }
}
7bfaced80ec2deea86b962d706513d30.png

上面示例,依旧会随机稳定输出,但是_runOut=1表明产值动作只执行了一次、

valueFactory工厂函数返回Lazy容器是一个精妙的trick。

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

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

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

④ 多个线程尝试执行Lazy.Value, 但这个延迟初始化方式被默认设置为ExecutionAndPublication:
不仅以线程安全的方式执行, 而且确保只会执行一次构造函数。

public Lazy(Func<T> valueFactory)
  :this(valueFactory, LazyThreadSafetyMode.ExecutionAndPublication, useDefaultConstructor: false)
{
}
控制构造函数执行的枚举值描述
ExecutionAndPublication[3]能确保只有一个线程能够以线程安全方式执行构造函数
None线程不安全
Publication并发线程都会执行初始化函数,以先完成初始化的值为准

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

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


总结

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

① valueFactory工厂函数产生Lazy容器;

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

两姿势缺一不可。

本人会不时修正理解、更正错误,请适时移步左下角永久更新地址;也请看客大胆斧正。

引用链接

[1] ConcurrentDictionary<TKey,TValue>绝大部分api都是线程安全的: https://docs.microsoft.com/en-us/dotnet/api/system.collections.concurrent.concurrentdictionary-2?view=net-6.0
[2] double check: https://github.com/dotnet/runtime/blob/main/src/libraries/System.Collections.Concurrent/src/System/Collections/Concurrent/ConcurrentDictionary.cs#L1152
[3] ExecutionAndPublication: https://docs.microsoft.com/en-us/dotnet/api/system.threading.lazythreadsafetymode?view=net-6.0#system-threading-lazythreadsafetymode-executionandpublication
[4] DefaultHttpCLientFactory源码: https://github.com/dotnet/runtime/blob/main/src/libraries/Microsoft.Extensions.Http/src/DefaultHttpClientFactory.cs#L118

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

有态度的马甲

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值