1

I'm using C# in Visual Studio 2019. I seem not to be able to correctly run and cancel a task. I'm sure I am missing the point, but reading a lot of articles only creates more questions.

EDIT --> For a shorter and easier to understand example, see further below.

I am using a SerialPort and want it to continuously read data. My class COMDevice has some public properties that I can use to test with.

public Task _RxTask;
public CancellationTokenSource _ctsRxTask = new CancellationTokenSource();
public CancellationToken _ctRxTask;

After the initialization process, I start a async method that continuously reads the SerialPort.

public bool Initialize()
{
  ... Do some initialization stuff
  _ctRxTask = _ctsRxTask.Token;
  _RxTask = Task.Run(() => ReceiveCommandsAsync(_ctRxTask));

  Debug.WriteLine($"Initialize _RxTask.Status = {_RxTask.Status}");
}

The ReceiveCommandsAsync method is shown below. Don't bother too much with the processing of the received data (it basically checks if the sequence "A\n" or "[Command]\n" is received).

private async Task ReceiveCommandsAsync(CancellationToken ct)
{
    if (ct.IsCancellationRequested)
    {
        Debug.WriteLine("ct.IsCancellationRequested 1");
        return;
    }

    var buffer = new byte[1024];
    StringBuilder cmdReceived = new StringBuilder("", 256);

    try
    {
        while (_serialPort.IsOpen)
        {
            Debug.WriteLine($"ReceiveCommandAsync 1 _RxTask.Status = {_RxTask.Status}");

            int cnt = await _serialPort.BaseStream.ReadAsync(buffer, 0, 1024).ConfigureAwait(true);
            Debug.WriteLine($"ReceiveCommandAsync 2 _RxTask.Status = {_RxTask.Status}");
            cntMax = Math.Max(cnt, cntMax);

            for (int i = 0; i < cnt; i++)
            {
                if ((char)buffer[i] == '\n')
                {
                    if ((cmdReceived.Length == 1) && (cmdReceived[0] == 'A'))
                        mreAck.Set();
                    else if (cmdReceived.Length != 0)
                    { 
                        // Check if command matches
                        string s = cmdReceived.ToString().Substring(4);
                        if (!_RegisteredCmds.Contains(s))
                            Debug.WriteLine($"{DateTime.Now.ToString("HH:mm:ss:fff")} ReceiveCommandsAsync: cmd {cmdReceived} not found");
                    }
                    cmdReceived.Clear();
                }
                else
                    cmdReceived.Append((char)buffer[i]);
            }

            if (ct.IsCancellationRequested)
            {
                Debug.WriteLine("ct.IsCancellationRequested 2");
                return;
            }
        }
    }
    catch (Exception ex)
    {
        Debug.WriteLine($"{DateTime.Now.ToString("HH:mm:ss:fff")} ReceiveCommandsAsync: Exception {ex}");
    }
}

There are a few issues:

First, the Debug.WriteLines showing the Task.Status are continuously showing "WaitingForActivation". But my task is running, because when my connected device sends data, the data is received. Shouldn't I see "Running" instead?

Second, in a test form, I'm closing the COMDevice with below code:

private async void btnClose_ClickAsync(object sender, EventArgs e)
{
    _COMDevice?._ctsRxTask.Cancel();
    await Task.Delay(1000);
    _COMDevice?.Close();
    _COMDevice = null;
}

But ct.IsCancellationRequested in my ReceiveCommandsAsync is never triggered. In the contrary, I seem to receive an Exception:

20:12:02:446 ReceiveCommandsAsync: Exception System.IO.IOException: The I/O operation has been aborted because of either a thread exit or an application request.

I'm sure I'm doing a lot of things wrong here!

EDIT - Additional Test

I created a much more simplified test. A simple Windows Form application, which has 3 buttons. One to start the Task, one to show the Status, and one to stop the Task.

The good news is that I can stop the task with the CancellationToken, so I will investigate why this works, and the above example doesn't.

But if I show the status, I'm continuously seeing "WaitingForActivation". I still don't understand why this is not "Running". And after the task has been cancelled, the status shows "RanToCompletion", and not "Cancelled".

