关于C#中Dictionary多线程情况下CPU 100%问题的详细分析

近期线上偶尔会出现CPU跑到100%的问题,通过抓取、分析dump文件定位到是Dictionary线程不安全导致死循环,使CPU飙到100%,改用ConcurrentDictionary就可以解决问题。

我也知道Dictionary线性不安全,但我以为它只是在多线程里面会导致脏读而已,并不知道会导致CPU 100%。那我就好奇,为什么Dictionary的ContainsKey方法,会导致CPU100%呢?这里需要结合Dictionary源码和多线程并发场景深挖一下。

一、源码分析

Dictionary是一种KV(Key-Value)的数据类型,它是一个泛型的类型,使用Dictonary<TKey, TValue>时必须指定键和值的类型,类似java的HashMap。定义如下:

我们回到主题,通过dump看到CPU 100%时代码卡在了ContainsKey里面,ContainsKey方法具体实现的源码如下:

 它直接调用了FindEntry,我们继续看FindEntry:

我们发现FindEntry中有一个for循环,代码卡在这个方法内、CPU 100%,那这个循环的嫌疑就非常大了,有可能此处出现了死循环导致CPU 100%。那什么情况下才会出现死循环呢?
我们看到循环继续执行的条件是i>=0,也就是说如果并发造成了entries[i].next永远大于等于0,那本for循环就成了死循环,也就是说entries[i].next本应该小于0时却被赋了一个大于等于0的值,因此我们要重点关注entries[i].next的赋值操作。
通过分析源码发现Add(TKey, TValue)最终会对entries[i].next赋值,因此我们使用dict.Add()来分析count、buckets、entries、entries[i].next的作用,Add方法具体实现的源码如下:

 它直接调用了Insert,我们继续看Insert:

二、顺序插入元素执行逻辑

 dict.Add("first","value01")首次添加元素时,需要执行Initialize(0),我们先看Initialize: 

在此可以得到第一个重要结果,buckets和entries是两个长度相等的数组,而且buckets中所有元素的默认值是-1。

求hashCode时而为什么要 & 0x7FFFFFFF呢,这是为了保证num1得到的是一个正数的小技巧,0x7FFFFFFF实际就是int.MaxValue

然后继续分析dict.Add("first","value01")首次添加元素之外的公共执行逻辑:
1、假设buckets、entries的长度是3,first计算出HashCode是7,那么targetBucket=1
2、for循环中buckets[targetBucket]的值是-1直接跳出for循环(targetBucket=1)
3、freeCount=0 -> index=count=0 ->count++后count值为1
4、entries[index].hashCode=7(index=0、targetBucket=1)
     entries[index].next=buckets[targetBucket]=-1
     entries[index].key=first;
     entries[index].value=value01;
     buckets[targetBucket]=index

然后继续分析dict.Add("second","value02")的公共执行逻辑:
1、假设buckets、entries的长度是3,second计算出HashCode是8,那么targetBucket=2
2、for循环中buckets[targetBucket]的值是-1直接跳出for循环(targetBucket=2)
3、freeCount=0 -> index=count=1 ->count++后count值为2
 4、entries[index].hashCode=8(index=1、targetBucket=2)
      entries[index].next=buckets[targetBucket]=-1
      entries[index].key=second;
      entries[index].value=value02;
      buckets[targetBucket]=index

然后继续分析dict.Add("third","value03")的公共执行逻辑:
1、假设buckets、entries的长度是3,third计算出HashCode是10,那么targetBucket=1
2、for循环中buckets[targetBucket]的值是0,entries[0].next=-1跳出for循环
3、freeCount=0 -> index=count=2 ->count++后count值为3
4、entries[index].hashCode=10(index=2、targetBucket=1)
      entries[index].next=buckets[targetBucket]=0
      entries[index].key=third;
      entries[index].value=value03;
      buckets[targetBucket]=index

dict顺序添加三个元素之后,其在内存中的存储结构如下:

在此可以得到第二个重要结果,当多个key的hashCode%buckets.Length相同时会出现链式结构,entries[i].next=-1表示本元素为链条中的最后一个元素;
当Dictionary添加新元素时会按顺序将其赋值给entries中的空元素,假设entries是长度为5的数组,Dictionary添加的第一个元素会被赋值给entries[0]、第二个元素会被赋值给entries[1]......以此类推,当entries中的空元素用光后会进行扩容;
buckets[i]中存储的值是【本次被赋值的entries元素】的索引,而且【被赋值的entries元素】的索引随count自增,entries[i].next存储的值是buckets[i]上次存储的值,即当Dictionary中元素成链时,把新元素放在链条首位。

三、并发插入元素执行逻辑

在顺序插入三个元素的基础上分析并发插入的场景,此时count=3,并发插入dict.Add("fourth","value04")的公共执行逻辑(并发1):
1、假设buckets、entries的长度是3,fourth计算出HashCode是13,那么targetBucket=1
2、for循环中buckets[targetBucket]的值是2,entries[2].next=0,entries[0].next=-1跳出for循环
3、freeCount=0 -> index=count=3 ->count++后count值为4
4、entries[index].hashCode=13(index=3、targetBucket=1)
      entries[index].next=buckets[targetBucket]=2
      entries[index].key=fourth;
      entries[index].value=value04;
      buckets[targetBucket]=index

此时count=3,并发插入dict.Add("fifth","value05")的公共执行逻辑(并发2):
1、假设buckets、entries的长度是3,fifth计算出HashCode是16,那么targetBucket=1
2、for循环中buckets[targetBucket]的值是2,entries[2].next=0,entries[0].next=-1跳出for循环
3、freeCount=0 -> index=count=3 ->count++后count值为4
4、entries[index].hashCode=16(index=3、targetBucket=1)
       //并发2读取count时并发1还没执行count++,所以两个并发中都获取到了index=count=3
       //并发2执行entries[index].next=buckets[targetBucket]时并发1已经执行了buckets[targetBucket]=index,
       //所以并发2中读取到buckets[targetBucket]=3,代入数据就是entries[3].next=3,因此造成了死循环
       //并发插入两次之后entries中其实只增加了一个元素,因为后边的元素会替换之前的元素

      entries[index].next=buckets[targetBucket]=3
      entries[index].key=fifth;
      entries[index].value=value05;
      buckets[targetBucket]=index

至此能造成死循环的数据已经准备好了,当执行ContainsKey(key)时,如果hashCode%buckets.Length==1就会造成死循环了,由于条件非常苛刻所以死循环只是偶尔出现,这就是Dictionary多线程情况下造成CPU 100%的整个过程,解决方法就在文章开头。

四、总结死锁产生的条件

  1. 并发的时候,2个Key算出来的hashcode相同,导致targetBucket一样
  2. 并发2读取count时并发1还没执行count++,导致this.count 出现脏读
  3. 并发2执行entries[index].next=buckets[targetBucket]时并发1已经执行了buckets[targetBucket]=index,导致Dictionary中链条成环

如果本文对你有帮助的话,请点赞、评论、收藏支持下,谢谢!

  • 6
    点赞
  • 10
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 2
    评论
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

changuncle

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

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

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

打赏作者

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

抵扣说明:

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

余额充值