记一次 .NET 某注塑模具系统 CPU爆高分析 澳五机器人
一:背景
1. 讲故事
上个月,一位做工业智能制造的朋友找到我,说他们车间配套的注塑模具人机交互监控系统,上线一周就多次出现偶发性CPU爆高,每次持续时间从三五分钟到十几分钟不等,车间中控大屏会出现卡顿,模具状态数据刷新延迟,已经影响到生产线的正常监测。朋友团队有一定的调试基础,折腾了一周没定位到根因,特意找过来帮忙分析。
这次出问题的系统是部署在工业控制计算机上的,硬件配置不算高,只有4核8G的配置,平时只运行监控程序和边缘计算模块,正常负载下CPU使用率一般维持在15%以内,爆高的时候直接冲到95%以上,内存倒是没什么异常。闲话不多说,直接上WinDbg分析。
二:WinDbg 分析
1. 先确认CPU是不是真的爆高
分析问题的第一步永远是用数据说话,不能只听业务方描述,我们先抓了完整的dump文件,载入WinDbg之后先用!tp命令观察线程池状态:
0:000> !tp
CPU utilization: 92%
Worker Thread: Total: 16 Running: 16 Idle: 0 MaxLimit: 2048 MinLimit: 4
Work Request in Queue: 0
--------------------------------------
Number of Timers: 3
--------------------------------------
Completion Port Thread:Total: 3 Free: 3 MaxFree: 8 CurrentLimit: 3 MaxLimit: 1000 MinLimit: 4
再用!cpuid确认一下核心数:
0:000> !cpuid
CP F/M/S Manufacturer MHz
0 6,10,9 <unavailable> 2100
1 6,10,9 <unavailable> 2100
2 6,10,9 <unavailable> 2100
3 6,10,9 <unavailable> 2100
确实四个核心都被拉满了,CPU利用率92%,而且线程池里16个工作线程全部都在跑,没有空闲线程,问题定位思路一下子就清晰了:所有工作线程都被占满了,接下来看看这些线程现在都在做什么。
2. 遍历所有线程看调用栈
要找CPU爆高的根因,最直接的方式就是把所有线程的托管调用栈全部打出来,用命令~*e !clrstack,很快就发现了异常:几乎所有线程的调用栈都停在了同一个方法上,简化之后如下:
OS Thread Id: 0x2a34 (12)
Child SP IP Call Site
00000012F843C180 00007ffd88a37215 xxx.Injection.Monitor.DataProcessor+<ProcessRealTimeData>d__18.MoveNext()
00000012F843C360 00007ffd88a2f9e2 System.Threading.ThreadPoolWorkQueue.Dispatch()
00000012F843C410 00007ffd9138e3ee System.Threading.PortableThreadPool+WorkerThread.WorkerThreadStart()
所有工作线程都卡在了实时数据处理的ProcessRealTimeData方法里,接下来我们看看这个方法的IL,再反编译出代码看看具体逻辑,很快就找到了问题:方法里有一段循环处理模腔压力曲线的代码,存在一个隐性的死循环条件。
系统每秒钟会从注塑机采集200组压力和温度数据,每次采集后都会调用数据归一化方法做预处理,归一化方法里有一段代码是用来裁剪超出边界的异常数据:
while (currentIndex < dataLength)
{
if (data[currentIndex] > upperBound || data[currentIndex] < lowerBound)
{
RemoveAt(data, currentIndex);
}
currentIndex++;
}
问题就出在RemoveAt这里:当删除当前索引位置的元素后,原数组长度减一,但是代码还是直接给currentIndex加一,本来逻辑没问题,但是如果连续两个异常数据出现在数组末尾,删除第一个后,currentIndex增加,刚好等于新的数组长度,循环就直接退出了,看起来没错对不对?
那什么时候会死循环呢?如果数组最后一个元素本身就是异常数据,删除之后,数组长度变成currentIndex,这时候循环条件currentIndex < dataLength依然成立吗?不对,原来的dataLength是进循环前就缓存好的,删除元素后没有更新dataLength!所以不管删了多少个元素,dataLength一直是原始长度,currentIndex一直增加,循环永远满足条件,直接进入死循环占满CPU。
3. 为什么是偶发性爆高?
为什么这个问题不是每次都出现,而是偶发性的?其实也很好解释:只有当采集到的实时数据末尾刚好有异常数据的时候,才会触发这个死循环,如果没有异常数据,或者异常数据不在末尾,程序就能正常运行。而注塑机的异常数据本身就是偶发的,只有压力突变超出阈值的时候才会出现,所以对应的CPU爆高也是偶发性的,这也是朋友团队一周都没定位到根因的原因。
三:问题解决与总结
1. 问题修复
找到根因之后修复就非常简单了,两个修改方式:要么把dataLength换成data.Length,每次循环都拿最新的数组长度;要么删除元素后currentIndex不增加,因为下一个元素已经移到当前索引位置了。修改后的代码:
int currentIndex = 0;
while (currentIndex < data.Count)
{
if (data[currentIndex] > upperBound || data[currentIndex] < lowerBound)
{
data.RemoveAt(currentIndex);
}
else
{
currentIndex++;
}
}
修复之后上线观察了两周,再也没有出现过CPU爆高的问题,监控系统运行稳定。
2. 总结
这次的CPU爆高其实是一个非常典型的低级错误导致的线上问题,本质就是缓存数组长度的时候没有考虑到数组长度动态变化的场景,刚好触发了边界条件进入死循环。这里也给大家做两个总结:
第一:处理动态修改长度的集合循环,一定要特别注意循环条件和索引计数,非常容易出现边界错误引发死循环,占满CPU。
第二:工业软件的偶发性问题排查,一定要抓到爆高时刻的dump文件,通过遍历所有线程的调用栈,很快就能定位到卡死的方法,比瞎猜瞎试效率高很多。
这次问题也算给朋友团队上了一课,边界条件的测试一定要覆盖到极端场景,不然一个小小的错误就会导致整个系统不可用,影响生产线的正常运行。