Reputation: 413
A customer asked me to find out why their C# application (we'll call it XXX, delivered by a consultant who has fled the scene) is so flaky, and fix it. The application controls a measurement device over a serial connection. Sometimes the device delivers continuous readings (which are displayed on screen), and sometimes the app needs to stop continuous measurements and go into command-response mode.
For continuous measurements, XXX uses System.Timers.Timer
for background processing of serial input. When the timer fires, C# runs the timer's ElapsedEventHandler
using some thread from its pool. XXX's event handler uses a blocking commPort.ReadLine()
with a several second timeout, then calls back to a delegate when a useful measurement arrives on the serial port. This portion works fine, however...
When its time to stop realtime measurements and command the device to do something different, the application tries to suspend background processing from the GUI thread by setting the timer's Enabled = false
. Of course, that just sets a flag preventing further events, and a background thread already waiting for serial input continues waiting. The GUI thread then sends a command to the device, and tries to read the reply – but the reply is received by the background thread. Now the background thread becomes confused as its not the expected measurement. The GUI thread meanwhile becomes confused as it didn't receive the command reply expected. Now we know why XXX is so flaky.
In another similar application, I used a System.ComponentModel.BackgroundWorker
thread for free-running measurements. To suspend background processing I did two things in the GUI thread:
CancelAsync
method on the thread, andcommPort.DiscardInBuffer()
, which causes a pending (blocked, waiting) comport read in the background thread to throw a System.IO.IOException "The I/O operation has been aborted because of either a thread exit or an application request.\r\n"
. In the background thread I catch this exception and clean up promptly, and all works as intended. Unfortunately DiscardInBuffer
provoking the exception in another thread's blocking read is not documented behavior anywhere I can find, and I hate relying on undocumented behavior. It works because internally DiscardInBuffer
calls the Win32 API PurgeComm, which interrupts the blocking read (documented behavior).
Directly use the BaseClass Stream.ReadAsync
method, with a monitor cancellation token, using a supported way of interrupting the background IO.
Because the number of characters to be received is variable (terminated by a newline), and no ReadAsyncLine
method exists in the framework, I don't know if this is possible. I could process each character individually but would take a performance hit (might not work on slow machines, unless of course the line-termination bit is already implemented in C# within the framework).
Create a lock "I've got the serial port". Nobody reads, writes, or discards input from the port unless they have the lock (including repeating the blocking read in background thread). Chop the timeout values in the background thread to 1/4 second for acceptable GUI responsiveness without too much overhead.
Does anybody have a proven solution to deal with this problem?
How can one cleanly stop background processing of the serial port?
I've googled and read dozens of articles bemoaning the C# SerialPort
class, but haven't found a good solution.
Thanks in advance!
Upvotes: 5
Views: 4397
Reputation: 413
OK, here's what I did... Comments would be appreciated as C# is still somewhat new to me!
Its crazy to have multiple threads trying to access the serial port concurrently (or any resource, especially an asynchronous resource). To fix up this application without a complete rewrite, I introduced a lock SerialPortLockObject
to guarantee exclusive serial port access as follows:
SerialPortLockObject
except when it has a background operation running.SerialPort
class is wrapped so that any read or write by a thread not holding SerialPortLockObject
throws an exception (helped find several contention bugs).SerialOperationTimer
) so that the background worker function is called bracketed by acquiring SerialPortLockObject
.
SerialOperationTimer
allows only one timer running at a time (helped find several bugs where the GUI forgot to stop background processing before starting up a different timer). This could be improved by using a specific thread for timer work, with that thread holding the lock for the entire time the timer is active (but would be still more work; as coded System.Timers.Timer
runs worker function from thread pool).SerialPortLockObject
is reacquired by the GUI thread.Here's the wrapper for 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); }
}
And here's the wrapper for 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
Upvotes: 0
Reputation: 28356
MSDN article for the SerialPort
Class clearly states:
If a
SerialPort
object becomes blocked during a read operation, do not abort the thread. Instead, either close the base stream or dispose of theSerialPort
object.
So the best approach, from my point of view, is second one, with async
reading and step by step checking for the line-ending character. As you've stated, the check for each char is very big performance loss, I suggest you to investigate the ReadLine
implementation for some ideas how to perform this faster. Note that they use NewLine
property of SerialPort
class.
I want also to note that there is no ReadLineAsync
method by default as the MSDN states:
By default, the
ReadLine
method will block until a line is received. If this behavior is undesirable, set theReadTimeout
property to any non-zero value to force theReadLine
method to throw aTimeoutException
if a line is not available on the port.
So, may be, in your wrapper you can implement similar logic, so your Task
will cancel if there is no line end in some given time. Also, you should note this:
Because the
SerialPort
class buffers data, and the stream contained in theBaseStream
property does not, the two might conflict about how many bytes are available to read. TheBytesToRead
property can indicate that there are bytes to read, but these bytes might not be accessible to the stream contained in theBaseStream
property because they have been buffered to theSerialPort
class.
So, again, I suggest you to implement some wrapper logic with asynchronous read and checking after each read, are there line-end or not, which should be blocking, and wrap it inside async
method, which will cancel Task
after some time.
Hope this helps.
Upvotes: 2