3

记一次 .NET 某医院HIS系统 CPU爆高分析

 3 years ago
source link: https://www.cnblogs.com/huangxincheng/p/14767250.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.
neoserver,ios ssh client

1. 讲故事

前几天有位朋友加 wx 抱怨他的程序在高峰期总是莫名其妙的cpu爆高,求助如何分析?

和这位朋友沟通下来,据说这问题困扰了他们几年😂,还请了微软的工程师过来解决,无疾而终,应该还是没找对微软的大佬。。。

关于程序CPU爆高的问题,老读者应该知道我都写了好几篇了,基本上归为两类:

  • 大量 lock 锁

少部分就是各种莫名其妙的问题了,无法一一列举 🤣🤣🤣,既然朋友找到我了,我得想办法给他解决,话不多聊,上 windbg。

二: windbg 分析

1. 查看同步块表

遇到这种问题,首查 同步块表 已经是我的惯性思维了,命令很简单 !syncblk


0:000> !syncblk
Index SyncBlock MonitorHeld Recursion Owning Thread Info  SyncBlock Owner
-----------------------------
Total           20779
CCW             16
RCW             21
ComClassFactory 0
Free            16490

我去,扑了一个空,同步块表中啥也没有。。。 既然和锁没关系,那就看看线程吧,毕竟线程就是靠 CPU 养着的。

2. 查看线程

要想查看系统中的 托管线程 ,可以使用 !t 命令, 线程比较多,稍微简化一下。


0:000> !t
ThreadCount:      135
UnstartedThread:  0
BackgroundThread: 132
PendingThread:    0
DeadThread:       1
Hosted Runtime:   no
                                                                                                        Lock  
       ID OSID ThreadOBJ           State GC Mode     GC Alloc Context                  Domain           Count Apt Exception
  34    1 25d4 000001ea28702130    28220 Preemptive  0000000000000000:0000000000000000 000001ea286ee080 0     Ukn 
  74    2 3ed0 000001ea286fa940    2b220 Preemptive  0000000000000000:0000000000000000 000001ea286ee080 0     MTA (Finalizer) 
  76    3 4a70 000001f4447d7810  102a220 Preemptive  0000000000000000:0000000000000000 000001ea286ee080 0     MTA (Threadpool Worker) 
  77    4 326c 000001f4447dbe60    21220 Preemptive  0000000000000000:0000000000000000 000001ea286ee080 0     Ukn 
  78    6 2dac 000001f4447d9750  1020220 Preemptive  0000000000000000:0000000000000000 000001ea286ee080 0     Ukn (Threadpool Worker) 
  79    7 1468 000001f444a2d6f0    21220 Preemptive  0000000000000000:0000000000000000 000001ea286ee080 1     Ukn (GC) 
  80    8   f0 000001f444a2cf20    21220 Preemptive  0000000000000000:0000000000000000 000001ea286ee080 0     Ukn 
  81    9 3118 000001f444a2f630    21220 Preemptive  0000000000000000:0000000000000000 000001ea286ee080 0     Ukn 

先卖个关子,可有朋友看出这些线程有什么异样??? 对,线程 79 的最后一列有一个 Ukn (GC) 标记,我想你肯定好奇,这说明什么? 由于底层GC的模式有些变更,但不管怎么说,它在一定程度上告诉你,你的程序触发了GC,为了进一步验证,可以用 !t -special 看下 79 号线程到底属于什么类别以及更加详细的信息。


0:000> !t -special
ThreadCount:      135
UnstartedThread:  0
BackgroundThread: 132
PendingThread:    0
DeadThread:       1
Hosted Runtime:   no
                                                                                                        Lock  
       ID OSID ThreadOBJ           State GC Mode     GC Alloc Context                  Domain           Count Apt Exception
  34    1 25d4 000001ea28702130    28220 Preemptive  0000000000000000:0000000000000000 000001ea286ee080 0     Ukn 
  79    7 1468 000001f444a2d6f0    21220 Preemptive  0000000000000000:0000000000000000 000001ea286ee080 1     Ukn (GC) 

OSID Special thread type
41 38d8 DbgHelper 
42 1c88 GC 
74 3ed0 Finalizer 
75 402c ProfilingAPIAttach 
76 4a70 Timer
79 1468 GC SuspendEE 

从最后一行输出中可以看到, 79就是GC线程,后面还有一个奇怪的 SuspendEE 标记,你又好奇了,这又啥意思?

SuspendEE = Suspend CLR Execution Engine (EE)

也就是说,79号线程把 CLR执行引擎 给冻结了,目的很简单,就是为了方便其他31个GC线程打理当前的 托管堆,不过这老哥机器真🐂👃,32core,也不知道哪家医院这么给力,补充一下,用 !cpuid 验证。


0:000> !cpuid
CP  F/M/S  Manufacturer     MHz
 0  6,62,4  <unavailable>   2600
 1  6,62,4  <unavailable>   2600
 2  6,62,4  <unavailable>   2600
 3  6,62,4  <unavailable>   2600
 xxx
31  6,62,4  <unavailable>   2600

既然预判到了是 GC 触发,下一步就可以把所有线程的托管和非托管堆栈全部调出来。

3. 查看各个线程栈

要想查看各个线程的托管和非托管栈很简单, 使用 !eestack 即可,然后可以检索关键词 WaitUntilGCComplete 来判断有多少线程在等待GC处理完毕。

从图中可以看出,当前有 40 个线程被阻塞了,真好,问题越来越清晰了,接下来再分析到底是哪个线程做了什么不该做的事,导致 GC 触发,同样也可以搜 try_allocate_more_space 来判断哪些线程正在分配空间。

