C# 串口关闭时主界面卡死原因分析

简介: 串口程序关闭导致界面卡死的原因是主线程与辅助线程间的死锁。问题出在`SerialPort.Close()`方法与`DataReceived`事件处理程序。`DataReceived`事件在`lock (stream)`块中执行,而`Close()`方法会关闭`SerialStream`并锁定自身。当辅助线程处理数据并尝试更新UI时,UI线程因调用`Close()`被阻塞,造成死锁。解决办法是让`DataReceived`事件处理程序使用`this.BeginInvoke()`异步更新界面,避免等待UI线程,从而防止死锁。

问题描述

前几天用SerialPort类写一个串口的测试程序,关闭串口的时候会让界面卡死。
参考博客windows程序界面卡死的原因,得出界面卡死原因:主线程和其他的线程由于资源或者锁争夺,出现了死锁。

参考知乎文章WinForm界面假死,如何判断其卡在代码中的哪一步?,通过点击调试暂停,查看ui线程函数栈,直接定位阻塞代码的行数,确定问题出现在SerialPort类的Close()方法。

参考文章C# 串口操作系列(2) -- 入门篇,为什么我的串口程序在关闭串口时候会死锁 ?文章的解决方法和网上的大部分解决方法类似:定义2个bool类型的标记Listening和Closing,关闭串口和接受数据前先判断一下。我个人并不太接受这种方法,感觉还有更好的方式,而且文章讲述的也并不太清楚。

查找原因

基于刨根问底的原则,我继续查找问题发生的原因。
先看看导致界面卡死的代码:

void comm_DataReceived(object sender, SerialDataReceivedEventArgs e)   
{
      
    //获取串口读取的字节数
    int n = comm.BytesToRead;    
    //读取缓冲数据  
    comm.Read(buf, 0, n);       
    //因为要访问ui资源,所以需要使用invoke方式同步ui。   
    this.Invoke(new Action(() =>{
   ...界面更新,略})); 
}   

private void buttonOpenClose_Click(object sender, EventArgs e)   
{
      
    //根据当前串口对象,来判断操作   
    if (comm.IsOpen)   
    {
      
        //打开时点击,则关闭串口   
        comm.Close();//界面卡死的原因
    }   
    else  
    {
   ...}  
}

问题就出现在上面的代码中,原理目前还不明确,我只能参考.NET源码来查找问题。

SerialPort类Open()方法

SerialPort类Close()方法的源码如下:

        public void Open()
        {
   
           //省略部分代码...
            internalSerialStream = new SerialStream(portName, baudRate, parity, dataBits, stopBits, readTimeout,
                writeTimeout, handshake, dtrEnable, rtsEnable, discardNull, parityReplace);

            internalSerialStream.SetBufferSizes(readBufferSize, writeBufferSize); 
            internalSerialStream.ErrorReceived += new SerialErrorReceivedEventHandler(CatchErrorEvents);
            internalSerialStream.PinChanged += new SerialPinChangedEventHandler(CatchPinChangedEvents);
            internalSerialStream.DataReceived += new SerialDataReceivedEventHandler(CatchReceivedEvents);
        }

每次执行SerialPort类Open()方法都会出现实例化一个SerialStream类型的对象,并将CatchReceivedEvents事件处理程序绑定到SerialStream实例的DataReceived事件。

SerialStream类CatchReceivedEvents方法的源码如下:

        private void CatchReceivedEvents(object src, SerialDataReceivedEventArgs e)
        {
   
            SerialDataReceivedEventHandler eventHandler = DataReceived;
            SerialStream stream = internalSerialStream;

            if ((eventHandler != null) && (stream != null)){
   
                lock (stream) {
   
                    bool raiseEvent = false;
                    try {
   
                        raiseEvent = stream.IsOpen && (SerialData.Eof == e.EventType || BytesToRead >= receivedBytesThreshold);    
                    }
                    catch {
   
                        // Ignore and continue. SerialPort might have been closed already! 
                    }
                    finally {
   
                        if (raiseEvent)
                            eventHandler(this, e);  // here, do your reading, etc. 
                    }
                }
            }
        }

可以看到SerialStream类CatchReceivedEvents方法触发自身的DataReceived事件,这个DataReceived事件就是我们处理串口接收数据的用到的事件。

DataReceived事件处理程序是在lock (stream) {...}块中执行的,ErrorReceived 、PinChanged 也类似。

SerialPort类Close()方法

SerialPort类Close()方法的源码如下:

        // Calls internal Serial Stream's Close() method on the internal Serial Stream.
        public void Close()
        {
   
            Dispose();
        }

        public void Dispose() {
   
            Dispose(true);
            GC.SuppressFinalize(this);
        }
        protected override void Dispose( bool disposing )
        {
   
            if( disposing ) {
   
                if (IsOpen) {
   
                    internalSerialStream.Flush();
                    internalSerialStream.Close();
                    internalSerialStream = null;
                }
            }
            base.Dispose( disposing );
        }

可以看到,执行Close()方法最终会调用Dispose( bool disposing )方法。
微软SerialPort类对父类的Dispose( bool disposing )方法进行了重写,在执行base.Dispose( disposing )前会执行internalSerialStream.Close()方法,也就是说SerialPort实例执行Close()方法时会先关闭SerialPort实例内部的SerialStream实例,再执行父类的Close()操作

base.Dispose( disposing )方法不作为重点,我们再看internalSerialStream.Close()方法。

SerialStream类源码没有找到Close()方法,说明没有重写父类的Close方法,直接看父类的Close()方法,源码如下:

        public virtual void Close()
        {
   
            Dispose(true);
            GC.SuppressFinalize(this);
        }

