記一次 .NET 某安全生產資訊系統 CPU爆高分析

一線碼農發表於2022-12-19

一:背景

1.講故事

今天是?的第四天,頭終於不巨疼了,寫文章已經沒什麼問題,趕緊爬起來寫。

這個月初有位朋友找到我,說他的程式出現了CPU爆高,讓我幫忙看下怎麼回事,簡單分析了下有兩點比較有意思。

  1. 這是一個安全生產的資訊管理平臺,第一次聽說,我的格局小了。

  2. 這是一個經典的 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`390bef6b

0: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團隊 被這樣的問題諮詢煩了,乾脆拋一個異常得了。。。

三: 總結

多執行緒環境下使用執行緒不安全集合,問題雖然很小白,但還是有很多朋友栽在這上面,值得反思哈,借這一次機會進一步解釋下死迴圈形成的內部機理。

圖片名稱

相關文章