我去,可以很明显的看出当前 170187 号线程正在分配大对象 gc_heap::allocate_large_object 触发了 GC,本身大对象堆是一个令人生畏的东西,对它的回收清扫都是非常耗CPU资源的,这也和朋友说到的 1分钟左右CPU就下降了 的情况比较吻合。

4. 寻找祸首

现在关注点就在这两个线程上了,我看了下这两个线程栈都是同一处方法,所以这里就挑一个 187 线程来分析吧,可以用 !clrstack 看下它的托管栈。


0:187> !clrstack 
OS Thread Id: 0x1ef0 (187)
        Child SP               IP Call Site
00000054ce631e30 00007ffc4021bde2 System.String.FillStringChecked(System.String, Int32, System.String)
00000054ce631e70 00007ffc402222a8 System.String.ConcatArray(System.String[], Int32)
00000054ce631ed0 00007ffc40223528 System.String.Concat(System.String[])
00000054ce631f40 00007ffbe6dbdafb BLL.xxx.xxx.GetRowString(System.String, Boolean, Boolean, System.String, System.String, System.String, System.String, System.String, System.String, Int32, System.String, System.String, System.String, System.String, System.String, System.String, Int32, Int32, System.String, System.Nullable`1, System.Nullable`1, System.String, System.Nullable`1, System.Nullable`1, System.Nullable`1, System.Nullable`1, System.String, System.String, System.String, System.String, System.String ByRef)
00000054ce65cf40 00007ffbe6ab3187 BLL.xxx.xxx.ExpZB(System.String, Boolean, Boolean, System.String, System.String, System.String, System.String, Int32, System.String, System.String, System.String, Int32, System.String, System.String, System.String, System.String, System.String, System.String, Int32, Int32, System.String, System.Nullable`1, System.Nullable`1, System.String, System.Nullable`1, System.Nullable`1, System.Nullable`1, System.Nullable`1, System.String, System.String, System.String, System.String)

从堆栈上看,貌似是使用了 System.String.Concat 拼接 string 所致,好家伙,string 拼接这么多年不知道被人抨击了多少次,还是有很多的人踩坑😓😓😓,为了进一步验证,可以使用 !clrstack -p + !da -details xxx 看看这个 System.String[] 到底是什么,简化如下:


0:187> !clrstack -p
OS Thread Id: 0x1ef0 (187)
00000054ce631e70 00007ffc402222a8 System.String.ConcatArray(System.String[], Int32)
    PARAMETERS:
        values (<CLR reg>) = 0x000001ea69e8d2f8
        totalLength = <no data>
0:187> !da -details 0x000001ea69e8d2f8
Name:        System.String[]
Size:        128(0x80) bytes
Array:       Rank 1, Number of elements 13, Type CLASS
Element Methodtable: 00007ffc403d6948
[0] 000001f2391a83f0
    Name:        System.String
    MethodTable: 00007ffc403d6948
    EEClass:     00007ffc3fcd50e0
    Size:        445950(0x6cdfe) bytes
    File:        C:\Windows\Microsoft.Net\assembly\GAC_64\mscorlib\v4.0_4.0.0.0__b77a5c561934e089\mscorlib.dll
    String:      xxxxx

从输出信息中可以看到,String[] 里面有 13 个元素,其中最大的一个 string 是 445950 bytes = 445k,大于大对象的85k界定,所以这里的 Concat 就是症结所在,同样 170 线程也是如此,接下来我还得要解决的一个问题是:为什么会有如此大的字符串产生,代码里面到底做了什么??? 要想寻找答案,还得从dump中导出源码一探究竟。

5. 查看问题代码

要想分析问题代码,可以通过 !ip2md + !savemodule 导出 BLL.xxx.xxx.GetRowString 方法。


0:187> !ip2md 00007ffbe6dbdafb
MethodDesc:   00007ffbe5342118
Method Name:  BLL.xxx.xxx.GetRowString(System.String, Boolean, Boolean, System.String, System.String, System.String, System.String, System.String, System.String, Int32, System.String, System.String, System.String, System.String, System.String, System.String, Int32, Int32, System.String, System.Nullable`1<Int32>, System.Nullable`1<Int32>, System.String, System.Nullable`1<Single>, System.Nullable`1<Single>, System.Nullable`1<Single>, System.Nullable`1<Single>, System.String, System.String, System.String, System.String, System.String ByRef)
Class:        00007ffbe52fe328
MethodTable:  00007ffbe53421d8
mdToken:      0000000006000096
Module:       00007ffbe471a890
0:187> !savemodule  00007ffbe471a890 E:\dumps\RowString.dll
3 sections in file
section 0 - VA=2000, VASize=f7fcc, FileAddr=200, FileSize=f8000
section 1 - VA=fa000, VASize=3bc, FileAddr=f8200, FileSize=400
section 2 - VA=fc000, VASize=c, FileAddr=f8600, FileSize=200

然后祭出 ILSpy 反编译这段代码。

好家伙,这写法真🐂👃,无数的字符串拼接,我都感觉 genLOH 都来不及分配内存段了😥😥😥,真的是害死 GC 了。。。

其实这是一个教科书式的问题,也有教科书式的解决方法,而且我看了下这个方法有 600 多行的代码,基本上都是做string拼接的事,最后说一下解决方案。

  • 重构该方法,尽量用 StringBuilder 替代 String,将因这种 GC 触发的次数降到最低。

最后的小彩蛋,貌似这个分析结果和朋友的深度怀疑不约而同。。。

--------------------- 彩蛋再更新 ---------------------

更多高质量干货:参见我的 GitHub: dotnetfly

图片名称

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK