3

记一次 .NET 某安全生产信息系统 CPU爆高分析 - 一线码农

 1 year ago
source link: https://www.cnblogs.com/huangxincheng/p/16992822.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

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() 方法上,截图如下:

214741-20221219182141664-566296860.png

从图中可以看到,有 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 挂链中的下一个节点的数组下标,画个图大概是这样。

214741-20221219182141684-31414010.png

接下来我们验证下是不是入口参数不幸进入了 [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团队 被这样的问题咨询烦了,干脆抛一个异常得了。。。

多线程环境下使用线程不安全集合,问题虽然很小白,但还是有很多朋友栽在这上面,值得反思哈,借这一次机会进一步解释下死循环形成的内部机理。

图片名称

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK