2026工业级实战:Process Explorer排查C#上位机内存泄漏,解决7×24运行崩溃,不用重启产线
在工业自动化领域,有一个比功能bug更致命的问题:内存泄漏。
我见过太多这样的项目:功能测试完美通过,上线前跑了72小时也没问题,结果一到产线,运行3天内存就涨到2G,一周就涨到4G,然后突然崩溃,整个产线停转。最坑的是,这种崩溃总是发生在半夜,等运维人员赶到现场,已经停线好几个小时,损失几十万。
去年我接手了一个烂尾项目,客户说他们的上位机平均每3天崩溃一次,已经换了3波开发团队都没解决。最后我只用了2个小时,用一个不到2MB的绿色工具,就找到了问题根源:PLC通信模块每次重连都会创建一个新的SerialPort对象,但从来没有释放过,运行3天就泄漏了10万多个句柄。
这个工具就是Process Explorer,微软官方出品的系统进程监控工具。它绿色免安装,不用重启程序,不用装任何运行时,不用修改一行代码,直接拷贝到产线电脑上就能用,是工业现场排查内存泄漏的最佳工具。
今天,我就把这套经过50多个项目验证的内存泄漏排查方法论分享出来,从原理到实战,一步步教你用Process Explorer定位和解决C#上位机的内存泄漏问题,让你的系统真正实现7×24小时不间断运行。
一、为什么90%的C#上位机都有内存泄漏?
很多人有一个误区:C#有垃圾回收器(GC),所以不会有内存泄漏。这是大错特错的。GC只能回收不再被任何对象引用的内存,如果你的代码中存在无意识的引用,GC就永远不会回收这些对象,最终导致内存泄漏。
1.1 工业上位机内存泄漏的特殊性
工业上位机和普通桌面应用有本质的区别:
- 运行时间长:需要7×24小时不间断运行,哪怕每天只泄漏1MB,一年也会泄漏3.6GB
- 资源密集:频繁创建和销毁串口、网络、文件、图像等资源
- 多线程复杂:通常有十几个甚至几十个线程同时运行
- 不能随意重启:重启就意味着产线停转,会造成巨大的经济损失
一个在普通应用中微不足道的内存泄漏,在工业上位机中就会被无限放大,最终导致系统崩溃。
1.2 传统排查方法的致命缺陷
很多开发者遇到内存泄漏,第一反应就是用Visual Studio的调试工具或者dotMemory。但这些工具在工业现场完全不适用:
- 需要安装:产线电脑不允许随意安装软件
- 需要重启程序:重启后泄漏现场就消失了
- 性能影响大:调试工具会占用大量CPU和内存,导致产线变慢
- 只能看托管内存:很多工业上位机的泄漏是非托管资源泄漏,这些工具看不到
而Process Explorer完美解决了这些问题:
- ✅ 绿色免安装,只有2MB,拷贝就能用
- ✅ 附加到运行中的进程,不用重启
- ✅ 性能影响极小,几乎不影响产线运行
- ✅ 同时支持托管和非托管内存、句柄、线程的监控
- ✅ 微软官方出品,安全可靠,不会被杀毒软件拦截
二、C#内存泄漏的本质与常见类型
在开始排查之前,我们首先要搞清楚C#内存泄漏的本质和常见类型。
2.1 内存泄漏的本质
C#的内存泄漏不是指内存丢失了,而是指开发者已经不再使用的对象,仍然被其他对象无意识地引用着,导致GC无法回收它们。
举个最简单的例子:
// 静态集合,生命周期和程序一样长publicstaticList<object>LeakList=newList<object>();// 每次调用都会向静态集合添加一个对象,永远不会被回收publicvoidLeak(){varobj=newobject();LeakList.Add(obj);}这个方法每次调用都会泄漏一个对象的内存,调用100万次就会泄漏100万个对象。
2.2 工业上位机最常见的两种泄漏类型
| 泄漏类型 | 原因 | 表现 |
|---|---|---|
| 托管内存泄漏 | 托管对象被无意识引用,GC无法回收 | .NET Bytes持续上涨,不会下降 |
| 非托管资源泄漏 | 非托管资源(句柄、COM对象、非托管内存)没有释放 | Private Bytes持续上涨,而.NET Bytes基本不变;句柄数持续上涨 |
工业上位机中,非托管资源泄漏占比超过70%,这也是很多开发者排查不出来的原因,因为他们只关注托管内存,忽略了非托管资源。
三、内存泄漏完整排查流程
我总结了一套标准化的内存泄漏排查流程,适用于所有C#上位机项目。按照这个流程,99%的内存泄漏都能在2小时内找到根源。
四、手把手教你用Process Explorer排查内存泄漏
下面我将结合一个真实的案例,一步步教你用Process Explorer排查内存泄漏。
4.1 准备工作
- 从微软官网下载Process Explorer:https://learn.microsoft.com/en-us/sysinternals/downloads/process-explorer
- 拷贝到产线电脑上,直接双击运行,不需要安装
- 配置符号路径:点击
Options->Configure Symbols,添加SRV*c:\symbols*https://msdl.microsoft.com/download/symbols,这样就能看到.NET的托管符号了
4.2 第一步:确认是内存泄漏
很多时候内存上涨不是泄漏,而是正常的缓存。首先我们要确认是不是真正的内存泄漏。
- 在Process Explorer中找到你的上位机进程
- 右键点击进程,选择
Properties - 切换到
Performance标签页 - 观察
Private Bytes和.NET CLR Memory -> Bytes in all Heaps这两个指标
判断标准:
- 如果系统空闲时,这两个指标仍然持续上涨,不会下降,那就是内存泄漏
- 如果内存上涨到一定程度后就稳定了,那就是正常的缓存
4.3 第二步:区分托管泄漏还是非托管泄漏
这是最关键的一步,决定了后续的排查方向。
- 如果
.NET Bytes in all Heaps和Private Bytes同步上涨,那就是托管内存泄漏 - 如果
Private Bytes持续上涨,而.NET Bytes in all Heaps基本不变,那就是非托管资源泄漏
在我们的案例中,Private Bytes从100MB涨到了2GB,而.NET Bytes in all Heaps一直稳定在200MB左右,同时Handles从1000涨到了10万+,很明显是非托管句柄泄漏。
4.4 第三步:定位泄漏的句柄类型
对于非托管句柄泄漏,我们需要先知道泄漏的是什么类型的句柄。
- 右键点击进程,选择
Properties - 切换到
Handles标签页 - 点击
Type列,按类型排序 - 观察哪种类型的句柄数量异常多
在我们的案例中,File类型的句柄有10万多个,明显不正常。进一步查看发现,这些句柄都是串口设备的句柄。
4.5 第四步:定位具体的代码
现在我们知道是串口句柄泄漏了,接下来要找到是哪里的代码没有释放串口。
- 右键点击一个泄漏的串口句柄,选择
Stack - Process Explorer会显示这个句柄被创建时的调用栈
- 从调用栈中找到你自己的代码
在我们的案例中,调用栈显示:
kernel32.dll!CreateFileA+0x1a System.IO.Ports.dll!System.IO.Ports.SerialStream.CreateFile+0x8c System.IO.Ports.dll!System.IO.Ports.SerialStream..ctor+0x14d System.IO.Ports.dll!System.IO.Ports.SerialPort.Open+0x64 MyApp.dll!MyApp.Plc.PlcCommunicator.Reconnect+0x2f问题找到了:PlcCommunicator.Reconnect方法每次重连都会创建一个新的SerialPort对象并调用Open(),但从来没有调用过Close()和Dispose(),导致旧的串口句柄永远不会被释放。
4.5 第五步:修复并验证
修复代码非常简单,在创建新的SerialPort之前,先释放旧的:
// 错误的写法publicvoidReconnect(){_serialPort=newSerialPort("COM1",9600);_serialPort.Open();}// 正确的写法publicvoidReconnect(){if(_serialPort!=null){if(_serialPort.IsOpen){_serialPort.Close();}_serialPort.Dispose();_serialPort=null;}_serialPort=newSerialPort("COM1",9600);_serialPort.Open();}修复后,重新运行程序,观察Process Explorer中的句柄数,发现重连时句柄数不再上涨,问题解决。
五、托管内存泄漏的排查方法
如果是托管内存泄漏,排查方法也类似:
- 右键点击进程,选择
.NET->Heap - Process Explorer会显示所有托管对象的类型和数量
- 点击
Count列,按数量排序 - 观察哪种对象的数量异常多
- 右键点击对象类型,选择
Instances,查看具体的对象实例 - 右键点击对象实例,选择
References,查看引用链,找到是谁在引用这个对象
最常见的托管泄漏是静态集合和事件订阅,比如:
// 泄漏:事件订阅后没有取消publicvoidBadSubscribe(){varplc=newPlcCommunicator();plc.DataReceived+=Plc_DataReceived;}// 正确:不再使用时取消事件订阅publicvoidGoodSubscribe(){varplc=newPlcCommunicator();plc.DataReceived+=Plc_DataReceived;// 使用完后取消订阅plc.DataReceived-=Plc_DataReceived;}六、工业上位机最常见的8个内存泄漏坑
根据我8年的工业开发经验,总结了工业上位机最常见的8个内存泄漏坑,90%的泄漏都出现在这些地方。
坑1:非托管资源没有释放
- 表现:句柄数持续上涨,非托管内存泄漏
- 常见资源:SerialPort、TcpClient、FileStream、Bitmap、COM对象
- 修复:遵循IDisposable模式,使用using语句
坑2:事件订阅后没有取消
- 表现:托管内存泄漏,对象无法被回收
- 修复:谁订阅谁取消,使用弱事件
坑3:静态集合无限增长
- 表现:托管内存持续上涨,永远不会下降
- 修复:给静态集合设置最大容量,定期清理过期元素
坑4:Timer没有释放
- 表现:线程数持续上涨,内存泄漏
- 修复:不再使用时调用Timer.Dispose()
坑5:匿名方法和Lambda表达式导致的引用
- 表现:短生命周期对象被长生命周期对象引用,无法回收
- 修复:避免在长生命周期对象中使用匿名方法引用短生命周期对象
坑6:线程泄漏
- 表现:线程数持续上涨,CPU和内存占用升高
- 修复:使用线程池,不要手动创建无限循环的线程
坑7:GDI对象泄漏
- 表现:GDI对象数持续上涨,最终导致界面卡死
- 修复:所有GDI对象(Pen、Brush、Bitmap)使用完后必须Dispose
坑8:第三方库泄漏
- 表现:使用某个第三方库后内存持续上涨
- 修复:升级到最新版本,或者替换为其他库
七、预防内存泄漏的最佳实践
最好的排查方法就是从源头预防内存泄漏。
7.1 编码规范
- 所有实现了IDisposable接口的对象,都必须使用using语句
- 谁订阅事件谁取消事件
- 尽量少用静态变量,尤其是静态集合
- 不要在循环中创建大量临时对象
- 及时释放不再使用的资源
7.2 测试规范
- 所有项目必须进行72小时压力测试
- 测试过程中用Process Explorer监控内存和句柄数
- 每次版本发布前都要进行内存泄漏测试
7.3 运行时监控
- 在程序中加入内存和句柄监控功能
- 当内存或句柄数超过阈值时,自动报警
- 定期生成内存快照,便于事后排查
八、实战案例:从3天崩溃一次到连续运行30天无故障
回到开头的那个项目,原来的PLC通信模块每次重连都会泄漏一个串口句柄,运行3天就会泄漏10万多个句柄,导致系统崩溃。
我们不仅修复了串口句柄泄漏的问题,还对整个系统进行了全面的内存泄漏排查,修复了以下问题:
- 图像采集模块每次采集都会创建一个新的Bitmap,没有释放
- MES通信模块每次请求都会创建一个新的HttpClient,没有释放
- 日志模块的静态集合没有清理,无限增长
- 多个Timer没有释放,导致线程泄漏
修复后,系统连续运行了30天,内存稳定在200MB左右,句柄数稳定在1200左右,没有出现任何崩溃。客户非常满意,一次性续签了3年的维护合同。
九、总结
内存泄漏不是玄学,也不是无法解决的难题。很多开发者觉得内存泄漏难排查,只是因为没有用对工具和方法。
Process Explorer是工业现场排查内存泄漏的神器,它绿色免安装,不用重启程序,不用修改代码,就能快速定位内存泄漏的根源。配合本文介绍的排查流程和最佳实践,你也能轻松解决C#上位机的内存泄漏问题,让你的系统真正实现7×24小时不间断运行。
记住:工业系统的稳定性,藏在每一个容易被忽略的细节里。一个小小的Dispose,就能避免几十万的停线损失。
👉 点击我的头像进入主页,关注专栏第一时间收到更新提醒,有问题评论区交流,看到都会回。
