项目背景与痛点
2025年底我接手了一个汽车零部件产线的监控项目,用C# WPF写的工控机程序,对接12台三菱FX5U PLC,每秒要采集2500个左右的尺寸、温度、压力数据点。程序刚上线时跑得挺稳,延迟控制在50ms以内,内存占用也只有200MB出头。但运行了三周后,问题开始集中爆发:
一是数据采集卡顿,延迟从50ms一路涨到600ms,导致部分关键尺寸数据漏采,产线不得不停了两次来返工;二是内存泄漏,程序运行24小时后内存直接飙到2.3GB,最后自动崩溃重启,夜班工人怨声载道。
当时客户给了一周时间必须解决问题,我只好沉下心来,用工具一点点定位,最终把这两个顽疾都搞定了。今天把整个调优过程分享出来,都是踩过坑的实战经验。
问题定位:先拿工具说话,别瞎优化
很多人一遇到性能问题就开始改代码,其实这是大忌。我当时先上了两个工具:dotTrace看CPU占用,dotMemory看内存分布,很快就找到了问题根源。
dotTrace定位卡顿原因
打开dotTrace attach到进程,跑了10分钟采集数据,发现两个热点:
- 数据处理模块的一个
foreach循环占了32%的CPU时间——原来我是单线程逐个处理2500个数据点,每个点还要做单位转换和阈值判断,不卡才怪。 - UI更新占了28%的CPU时间——我当时图省事,每个数据点都触发一次
PropertyChanged,UI线程每秒要刷新2500次,直接被堵死了。
dotMemory定位内存泄漏
再用dotMemory抓了两个内存快照(一个刚启动,一个运行24小时后),对比发现:
- 有12万个
byte[]数组没释放,总大小1.2GB——原来我的PLC连接类每次读数据都new byte[256],用完没释放Socket资源,也没复用数组。 - 有5000多个
DataPoint对象被事件引用着——UI窗口订阅了数据处理模块的OnDataReceived事件,窗口关闭时我忘了取消订阅,导致整个窗口和里面的控件都没法被GC回收。
解决方案一:实时数据采集卡顿优化
1.1 线程池与Task调度优化
原来我为了“实时”,给每个PLC都开了一个Thread,12个线程来回切换,上下文切换开销特别大。改成Task.Run用线程池后,又做了两个优化:
- 设置
ThreadPool.SetMinThreads(50, 50),避免线程池因为线程增长延迟导致任务排队。 - 用
LongRunning选项标记采集Task,让线程池给它分配单独的线程,避免阻塞其他短任务。
// 优化前:每个PLC开一个ThreadnewThread(()=>CollectData(plcIp)){IsBackground=true}.Start();// 优化后:用Task.Run + 线程池配置ThreadPool.SetMinThreads(50,50);Task.Factory.StartNew(()=>CollectData(plcIp),TaskCreationOptions.LongRunning);1.2 数据处理并行化
单线程处理2500个数据点太慢,我改成了Parallel.ForEach,但要注意线程安全:
- 用
ConcurrentQueue<DataPoint>存采集到的原始数据,避免锁竞争。 - 并行处理时只做计算,不更新UI,处理完的结果再批量丢给UI线程。
这里给大家画个数据采集优化后的流程图:
1.3 UI更新优化
原来每个数据点都触发PropertyChanged,UI线程根本扛不住。我改成了批量更新:
- 用
DispatcherTimer每50ms触发一次UI更新,把这50ms内处理好的数据一次性绑定到界面。 - 用
BindingOperations.EnableCollectionSynchronization让ObservableCollection支持跨线程访问,避免锁。
// 优化后:批量更新UIprivatereadonlyConcurrentQueue<DataPoint>_uiQueue=new();privatereadonlyDispatcherTimer_uiTimer=new(){Interval=TimeSpan.FromMilliseconds(50)};publicMainWindow(){InitializeComponent();_uiTimer.Tick+=(s,e)=>UpdateUiBatch();_uiTimer.Start();}privatevoidUpdateUiBatch(){while(_uiQueue.TryDequeue(outvarpoint)){// 批量添加到ObservableCollectionDataPoints.Add(point);}// 只触发一次PropertyChangedOnPropertyChanged(nameof(DataPoints));}解决方案二:内存泄漏彻底解决
2.1 IDisposable正确实现
原来的PLC连接类没正确释放Socket资源,我重新实现了IDisposable,并且用using语句包裹所有使用场景:
// 正确实现IDisposablepublicclassPlcClient:IDisposable{privateSocket_socket;privatebool_disposed=false;publicvoidDispose(){Dispose(true);GC.SuppressFinalize(this);}protectedvirtualvoidDispose(booldisposing){if(_disposed)return;if(disposing){// 释放托管资源:Socket_socket?.Close();_socket?.Dispose();}_disposed=true;}~PlcClient()=>Dispose(false);}// 使用时一定要用usingusingvarplcClient=newPlcClient(plcIp);plcClient.Connect();2.2 事件订阅取消 + 弱事件模式
事件订阅是C#内存泄漏的重灾区,我做了两层保护:
- 窗口关闭时手动取消事件订阅:
dataProcessor.OnDataReceived -= OnDataReceived; - 用
WeakEventManager实现弱事件模式,即使忘了取消订阅,GC也能回收窗口。
// 弱事件模式:避免事件引用导致内存泄漏publicclassDataProcessor{// 原来的事件定义// public event EventHandler<DataPoint> OnDataReceived;// 改成弱事件publicstaticreadonlyRoutedEventDataReceivedEvent=EventManager.RegisterRoutedEvent("DataReceived",RoutingStrategy.Direct,typeof(EventHandler<DataPoint>),typeof(DataProcessor));publiceventEventHandler<DataPoint>OnDataReceived{add=>WeakEventManager<DataProcessor,DataPoint>.AddHandler(this,nameof(OnDataReceived),value);remove=>WeakEventManager<DataProcessor,DataPoint>.RemoveHandler(this,nameof(OnDataReceived),value);}}2.3 大对象堆(LOH)优化:数组池复用
原来每次读PLC数据都new byte[256],这些数组大于85000字节(其实256字节没到,但我后来有个10KB的数组也在new),会被分配到大对象堆(LOH)。LOH不会被压缩,时间长了就会内存碎片。
改成ArrayPool<byte>.Shared复用数组后,LOH碎片问题彻底解决:
// 优化前:每次new新数组varbuffer=newbyte[10240];// 10KB,会进LOHplcClient.Read(buffer);// 优化后:用数组池复用varpool=ArrayPool<byte>.Shared;varbuffer=pool.Rent(10240);// 从池里借数组try{plcClient.Read(buffer);// 处理数据...}finally{pool.Return(buffer);// 用完还回去,一定要在finally里}这里再给大家画个内存管理的框架图,方便理解:
调优效果对比
折腾了五天,终于把所有优化都上线了,效果可以说是立竿见影:
| 指标 | 优化前 | 优化后 |
|---|---|---|
| 数据采集延迟 | 500-600ms | 25-35ms |
| CPU占用 | 75-85% | 25-35% |
| 内存占用(24h) | 2.3GB | 280MB |
| 内存占用(72h) | 崩溃 | 310MB |
| 产线停机次数(周) | 2次 | 0次 |
客户当时直接给我发了个红包,说终于能睡个安稳觉了。
踩坑总结:这7个坑别再踩
- 别用Thread开大量线程:线程池+Task才是正道,上下文切换开销差10倍都不止。
- 事件订阅一定要取消:最好用弱事件模式,双重保险。
- 大对象一定要复用:ArrayPool是个好东西,LOH碎片真的会搞崩程序。
- UI更新要批量:别每个数据点都刷新,50ms-100ms的延迟人眼根本感觉不到。
- 性能调优前一定要用工具:dotTrace和dotMemory虽然贵,但真的能救命,别瞎猜。
- IDisposable要正确实现:别忘了Finalizer和GC.SuppressFinalize。
- ConcurrentQueue比lock好用:高并发场景下,无锁集合的性能优势太明显了。
👉 点击我的头像进入主页,关注专栏第一时间收到更新提醒,有问题评论区交流,看到都会回。