private void btnTestTask_Click(object sender, EventArgs e)
{
    cancellationToken = cancellationTokenSource.Token;

    myTask = Task.Run(() => Test(cancellationToken));
}

private void btnTestCancel_Click(object sender, EventArgs e)
{
    cancellationTokenSource.Cancel();
}

private void btnShowStatus_Click(object sender, EventArgs e)
{
    Debug.WriteLine($"Task Status: {myTask.Status}");
}

async static Task Test(CancellationToken cancellationToken)
{
    while (true)
    {
        try
        {
            Debug.WriteLine("The task is running");
            await Task.Delay(1000, cancellationToken);
        }
        catch (TaskCanceledException)
        {
            Debug.WriteLine("The task is cancelled");
            break;
        }
    }
}
Hans Billiet
  • 91
  • 1
  • 11
  • "WaitingForActivation" would always show up, as you log that message right after Task.Run, so your task hasn't had the chance to run – Sten Petrov Mar 24 '22 at 19:24
  • string interpolation with format: `$"Date: {DateTime.Now:dd/MM/yyyy}. blah blah"` – Sten Petrov Mar 24 '22 at 19:27
  • using a class member like `_serialPort` (and a few other) in an async task is also asking for trouble in the future, pass the instance to the method call instead – Sten Petrov Mar 24 '22 at 19:29
  • Have you tried passing CancellationToken to `ReadAsync`? I'm guessing your problem is that your Task is awaiting ReadAsync which maybe throws if the underlying stream is closed while it's waiting. – IllusiveBrian Mar 24 '22 at 19:37
  • @StenPetrov I also call it continuously just before and after the BaseStream.ReadAsync, and also there it keeps showing "WaitingForActivation". – Hans Billiet Mar 24 '22 at 21:00
  • @StenPetrov What do you mean with "blah blah". Am I doing something wrong? – Hans Billiet Mar 24 '22 at 21:01
  • @StenPetrov If somebody has better ideas on how to do Serial communication, whether it is using SerialPort or not, then please don't hesitate to explain. What exactly do you mean with "pass the instance to the method call instead"? – Hans Billiet Mar 24 '22 at 21:02
  • @IllusiveBrian I didn't even realize that I could pass the CancellationToken to the ReadAsync method. I tried, but unfortunately, seems the same result. Still the same Exception. After all, what I'm trying to achieve, is that so exceptional? Seems not so obvious after all, but can somebody tell me why? – Hans Billiet Mar 24 '22 at 21:07
  • 1
    I think that this thread explains it all: https://stackoverflow.com/questions/20830998/async-always-waitingforactivation – Hans Billiet Mar 25 '22 at 07:19
  • 1
    In your second simplified example, you could try replacing the `break;` with `throw;`. Does this solve the problem of the final status being `RanToCompletion` instead of `Canceled`? – Theodor Zoulias Mar 25 '22 at 12:26
  • 1
    @HansBilliet I suggest you post this code in CodeReview - there are too many things to nitpick here. When making a parallel task, it is best to reduce its external dependencies - all the data should be passed into the call, so it is guaranteed to not change. For example, the task doesn't know if the stream is the same - the class can decide to change the stream. Same goes for all other class properties/fields. Use `Task ReadCommands(Stream stream, RegisteredCommands commands)` or something similar – Sten Petrov Mar 25 '22 at 16:11
  • @StenPetrov Thanks for the advice! Sorry, but I'm not so super familiar with StackOverflow, so what do you mean with "post this code in CodeReview"? – Hans Billiet Mar 25 '22 at 16:39
  • @HansBilliet https://codereview.stackexchange.com/ . There are a number of "stack" sites with different themes. You can find them by clicking the hamburger-like icon on top right – Sten Petrov Mar 25 '22 at 20:42

1 Answers1

1

First, the Debug.WriteLines showing the Task.Status are continuously showing "WaitingForActivation". But my task is running, because when my connected device sends data, the data is received. Shouldn't I see "Running" instead?

But if I show the status, I'm continuously seeing "WaitingForActivation". I still don't understand why this is not "Running". And after the task has been cancelled, the status shows "RanToCompletion", and not "Cancelled".

There are two types of tasks: Delegate Tasks and Promise Tasks. Delegate tasks represent some (synchronous) code that is executed in some context. Promise tasks just represent some kind of "completion" that will happen. Asynchronous code generally uses Promise tasks. Specifically, tasks returned from async methods are always Promise tasks, as are tasks returned from Task.Run used with a Task-returning delegate.

Since Promise tasks don't actually have a delegate (i.e., synchronous code) and just represent the completion of something, they never enter the Running state. WaitingForActivation is normal. More detail on my blog.

But ct.IsCancellationRequested in my ReceiveCommandsAsync is never triggered. In the contrary, I seem to receive an Exception

Actually, I expect that IsCancellationRequested is being set just fine. The problem is that the code in your loop is sitting at ReadAsync, waiting for the next byte to come in the serial port. If no byte comes in after you set the cancellation token, then your code will just end up (asynchronously) waiting at the ReadAsync and never actually check IsCancellationRequested.

Ideally, you would solve this by passing the CancellationToken down rather than doing explicit checks. I.e., pass ct right through to ReadAsync. This is the "90% case" for handling cancellation that I describe on my blog. That way the CancellationToken will cancel the ReadAsync call itself, very similarly to how the CancellationToken cancels the Task.Delay in your simplified example.

I say "ideally" because that is how it is supposed to work. However, I suspect this may not work. Not every method that takes a CancellationToken actually respects that token. Specifically, I suspect this may not work because SerialPort is an ancient class and older versions of .NET supported non-NT Windows versions which did not have a way to cancel specific I/O calls, so unless SerialPort has been updated to respect the CancellationToken in ReadAsync, I suspect it may just ignore it.

And in that case, your only real solution is to just close the serial port, which cancels all I/O operations on that port as a side effect. (I'll be covering this technique in a future blog post in my Cancellation series). This is a cancellation although it's not represented by an OperationCanceledException - again, because SerialPort is much, much older than OperationCanceledException and Microsoft has a very high backwards-compatibility bar.

Sorry for the wall of text; the TL;DR is:

  1. Try passing CancellationToken to ReadAsync.
  2. If that doesn't work, just close the port instead of using a cancellation token, and ignore any exceptions that happen after closing the port.
Stephen Cleary
  • 437,863
  • 77
  • 675
  • 810
  • Thanks a lot for this complete answer. I will spend some time reading your blog - looks interesting! It indeed looks like the CancellationToken is completely ignored by the SerialPort.BaseStream class. I don't understand why the ReadAsync has a signature including that CancellationToken - very misleading. Anyway, I dumped all the CancellationToken stuff, and just close the port ignoring the Exceptions created. – Hans Billiet Mar 26 '22 at 17:32
  • 1
    `ReadAsync` is on the base `Stream` type. That's why it's on the signature. The serial port-specific `Stream` derived type just doesn't implement the cancellation. :/ It's been a long time now since I've used the .NET `SerialPort` type, but way back in the day when I needed to do serial port programming, I ended up writing my own wrapper over the Win32 methods. – Stephen Cleary Mar 26 '22 at 19:40
  • Ah, that explains it! My implementation is currently just working fine, but I would love to see that wrapper. Is this anywhere public? – Hans Billiet Mar 27 '22 at 06:41
  • At the end, I'm even wondering why I should use the Async methods at all. If both my Tx and Rx tasks are running in some background thread from the threadpool, they are not blocking anything, right? I just used the non-async methods, and my implementation works as good as before. Does this make sense? – Hans Billiet Mar 27 '22 at 09:39
  • If you want to see the full code of both my synchronous and asynchronous versions, please find them here https://codereview.stackexchange.com/questions/275287/what-is-better-using-serialport-with-or-without-await-async-methods – Hans Billiet Mar 27 '22 at 09:56
  • 1
    @HansBilliet: No, it's proprietary software I wrote for an employer many years ago. I do recommend having reads going all the time (even while writing), but as long as you're doing that then there isn't much difference between sync and async. – Stephen Cleary Mar 27 '22 at 12:17