1

Instead of using conventional threading, I am using async/await to implement a long-running job that will be called from various scenarios such as Desktop/Web/Mobile.

This question is about design considerations when using CancellationTokenSource/CancellationToken objects. Consider the following code written in .NET Core 5:

System
System.Collections.Generic
System.Diagnostics
System.IO
System.Threading
System.Threading.Tasks

[STAThread]
private static async Task Main ()
{
    using (var job = new Job())
    //using (var source = new CancellationTokenSource())
    {
        var watch = Stopwatch.StartNew();

        job.OnJobProgress += (sender, e) => { Console.WriteLine (watch.Elapsed); };

        Task.Run (async () => await job.StartAsync());
        //Task.Run (async () => await job.StartAsync (source.Token));

        do
        {
            await Task.Delay (100);
            if ((Console.KeyAvailable) && (Console.ReadKey ().Key == ConsoleKey.Escape))
            {
                //source.Cancel();
                await job.CancelAsync();
                break;
            }
        }
        while (job.Running);
    }
}

public class Job : IDisposable
{
    public EventHandler OnJobProgress;

    private bool _Running = false;
    private readonly object SyncRoot = new object();
    private CancellationTokenSource CancellationTokenSource = new CancellationTokenSource();

    public bool Running => this._Running;

    public async Task StartAsync () => await this.StartAsync(CancellationToken.None);
    public async Task StartAsync (CancellationToken cancellationToken) => await this.ProcessAsync(cancellationToken);

    public void Cancel ()
    {
        this.CancellationTokenSource?.Cancel();
        do { Thread.Sleep (10); } while (this._Running);
    }

    public async Task CancelAsync ()
    {
        this.CancellationTokenSource?.Cancel();
        do { await Task.Delay (10); } while (this._Running);
    }

    private async Task ProcessAsync (CancellationToken cancellationToken)
    {
        lock (this.SyncRoot)
        {
            if (this._Running) { return; }
            else { this._Running = true; }
        }

        do
        {
            await Task.Delay (100);
            this.OnJobProgress?.Invoke (this, new EventArgs());
        }
        while (!cancellationToken.IsCancellationRequested);

        lock (this.SyncRoot)
        {
            this._Running = false;
            this.CancellationTokenSource?.Dispose();
            this.CancellationTokenSource = new CancellationTokenSource();
        }
    }

    public void Dispose () => this.Cancel();
}

Notice the three commented lines in the Main method as well as the Cancel and CancelAsync methods. My gut says that there should be a locking mechanism in place in the Cancel methods instead of the Process method. Depending on where the CancellationToken comes from, are there any potential deadlocks in this implementation? Somehow, I am not comfortable with the do/while blocking mechanism.

Any thoughts would be appreciated.

AUXILIARY QUESTION: Since CancellationToken is a readonly struct and being passed around by value, how is it that calling Cancel on the CancellationTokenSource modifies the CancellationToken.IsCancellationRequested property? Perhaps that was the source of confusion all along.

Raheel Khan
  • 14,205
  • 13
  • 80
  • 168
  • 2
    `Somehow, I am not comfortable with the do/while blocking mechanism` - so don't use it? Simply `await` your `Task.Run(...)` in `Main`, and then have an unrelated key press handler that flags the cancellation token? – GSerg Apr 26 '21 at 19:37
  • Your gut feeling is correct. This combination of a `bool` flag and a loop that observes this flag is inefficient, introduces latency, and it's most certainly buggy. A thread is not supposed to read a non-[`volatile`](https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/keywords/volatile) variable that is mutated by another thread, without synchronization. The changed value may not be visible by the current thread. You can take a look at this: [C# and thread-safety of a bool](https://stackoverflow.com/questions/29411961/c-sharp-and-thread-safety-of-a-bool). – Theodor Zoulias Apr 26 '21 at 20:12

1 Answers1

2

This is a job for Task.WhenAny. Await the first job to complete from two: either the one you want to really want to complete or the one representing user's impatience by hitting the ESC key or appropriate mobile touch.

Pseudocode:

  • mainTask = Setup main task, take the token as input. That's it.
  • userInterruptTask = Setup user action monitoring task, and in it's continuation or as part of its natural loop's time to end (the ESC key), call Cancel. Note, in this loop, there is NO check against a boolean value; it just goes until it must cancel, and then is done via break/return; the other task goes to done if it is properly listening for cancellation.
  • So, when either task completes, you're done.
var ret = await Task.WhenAny(mainTask, userInterruptTask);

If it matters at this point, get the value of ret and act accordingly. Task.WhenAny returns

A task that represents the completion of one of the supplied tasks. The return task's Result is the task that completed.

For a specific answer to "what is the scope" of the token... its scope is everything that may act on it. Cancellation in TPL is 100% cooperative, so all tasks that care to set cancellation or look for cancellation are in play.

For your auxiliary question, I can understand your confusion. I hadn't thought of it before, myself, but the answer turns out to be simple. The implementation of that property delegates to the token source:

public bool IsCancellationRequested 
    => _source != null && _source.IsCancellationRequested;

where the CancellationTokenSource is a stateful class.

Kit
  • 20,354
  • 4
  • 60
  • 103