近期线上偶尔会出现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%的整个过程,解决方法就在文章开头。
四、总结死锁产生的条件
- 并发的时候,2个Key算出来的hashcode相同,导致targetBucket一样
- 并发2读取count时并发1还没执行count++,导致this.count 出现脏读
- 并发2执行entries[index].next=buckets[targetBucket]时并发1已经执行了buckets[targetBucket]=index,导致Dictionary中链条成环
如果本文对你有帮助的话,请点赞、评论、收藏支持下,谢谢!