C# - 如何交接哪个线程从串口读取数据?

5

背景

一位客户让我找出为什么他们的C#应用程序(我们称之为XXX,由一名已经离开现场的顾问交付)如此不稳定,并修复它。该应用程序通过串行连接控制测量设备。有时设备会提供连续读数(显示在屏幕上),有时应用程序需要停止连续测量并进入命令响应模式。

如何

对于连续测量,XXX使用System.Timers.Timer进行后台处理串行输入。当计时器触发时,C#使用其池中的某个线程运行计时器的ElapsedEventHandler。XXX的事件处理程序使用带有几秒超时的阻塞commPort.ReadLine(),然后在串行端口上接收到有用的测量时回调委托。这部分工作正常,但是...

当停止实时测量并命令设备执行其他操作时,应用程序尝试通过设置计时器的Enabled = false来暂停GUI线程中的后台处理。当然,这只是设置了一个标志以防止进一步事件,而已经在等待串行输入的后台线程继续等待。然后,GUI线程发送一个命令到设备,并尝试读取回复 - 但是回复被后台线程接收。现在后台线程变得困惑了,因为它不是预期的测量值。同时,GUI线程变得困惑了,因为它没有收到预期的命令回复。现在我们知道XXX为什么如此不稳定。

可能的方法1

在另一个类似的应用程序中,我使用System.ComponentModel.BackgroundWorker线程进行自由运行测量。要暂停后台处理,我在GUI线程中执行了两件事:

  1. 调用线程上的CancelAsync方法,和
  2. 调用commPort.DiscardInBuffer(),这会导致后台线程中的挂起(阻塞,等待)comport读取抛出System.IO.IOException "The I/O operation has been aborted because of either a thread exit or an application request.\r\n"

在后台线程中,我捕获此异常并及时清理,一切都按预期工作。不幸的是,我无法在任何地方找到文档记录DiscardInBuffer在另一个线程的阻塞读取中引发异常的行为,我讨厌依赖未记录的行为。它能够工作是因为内部DiscardInBuffer调用了Win32 API PurgeComm,这会中断阻塞读取(记录在文档中)。

可能的方法2

直接使用BaseClass Stream.ReadAsync方法,使用监视取消令牌,使用支持的方式中断后台IO。

由于要接收的字符数是可变的(以换行符结尾),并且框架中不存在ReadAsyncLine方法,我不知道这是否可能。我可以逐个处理每个字符,但会影响性能(在慢速机器上可能无法工作,除非当然C#中的框架已经实现了行终止位)。

可能的方法3

创建一个锁定“我已经获得了串口”的锁。除非他们拥有该锁(包括在后台线程中重复阻塞读取),否则没有人会读取、写入或丢弃端口输入。将后台线程的超时值切割为1/4秒,以便在不太耗费资源的情况下保持GUI的响应性。

问题

有人有解决这个问题的有效方法吗?如何干净地停止串口的后台处理?我已经谷歌搜索并阅读了数十篇文章,对C# SerialPort类进行了抱怨,但没有找到好的解决方案。

先感谢您的帮助!


你没有专注于真正的问题,它是System.Timers.Timer。摆脱它,使用同步计时器代替。 - Hans Passant
抱歉,汉斯,我不明白。可能的方法1-3都没有使用System.Timers.Timer; 你在暗示什么? - Dave Nadler
2个回答

2

SerialPort 类的 MSDN 文章明确指出:

如果 SerialPort 对象在读操作期间被阻塞,不要中止线程。相反,要么关闭基本流,要么处理 SerialPort 对象

因此,从我的角度来看,最好的方法是第二种方法,使用异步读取并逐步检查行结束字符。正如您所述,每个字符的检查会导致非常大的性能损失,我建议您调查一下ReadLine 实现以获取一些更快速执行此操作的想法。请注意,他们使用 SerialPort 类的 NewLine 属性。

我还想指出,默认情况下没有ReadLineAsync方法,如MSDN所述

默认情况下,ReadLine方法将阻塞直到接收到一行数据。如果不希望出现这种行为,请将ReadTimeout属性设置为任何非零值,以强制ReadLine方法在端口上没有可用行时抛出TimeoutException异常。

因此,在您的包装器中,您可以实现类似的逻辑,以便在某个给定时间内没有结束行时,您的Task将被取消。此外,您应该注意以下内容:

由于SerialPort类缓冲数据,而包含在BaseStream属性中的流不会缓冲数据,因此两者可能会发生关于可读取字节数的冲突。 BytesToRead属性可以指示有可读取的字节,但是这些字节可能对于包含在BaseStream属性中的流不可访问,因为它们已被缓冲到SerialPort类中。

所以,我建议您实现一些包装逻辑,使用异步读取并在每次读取之后检查是否有行尾符,这应该是阻塞的,并将其包装在async方法中,在一定时间后取消Task

希望这可以帮助您。


0

好的,这就是我做的... 由于C#对我来说还有些陌生,评论将不胜感激!

让多个线程同时访问串口(或任何资源,特别是异步资源)是不可行的。为了在不完全重写应用程序的情况下修复它,我引入了一个锁SerialPortLockObject来保证串口的独占访问,如下所示:

  • GUI线程除了在有后台操作运行时,持有SerialPortLockObject
  • SerialPort类被包装,以便任何非持有SerialPortLockObject的线程读取或写入时都会抛出异常(有助于发现几个争用错误)。
  • 定时器类被包装(SerialOperationTimer类),使得后台工作函数被调用时都会通过获取SerialPortLockObject进行括号封闭。 SerialOperationTimer 一次只允许运行一个计时器(有助于发现GUI在启动不同计时器之前忘记停止后台处理的多个bug)。这可以通过为计时器工作使用特定的线程,并使该线程在计时器处于活动状态时持有锁来改进(但这仍需要更多工作;按编码方式,System.Timers.Timer从线程池运行工作函数)。
  • 当停止 SerialOperationTimer 时,它会禁用基础计时器并刷新串口缓冲区(引发任何被阻塞的串口操作的异常,如上述可能的方法1所解释)。然后GUI线程重新获取 SerialPortLockObject

这是SerialPort的包装器:

/// <summary> CheckedSerialPort class checks that read and write operations are only performed by the thread owning the lock on the serial port </summary>
// Just check reads and writes (not basic properties, opening/closing, or buffer discards). 
public class CheckedSerialPort : SafePort /* derived in turn from SerialPort */
{
    private void checkOwnership()
    {
        try
        {
            if (Monitor.IsEntered(XXX_Conn.SerialPortLockObject)) return; // the thread running this code has the lock; all set!
            // Ooops...
            throw new Exception("Serial IO attempted without lock ownership");
        }
        catch (Exception ex)
        {
            StringBuilder sb = new StringBuilder("");
            sb.AppendFormat("Message: {0}\n", ex.Message);
            sb.AppendFormat("Exception Type: {0}\n", ex.GetType().FullName);
            sb.AppendFormat("Source: {0}\n", ex.Source);
            sb.AppendFormat("StackTrace: {0}\n", ex.StackTrace);
            sb.AppendFormat("TargetSite: {0}", ex.TargetSite);
            Console.Write(sb.ToString());
            Debug.Assert(false); // lets have a look in the debugger NOW...
            throw;
        }
    }
    public new int ReadByte()                                       { checkOwnership(); return base.ReadByte(); }
    public new string ReadTo(string value)                          { checkOwnership(); return base.ReadTo(value); }
    public new string ReadExisting()                                { checkOwnership(); return base.ReadExisting(); }
    public new void Write(string text)                              { checkOwnership(); base.Write(text); }
    public new void WriteLine(string text)                          { checkOwnership(); base.WriteLine(text); }
    public new void Write(byte[] buffer, int offset, int count)     { checkOwnership(); base.Write(buffer, offset, count); }
    public new void Write(char[] buffer, int offset, int count)     { checkOwnership(); base.Write(buffer, offset, count); }
}

这里是 System.Timers.Timer 的包装器:

/// <summary> Wrap System.Timers.Timer class to provide safer exclusive access to serial port </summary>
class SerialOperationTimer
{
    private static SerialOperationTimer runningTimer = null; // there should only be one!
    private string name;  // for diagnostics
    // Delegate TYPE for user's callback function (user callback function to make async measurements)
    public delegate void SerialOperationTimerWorkerFunc_T(object source, System.Timers.ElapsedEventArgs e);
    private SerialOperationTimerWorkerFunc_T workerFunc; // application function to call for this timer
    private System.Timers.Timer timer;
    private object workerEnteredLock = new object();
    private bool workerAlreadyEntered = false;

    public SerialOperationTimer(string _name, int msecDelay, SerialOperationTimerWorkerFunc_T func)
    {
        name = _name;
        workerFunc = func;
        timer = new System.Timers.Timer(msecDelay);
        timer.Elapsed += new System.Timers.ElapsedEventHandler(SerialOperationTimer_Tick);
    }

    private void SerialOperationTimer_Tick(object source, System.Timers.ElapsedEventArgs eventArgs)
    {
        lock (workerEnteredLock)
        {
            if (workerAlreadyEntered) return; // don't launch multiple copies of worker if timer set too fast; just ignore this tick
            workerAlreadyEntered = true;
        }
        bool lockTaken = false;
        try
        {
            // Acquire the serial lock prior calling the worker
            Monitor.TryEnter(XXX_Conn.SerialPortLockObject, ref lockTaken);
            if (!lockTaken)
                throw new System.Exception("SerialOperationTimer " + name + ": Failed to get serial lock");
            // Debug.WriteLine("SerialOperationTimer " + name + ": Got serial lock");
            workerFunc(source, eventArgs);
        }
        finally
        {
            // release serial lock
            if (lockTaken)
            {
                Monitor.Exit(XXX_Conn.SerialPortLockObject);
                // Debug.WriteLine("SerialOperationTimer " + name + ": released serial lock");
            }
            workerAlreadyEntered = false;
        }
    }

    public void Start()
    {
        Debug.Assert(Form1.GUIthreadHashcode == Thread.CurrentThread.GetHashCode()); // should ONLY be called from GUI thread
        Debug.Assert(!timer.Enabled); // successive Start or Stop calls are BAD
        Debug.WriteLine("SerialOperationTimer " + name + ": Start");
        if (runningTimer != null)
        {
            Debug.Assert(false); // Lets have a look in the debugger NOW
            throw new System.Exception("SerialOperationTimer " + name + ": Attempted 'Start' while " + runningTimer.name + " is still running");
        }
        // Start background processing
        // Release GUI thread's lock on the serial port, so background thread can grab it
        Monitor.Exit(XXX_Conn.SerialPortLockObject);
        runningTimer = this;
        timer.Enabled = true;
    }

    public void Stop()
    {
        Debug.Assert(Form1.GUIthreadHashcode == Thread.CurrentThread.GetHashCode()); // should ONLY be called from GUI thread
        Debug.Assert(timer.Enabled); // successive Start or Stop calls are BAD
        Debug.WriteLine("SerialOperationTimer " + name + ": Stop");

        if (runningTimer != this)
        {
            Debug.Assert(false); // Lets have a look in the debugger NOW
            throw new System.Exception("SerialOperationTimer " + name + ": Attempted 'Stop' while not running");
        }
        // Stop further background processing from being initiated,
        timer.Enabled = false; // but, background processing may still be in progress from the last timer tick...
        runningTimer = null;
        // Purge serial input and output buffers. Clearing input buf causes any blocking read in progress in background thread to throw
        //   System.IO.IOException "The I/O operation has been aborted because of either a thread exit or an application request.\r\n"
        if(Form1.xxConnection.PortIsOpen) Form1.xxConnection.CiCommDiscardBothBuffers();
        bool lockTaken = false;
        // Now, GUI thread needs the lock back.
        // 3 sec REALLY should be enough time for background thread to cleanup and release the lock:
        Monitor.TryEnter(XXX_Conn.SerialPortLockObject, 3000, ref lockTaken);
        if (!lockTaken)
            throw new Exception("Serial port lock not yet released by background timer thread "+name);
        if (Form1.xxConnection.PortIsOpen)
        {
            // Its possible there's still stuff in transit from device (for example, background thread just completed
            // sending an ACQ command as it was stopped). So, sync up with the device...
            int r = Form1.xxConnection.CiSync();
            Debug.Assert(r == XXX_Conn.CI_OK);
            if (r != XXX_Conn.CI_OK)
                throw new Exception("Cannot re-sync with device after disabling timer thread " + name);
        }
    }

    /// <summary> SerialOperationTimer.StopAllBackgroundTimers() - Stop all background activity </summary>
    public static void StopAllBackgroundTimers()
    {
        if (runningTimer != null) runningTimer.Stop();
    }

    public double Interval
    {
        get { return timer.Interval; }
        set { timer.Interval = value; }
    }

} // class SerialOperationTimer

你的想法是正确的,但是将你锁定的对象公开是一种反模式,而且有很好的理由(因为任何代码都可以劫持监视器)。相反,我会创建一个自定义的SafePort类型,它是线程安全的,并执行自己的锁定,并重构所有代码,强制访问裸端口必须使用该SafePort类型的公共方法(所有锁定内容都是私有的)。更加简洁,无需验证对监视器的访问,这看起来非常落后等。有关C#和.NET中的线程原语的更多信息,请查看此优秀资源:www.albahari.com/threading/ - Mahol25

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接