一:背景
1.講故事
今天是🐏的第四天,頭終于不巨疼了,寫文章已經沒什么問題,趕緊爬起來寫。
這個月初有位朋友找到我,說他的程序出現了CPU爆高,讓我幫忙看下怎么回事,簡單分析了下有兩點比較有意思。
這是一個安全生產的信息管理平臺,第一次聽說,我的格局小了。
這是一個經典的 CPU 爆高問題,過往雖有分析,但沒有刨根問底,剛好這一篇就來問一下底吧。
話不多說,我們上 WinDbg 說話。
二:WinDbg 分析
1. 真的 CPU 爆高嗎?
別人說爆高不算,我們得拿數據說話不是,驗證命令就是 !tp
。
0:085>?!tp
CPU?utilization:?100%
Worker?Thread:?Total:?40?Running:?26?Idle:?6?MaxLimit:?32767?MinLimit:?8
Work?Request?in?Queue:?0
--------------------------------------
Number?of?Timers:?0
--------------------------------------
Completion?Port?Thread:Total:?1?Free:?1?MaxFree:?16?CurrentLimit:?1?MaxLimit:?1000?MinLimit:?8
從卦中看果然是被打滿了,接下來可以用 ~*e !clrstack
觀察各個線程都在做什么,稍微一觀察就會發現有很多的線程卡在 FindEntry()
方法上,截圖如下:

從圖中可以看到,有 25 個線程都停在 FindEntry()
之上,如果你的經驗比較豐富的話,我相信你馬上就知道這是多線程環境下使用了非線程安全集合 Dictionary
造成的死循環,把 CPU 直接打爆。
按以往套路到這里就結束了,今天我們一定要刨到底。
2. 為什么會出現死循環
要知道死循環的成因,那就一定要從 FindEntry
上入手。
private?int?FindEntry(TKey?key)
{if?(key?==?null){ThrowHelper.ThrowArgumentNullException(ExceptionArgument.key);}if?(buckets?!=?null){int?num?=?comparer.GetHashCode(key)?&?0x7FFFFFFF;for?(int?num2?=?buckets[num?%?buckets.Length];?num2?>=?0;?num2?=?entries[num2].next){if?(entries[num2].hashCode?==?num?&&?comparer.Equals(entries[num2].key,?key)){return?num2;}}}return?-1;
}
仔細觀察上面的代碼,如果真有死循環肯定是在 for
中出不來,如果是真的出在 for
上,那問題自然在 next
指針上。
關于 Dictionary 的內部布局和解析 可以參見我的 高級調試訓練營,這里我們就不細說了。
那是不是出在 next 指針上呢?我們來剖析下方法上下文。
3. 觀察 next 指針布局
為了方便觀察,先切到 85
號線程。
0:085>?~85s
mscorlib_ni!System.Collections.Generic.Dictionary<string,F2.xxx.ORM.SqlEntity>.FindEntry+0x8f:
00007ff8`5f128ccf?488b4e10????????mov?????rcx,qword?ptr?[rsi+10h]?ds:0000017f`39c07d00=0000017eb9ee00c0
0:085>?!clrstack
OS?Thread?Id:?0x4124?(85)Child?SP???????????????IP?Call?Site
0000007354ebcc70?00007ff85f128ccf?System.Collections.Generic.Dictionary`2[[System.__Canon,?mscorlib],[System.__Canon,?mscorlib]].FindEntry(System.__Canon)?[f:\dd\ndp\clr\src\BCL\system\collections\generic\dictionary.cs?@?305]
接下來把 Dictionary
中的 Entry[]
中的 next 給展示出來,可以用 !mdso
命令。
0:085>?!mdso
Thread?85:
Location??????????Object????????????Type
------------------------------------------------------------
RCX:??????????????0000017eb9ee00c0??System.Collections.Generic.Dictionary`2+Entry[[System.String,?mscorlib],[xx]][]
RSI:??????????????0000017f39c07cf0??System.Collections.Generic.Dictionary`2[[System.String,?mscorlib],[xxx.xxx]]0:085>?!mdt?-e:2?0000017eb9ee00c0
0000017eb9ee00c0?(System.Collections.Generic.Dictionary`2+Entry[[System.String,?mscorlib],[xxx.xxx]][],?Elements:?3,?ElementMT=00007ff816cedc18)
[0]?(System.Collections.Generic.Dictionary`2+Entry[[System.String,?mscorlib],[F2.xxx]])?VALTYPE?(MT=00007ff816cedc18,?ADDR=0000017eb9ee00d0)hashCode:0x0?(System.Int32)next:0x0?(System.Int32)key:NULL?(System.__Canon)value:NULL?(System.__Canon)
[1]?(System.Collections.Generic.Dictionary`2+Entry[[System.String,?mscorlib],[F2.xxx]])?VALTYPE?(MT=00007ff816cedc18,?ADDR=0000017eb9ee00e8)hashCode:0x5aba4760?(System.Int32)next:0xffffffff?(System.Int32)key:0000017f39c0ab50?(System.String)?Length=20,?String="xxxMessage_Select"value:0000017f39c0b5d0?(xxx.xxx.ORM.SqlEntity)
[2]?(System.Collections.Generic.Dictionary`2+Entry[[System.String,?mscorlib],[F2.xxx]])?VALTYPE?(MT=00007ff816cedc18,?ADDR=0000017eb9ee0100)hashCode:0x65b6e27b?(System.Int32)next:0x1?(System.Int32)key:0000017f39c09d58?(System.String)?Length=20,?String="xxxMessage_Insert"value:0000017f39c0ba50?(xxx.xxx.ORM.SqlEntity)
從卦中看也蠻奇葩的,只有三個元素的 Dictionary
還能死循環。。。如果你仔細觀察會發現 [0]
項是一種有損狀態,value 沒值不說, next:0x0
可是有大問題的,它會永遠指向自己,因為 next 是指向 hash 掛鏈中的下一個節點的數組下標,畫個圖大概是這樣。

接下來我們驗證下是不是入口參數不幸進入了 [0]
號坑,然后在這個坑中永遠指向自己呢?要想尋找答案,只需要在 FindEntry
的匯編代碼中找到 int num = comparer.GetHashCode(key) & 0x7FFFFFFF;
中的 num 值,看它是不是 0 即可。
0:085>?!U?/d?00007ff85f128ccf
preJIT?generated?code
System.Collections.Generic.Dictionary`2[[System.__Canon,?mscorlib],[System.__Canon,?mscorlib]].FindEntry(System.__Canon)
Begin?00007ff85f128c40,?size?130.?Cold?region?begin?00007ff85ff07ff0,?size?11
...
f:\dd\ndp\clr\src\BCL\system\collections\generic\dictionary.cs?@?303:
00007ff8`5f128c6f?488b5e18????????mov?????rbx,qword?ptr?[rsi+18h]
00007ff8`5f128c73?488b0e??????????mov?????rcx,qword?ptr?[rsi]
00007ff8`5f128c76?488b5130????????mov?????rdx,qword?ptr?[rcx+30h]
00007ff8`5f128c7a?488b2a??????????mov?????rbp,qword?ptr?[rdx]
00007ff8`5f128c7d?4c8b5d18????????mov?????r11,qword?ptr?[rbp+18h]
00007ff8`5f128c81?4d85db??????????test????r11,r11
00007ff8`5f128c84?750f????????????jne?????mscorlib_ni!System.Collections.Generic.Dictionary<string,xxx.SqlEntity>.FindEntry+0x55?(00007ff8`5f128c95)
00007ff8`5f128c86?488d154d2f1800??lea?????rdx,[mscorlib_ni+0x68bbda?(00007ff8`5f2abbda)]
00007ff8`5f128c8d?e8ce44f3ff??????call????mscorlib_ni+0x43d160?(00007ff8`5f05d160)?(mscorlib_ni)
00007ff8`5f128c92?4c8bd8??????????mov?????r11,rax
00007ff8`5f128c95?488bcb??????????mov?????rcx,rbx
00007ff8`5f128c98?488bd7??????????mov?????rdx,rdi
00007ff8`5f128c9b?3909????????????cmp?????dword?ptr?[rcx],ecx
00007ff8`5f128c9d?41ff13??????????call????qword?ptr?[r11]
00007ff8`5f128ca0?8bd8????????????mov?????ebx,eax
00007ff8`5f128ca2?81e3ffffff7f????and?????ebx,7FFFFFFFh
...0:085>???ebx
Evaluate?expression:?957083499?=?00000000`390bef6b0:085>???0n957083499?%?0n3
Evaluate?expression:?0?=?00000000`00000000
從匯編代碼中分析得出,num 是放在 ebx
寄存器上,此時 num=957083499
,再 %3
之后就是 0 號坑,大家再結合源代碼,你會發現這里永遠都不會退出,永遠都是指向自己,自然就是死循環了。
3. .NET6 下的補充
前段時間在整理課件時發現在 .NET6 中不再傻傻的死循環,而是在嘗試 entries.Length
次之后還得不到結束的話,強制拋出異常,代碼如下:
internal?ref?TValue?FindValue(TKey?key)
{uint?hashCode2?=?(uint)comparer.GetHashCode(key);int?bucket2?=?GetBucket(hashCode2);Entry[]?entries2?=?_entries;uint?num2?=?0u;bucket2--;while?((uint)bucket2?<?(uint)entries2.Length){reference?=?ref?entries2[bucket2];if?(reference.hashCode?!=?hashCode2?||?!comparer.Equals(reference.key,?key)){bucket2?=?reference.next;num2++;if?(num2?<=?(uint)entries2.Length){continue;}goto?IL_0171;}goto?IL_0176;}return?ref?Unsafe.NullRef<TValue>();
IL_0176:return?ref?reference.value;
IL_0171:ThrowHelper.ThrowInvalidOperationException_ConcurrentOperationsNotSupported();goto?IL_0176;
}
可能是 .NET團隊 被這樣的問題咨詢煩了,干脆拋一個異常得了。。。
三:總結
多線程環境下使用線程不安全集合,問題雖然很小白,但還是有很多朋友栽在這上面,值得反思哈,借這一次機會進一步解釋下死循環形成的內部機理。