SerialStream父类的Close方法调用了Dispose(true),不过SerialStream类重写了父类的Dispose(bool disposing)方法,源码如下:

        protected override void Dispose(bool disposing)
        {
   
            if (_handle != null && !_handle.IsInvalid) {
   
                try {
   
                //省略一部分代码
                }
                finally {
   
                    // If we are disposing synchronize closing with raising SerialPort events
                    if (disposing) {
   
                        lock (this) {
   
                            _handle.Close();
                            _handle = null;
                        }
                    }
                    else {
   
                        _handle.Close();
                        _handle = null;
                    }
                    base.Dispose(disposing);
                }
            }
        }

SerialStream父类的Close方法调用了Dispose(true),上面的代码一定会执行到lock (this) 语句,也就是说SerialStream实例执行Close()方法时会lock自身

死锁原因

把我们前面源码分析的结果总结一下:

  • DataReceived事件处理程序是在lock (stream) {...}块中执行的
  • SerialPort实例执行Close()方法时会先关闭SerialPort实例内部的SerialStream实例
  • SerialStream实例执行Close()方法时会lock实例自身

当辅助线程调用DataReceived事件处理程序处理串口数据但还未更新界面时,点击界面“关闭”按钮调用SerialPort实例的Close()方法,UI线程会在lock(stream)处一直等待辅助线程释放stream的线程锁。
当辅助线程处理完数据准备更新界面时问题来了,DataReceived事件处理程序中的this.Invoke()一直会等待UI线程来执行委托,但此时UI线程还停在SerialPort实例的Close()方法处等待DataReceived事件处理程序执行完成。
此时,线程死锁发生,两边都执行不下去了。

解决死锁

网上大多数方法都是定义2个bool类型的标记Listening和Closing,关闭串口和接受数据前先判断一下。
我的方法是DataReceived事件处理程序用this.BeginInvoke()更新界面,不等待UI线程执行完委托就返回,stream的线程锁会很快释放,SerialPort实例的Close()方法也无需等待。

总结

问题最终的答案其实很简单,但我在查阅.NET源码查找问题源头的过程中收获了很多。这是我第一次这么深入的查看.NET源码,发现这种解决问题的方法还是很有用处的。结果不重要,解决问题的方法是最重要的。

相关文章
|
9月前
|
开发框架 .NET C#
利用WinDbg分析C#程序产生的转储文件
利用WinDbg分析C#程序产生的转储文件
|
5天前
|
编译器 API C#
技术心得记录:深入分析C#键盘勾子(Hook)拦截器,屏蔽键盘活动的详解
技术心得记录:深入分析C#键盘勾子(Hook)拦截器,屏蔽键盘活动的详解
|
2月前
|
安全 算法 测试技术
C#编程实战:项目案例分析
【4月更文挑战第20天】本文以电子商务系统为例,探讨C#在实际项目中的应用。通过面向对象编程实现组件抽象和封装,确保代码的可维护性和可扩展性;利用安全性特性保护用户数据;借助数据库操作处理商品信息;通过逻辑控制和算法处理订单;调试工具加速问题解决,展现C#的优势:面向对象、数据库交互、数据安全和开发效率。C#在实际编程中展现出广泛前景。
|
jenkins 关系型数据库 MySQL
一文搞定SonarQube接入C#(.NET)代码质量分析
一文搞定SonarQube接入C#(.NET)代码质量分析
1330 0
一文搞定SonarQube接入C#(.NET)代码质量分析
|
存储 C# 开发工具
C#编程的构成要素(结合unity做实例分析)
C#编程的构成要素(结合unity做实例分析)
C#编程的构成要素(结合unity做实例分析)
基于C#的ArcEngine二次开发42:空间分析接口及分析(ITopologicalOperator / IRelationalOperator / IProximityOperator)(三)
基于C#的ArcEngine二次开发42:空间分析接口及分析(ITopologicalOperator / IRelationalOperator / IProximityOperator)
基于C#的ArcEngine二次开发42:空间分析接口及分析(ITopologicalOperator / IRelationalOperator / IProximityOperator)(三)
基于C#的ArcEngine二次开发42:空间分析接口及分析(ITopologicalOperator / IRelationalOperator / IProximityOperator)(二)
基于C#的ArcEngine二次开发42:空间分析接口及分析(ITopologicalOperator / IRelationalOperator / IProximityOperator)
基于C#的ArcEngine二次开发42:空间分析接口及分析(ITopologicalOperator / IRelationalOperator / IProximityOperator)(二)
|
搜索推荐 C# 索引
基于C#的ArcEngine二次开发42:空间分析接口及分析(ITopologicalOperator / IRelationalOperator / IProximityOperator)(一)
基于C#的ArcEngine二次开发42:空间分析接口及分析(ITopologicalOperator / IRelationalOperator / IProximityOperator)
基于C#的ArcEngine二次开发42:空间分析接口及分析(ITopologicalOperator / IRelationalOperator / IProximityOperator)(一)
|
存储 缓存 API
基于C#的ArcEngine二次开发36: 在地理数据库中创建要素类的接口及方法分析(下)
基于C#的ArcEngine二次开发36: 在地理数据库中创建要素类的接口及方法分析(下)
基于C#的ArcEngine二次开发36: 在地理数据库中创建要素类的接口及方法分析(下)
|
存储 NoSQL C#
基于C#的ArcEngine二次开发36: 在地理数据库中创建要素类的接口及方法分析(上)
基于C#的ArcEngine二次开发36: 在地理数据库中创建要素类的接口及方法分析
基于C#的ArcEngine二次开发36: 在地理数据库中创建要素类的接口及方法分析(上)