C#中读取SerialPort时如何避免线程泄漏?
看起来你碰到了两个棘手的问题:一是SerialPort的DataReceived事件触发后,线程池线程数量持续上涨,甚至能到2万+;二是不得不靠Thread.Sleep(50)来凑齐完整的串口响应,总觉得这方法不靠谱。我来一步步帮你解决这两个问题。
一、先搞定线程泄漏的核心原因
你怀疑Thread.Sleep(50)是问题根源,其实猜得没错——但不是因为线程没返回线程池,而是Sleep会阻塞线程池线程,导致线程池无法及时复用这些线程,只能不断新建线程。
线程池的线程本来是用来处理短任务的,你每次在DataReceived里让线程睡50ms,相当于把这个线程“占着茅坑不拉屎”。当串口数据频繁触发事件时,线程池里可用的线程很快被占满,它就会新建更多线程来处理新的请求。虽然这些线程最后会被回收,但回收有延迟,长期积累下来,线程数自然就蹭蹭往上涨了。
所以解决线程泄漏的关键,就是去掉那个Sleep,改用非阻塞的方式读取完整数据——这刚好也能解决你第二个头疼的问题。
二、不用Sleep,怎么可靠读取完整的串口数据?
串口数据是流式的,DataReceived事件可能只触发了一部分数据,所以不能每次触发就读取所有现有数据就完事,得自己维护缓冲区,直到凑齐一整帧有效数据。具体怎么做,取决于你的设备通信协议:
方案1:根据结束符判断(比如CRLF)
如果你的设备返回的数据是以固定结束符(比如你代码里的sgCRLF)结尾的,那可以这样处理:
- 给每个SerialPort关联一个私有缓冲区(注意线程安全,因为DataReceived是在后台线程触发的)
- 每次DataReceived触发时,把读取到的字符追加到缓冲区
- 检查缓冲区里是否包含结束符,如果包含,就从缓冲区里提取从开头到结束符的完整帧,剩下的留在缓冲区里等待后续数据
举个简化的例子:
// 给每个串口维护一个线程安全的缓冲区 private readonly Dictionary<SerialPort, StringBuilder> _portBuffers = new Dictionary<SerialPort, StringBuilder>(); private void SerialDataReceivedHandler(object sender, SerialDataReceivedEventArgs e) { SerialPort sp = (SerialPort)sender; if (sp == null || !sp.IsOpen) return; // 确保当前串口有对应的缓冲区 lock (_portBuffers) { if (!_portBuffers.ContainsKey(sp)) { _portBuffers[sp] = new StringBuilder(); } } try { // 读取当前可用的所有数据 string newData = sp.ReadExisting(); lock (_portBuffers[sp]) { _portBuffers[sp].Append(newData); } // 检查是否有完整的帧(这里假设结束符是CRLF) ProcessCompleteFrames(sp); } catch (IOException ex) { // 异常处理逻辑... SerialPort port = (SerialPort)sender; string msg = $"[ERROR] SerialDataReceived {port?.PortName ?? "[unknown port]"} - {ex.Message}"; Debug.WriteLine(msg); TDUtils.WriteLog(msg); } } private void ProcessCompleteFrames(SerialPort sp) { StringBuilder buffer = _portBuffers[sp]; lock (buffer) { string endMarker = "\r\n"; // 替换成你实际的结束符 int endIndex; while ((endIndex = buffer.ToString().IndexOf(endMarker)) != -1) { // 提取完整的帧 string fullFrame = buffer.ToString().Substring(0, endIndex + endMarker.Length); // 移除已处理的帧 buffer.Remove(0, endIndex + endMarker.Length); // 在这里处理你的完整帧数据(就是你原来在Sleep之后做的逻辑) HandleReceivedFrame(fullFrame, sp); } } } private void HandleReceivedFrame(string fullFrame, SerialPort sp) { Scales? scales = GetScalesFromSerialPort(sp); if (scales == null) return; int dataLen = fullFrame.Length; bool isC320 = scales.isC320; // 原来的帧判断和处理逻辑直接搬过来就行 if (dataLen >= 17 && fullFrame.StartsWith(isC320 ? Scales.C300_RECEIVED_GROSS : Scales.R300_RECEIVED_GROSS)) { string hexWeight = fullFrame.Substring(9, 8); if (int.TryParse(hexWeight, System.Globalization.NumberStyles.HexNumber, null, out int weightInt)) { UpdateWeight(scales.raceNumber, (double)weightInt / 10d); } else { Debug.WriteLine($"Scales ({scales.raceNumber}) weight data: (invalid)"); } } else if (dataLen >= 15 && fullFrame.StartsWith(isC320 ? Scales.C300_RECEIVED_ZERO : Scales.R300_RECEIVED_ZERO)) { Debug.WriteLine($"[DEBUG] Read Zero from race {scales.raceNumber} dataLen: {dataLen}"); } else { Debug.WriteLine($"[DATA] (unknown) {fullFrame} from serialport on race {scales.raceNumber}"); } }
方案2:根据帧长度判断
如果你的设备返回的每帧数据长度是固定的,或者可以通过帧头里的字段获取长度,那可以:
- 同样维护缓冲区
- 先读取足够的字节数来获取帧长度
- 等缓冲区里的数据长度达到帧长度时,再提取完整帧处理
额外注意事项
- 线程安全:因为DataReceived是在后台线程触发的,操作缓冲区时一定要加锁(或者用线程安全的集合,比如
ConcurrentQueue),避免多线程同时修改导致数据错乱。 - 避免重复订阅事件:看你的
Init方法里每次都先-=再+=事件处理,这没问题,但要确保不会多次调用Init导致不必要的操作。 - 线程池线程的回收:当你去掉Sleep后,DataReceived事件处理会变成短任务,线程池线程处理完就会返回池里复用,不会再被长时间占用,线程数自然就稳定下来了。
三、其他可能的线程泄漏排查点
如果去掉Sleep后线程数还是上涨,那可以检查:
- 有没有在其他地方(比如
BeginWeighing或者UpdateWeight)创建了新线程却没正确回收? - 是不是串口设备频繁发送数据,导致DataReceived触发太频繁?但只要处理逻辑是短任务,线程池应该能应付。
按照上面的方法改完,你应该能看到线程数稳定下来,而且不用再靠Sleep来凑数据了。
备注:内容来源于stack exchange,提问作者smstrong

